Skip to content

Commit

Permalink
issue #221: break azure storage conn string into individual fields
Browse files Browse the repository at this point in the history
  • Loading branch information
k-allagbe committed Jan 6, 2025
1 parent f5c3bd0 commit 059ccce
Show file tree
Hide file tree
Showing 13 changed files with 242 additions and 320 deletions.
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@
secrets.env
.env.secrets
6 changes: 5 additions & 1 deletion config.env → .env.config
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ AZURE_OPENAI_DEPLOYMENT=gpt-4o
ALLOWED_ORIGINS=["*"]

# API base path for deployment
API_BASE_PATH=/swagger
API_BASE_PATH=
SWAGGER_PATH=/docs

# DB
Expand All @@ -22,3 +22,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=
# Other
UPLOAD_PATH=./uploads
# LOG_FILENAME=fertiscan.log

# Azure Storage Configuration
AZURE_STORAGE_DEFAULT_ENDPOINT_PROTOCOL=https
AZURE_STORAGE_ENDPOINT_SUFFIX=core.windows.net
12 changes: 12 additions & 0 deletions .env.secrets.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Document Intelligence
AZURE_API_KEY=

# OpenAI
AZURE_OPENAI_KEY=

# DB
FERTISCAN_DB_URL=

# Azure Storage Configuration
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ celerybeat.pid

# Environments
.env
secrets.env
.env.secrets
.venv
env/
venv/
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ generation using an [LLM](https://en.wikipedia.org/wiki/Large_language_model).
2. Run the Docker container:

```sh
docker run -p 5000:5000 --env-file secrets.env fertiscan-backend
docker run -p 5000:5000 --env-file .env.secrets fertiscan-backend
```

#### Docker Compose

1. Create a `secrets.env` file from
[secrets.env.template](./secrets.env.template). Include the following
1. Create a `.env.secrets` file from
[.env.secrets.template](./.env.secrets.template). Include the following
environment variables:

```ini
Expand Down Expand Up @@ -99,7 +99,7 @@ following details:

### Environment Variables

Create a `secrets.env` file from [secrets.env.template](./secrets.env.template).
Create a `.env.secrets` file from [.env.secrets.template](./.env.secrets.template).

```ini
AZURE_API_ENDPOINT=your_azure_form_recognizer_endpoint
Expand Down
42 changes: 34 additions & 8 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from contextlib import asynccontextmanager
from http import HTTPStatus

from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI, Request
from fastapi.logger import logger
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
Expand All @@ -15,11 +17,13 @@
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from pipeline import GPT, OCR
from psycopg_pool import ConnectionPool
from pydantic import Field, PostgresDsn
from pydantic import Field, PostgresDsn, computed_field
from pydantic_settings import BaseSettings

load_dotenv("secrets.env")
load_dotenv("config.env")
from app.exceptions import log_error

load_dotenv(".env.secrets")
load_dotenv(".env.config")


class Settings(BaseSettings):
Expand All @@ -28,7 +32,10 @@ class Settings(BaseSettings):
base_path: str = Field("", alias="api_base_path")
fertiscan_db_url: PostgresDsn
fertiscan_schema: str
fertiscan_storage_url: str
azure_storage_account_name: str
azure_storage_account_key: str
azure_storage_default_endpoint_protocol: str
azure_storage_endpoint_suffix: str
openai_api_deployment: str = Field(alias="azure_openai_deployment")
openai_api_endpoint: str = Field(alias="azure_openai_endpoint")
openai_api_key: str = Field(alias="azure_openai_key")
Expand All @@ -38,10 +45,20 @@ class Settings(BaseSettings):
allowed_origins: list[str]
otel_exporter_otlp_endpoint: str = Field(alias="otel_exporter_otlp_endpoint")

@computed_field
@property
def azure_storage_connection_string(self) -> str:
return (
f"DefaultEndpointsProtocol={self.azure_storage_default_endpoint_protocol};"
f"AccountName={self.azure_storage_account_name};"
f"AccountKey={self.azure_storage_account_key};"
f"EndpointSuffix={self.azure_storage_endpoint_suffix}"
)


@asynccontextmanager
async def lifespan(app: FastAPI):
settings = app.state.settings
settings: Settings = app.settings
app.pool.open()
resource = Resource.create(
{
Expand Down Expand Up @@ -77,11 +94,11 @@ async def lifespan(app: FastAPI):
tracer_provider.shutdown()


def create_app(settings: Settings):
def create_app(settings: Settings, router: APIRouter, lifespan=None):
app = FastAPI(
lifespan=lifespan, docs_url=settings.swagger_path, root_path=settings.base_path
)
app.state.settings = settings
app.settings = settings

app.add_middleware(
CORSMiddleware,
Expand Down Expand Up @@ -109,4 +126,13 @@ def create_app(settings: Settings):
)
app.gpt = gpt

app.include_router(router)

@app.exception_handler(Exception)
async def global_exception_handler(_: Request, e: Exception):
log_error(e)
return JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content=str(e)
)

return app
25 changes: 2 additions & 23 deletions app/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from functools import lru_cache
from http import HTTPStatus

from fastapi import Depends, File, HTTPException, Request, UploadFile
Expand All @@ -7,44 +6,30 @@
from psycopg_pool import ConnectionPool

from app.config import Settings

from app.controllers.users import sign_in
from app.exceptions import UserNotFoundError
from app.models.users import User

auth = HTTPBasic()


@lru_cache
def get_settings():
return Settings()
def get_settings(request: Request) -> Settings:
return request.app.settings


def get_connection_pool(request: Request) -> ConnectionPool:
"""
Returns the app's connection pool.
"""
return request.app.pool


def get_ocr(request: Request) -> OCR:
"""
Returns the app's OCR instance.
"""
return request.app.ocr


def get_gpt(request: Request) -> GPT:
"""
Returns the app's GPT instance.
"""
return request.app.gpt


def authenticate_user(credentials: HTTPBasicCredentials = Depends(auth)):
"""
Authenticates a user.
"""
if not credentials.username:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Missing email address!"
Expand All @@ -57,9 +42,6 @@ async def fetch_user(
auth_user: User = Depends(authenticate_user),
cp: ConnectionPool = Depends(get_connection_pool),
) -> User:
"""
Fetches the authenticated user's info from db.
"""
try:
return await sign_in(cp, auth_user)
except UserNotFoundError:
Expand All @@ -69,9 +51,6 @@ async def fetch_user(


def validate_files(files: list[UploadFile] = File(..., min_length=1)):
"""
Validates uploaded files.
"""
for f in files:
if f.size == 0:
raise HTTPException(
Expand Down
154 changes: 3 additions & 151 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,152 +1,4 @@
from http import HTTPStatus
from typing import Annotated
from uuid import UUID
from app.config import Settings, create_app, lifespan
from app.routes import router

from fastapi import Depends, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, RedirectResponse
from pipeline import GPT, OCR
from psycopg_pool import ConnectionPool

from app.config import Settings, create_app
from app.controllers.data_extraction import extract_data
from app.controllers.inspections import (
create_inspection,
delete_inspection,
read_all_inspections,
read_inspection,
update_inspection,
)
from app.controllers.users import sign_up
from app.dependencies import (
authenticate_user,
fetch_user,
get_connection_pool,
get_gpt,
get_ocr,
get_settings,
validate_files,
)
from app.exceptions import InspectionNotFoundError, UserConflictError, log_error
from app.models.inspections import (
DeletedInspection,
Inspection,
InspectionData,
InspectionUpdate,
)
from app.models.label_data import LabelData
from app.models.monitoring import HealthStatus
from app.models.users import User
from app.sanitization import custom_secure_filename

app = create_app(get_settings())


@app.exception_handler(Exception)
async def global_exception_handler(_: Request, e: Exception):
log_error(e)
return JSONResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content=str(e))


@app.get("/", tags=["Home"])
async def home():
return RedirectResponse(url=app.docs_url)


@app.get("/health", tags=["Monitoring"], response_model=HealthStatus)
async def health_check():
return HealthStatus()


@app.post("/analyze", response_model=LabelData, tags=["Pipeline"])
async def analyze_document(
ocr: Annotated[OCR, Depends(get_ocr)],
gpt: Annotated[GPT, Depends(get_gpt)],
settings: Annotated[Settings, Depends(get_settings)],
files: Annotated[list[UploadFile], Depends(validate_files)],
):
file_dict = {custom_secure_filename(f.filename): f.file for f in files}
return extract_data(file_dict, ocr, gpt, settings.upload_folder)


@app.post("/signup", tags=["Users"], status_code=201, response_model=User)
async def signup(
cp: Annotated[ConnectionPool, Depends(get_connection_pool)],
user: Annotated[User, Depends(authenticate_user)],
settings: Annotated[Settings, Depends(get_settings)],
):
try:
return await sign_up(cp, user, settings.fertiscan_storage_url)
except UserConflictError:
raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="User exists!")


@app.post("/login", tags=["Users"], status_code=200, response_model=User)
async def login(user: User = Depends(fetch_user)):
return user


@app.get("/inspections", tags=["Inspections"], response_model=list[InspectionData])
async def get_inspections(
cp: Annotated[ConnectionPool, Depends(get_connection_pool)],
user: User = Depends(fetch_user),
):
return await read_all_inspections(cp, user)


@app.get("/inspections/{id}", tags=["Inspections"], response_model=Inspection)
async def get_inspection(
cp: Annotated[ConnectionPool, Depends(get_connection_pool)],
user: Annotated[User, Depends(fetch_user)],
id: UUID,
):
try:
return await read_inspection(cp, user, id)
except InspectionNotFoundError:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found"
)


@app.post("/inspections", tags=["Inspections"], response_model=Inspection)
async def post_inspection(
cp: Annotated[ConnectionPool, Depends(get_connection_pool)],
user: Annotated[User, Depends(fetch_user)],
settings: Annotated[Settings, Depends(get_settings)],
label_data: Annotated[LabelData, Form(...)],
files: Annotated[list[UploadFile], Depends(validate_files)],
):
# Note: later on, we might handle label images as their own domain
label_images = [await f.read() for f in files]
conn_string = settings.fertiscan_storage_url
return await create_inspection(cp, user, label_data, label_images, conn_string)


@app.put("/inspections/{id}", tags=["Inspections"], response_model=Inspection)
async def put_inspection(
cp: Annotated[ConnectionPool, Depends(get_connection_pool)],
user: Annotated[User, Depends(fetch_user)],
id: UUID,
inspection: InspectionUpdate,
):
try:
return await update_inspection(cp, user, id, inspection)
except InspectionNotFoundError:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found"
)


@app.delete("/inspections/{id}", tags=["Inspections"], response_model=DeletedInspection)
async def delete_inspection_(
cp: Annotated[ConnectionPool, Depends(get_connection_pool)],
user: Annotated[User, Depends(fetch_user)],
settings: Annotated[Settings, Depends(get_settings)],
id: UUID,
):
try:
conn_string = settings.fertiscan_storage_url
return await delete_inspection(cp, user, id, conn_string)
except InspectionNotFoundError:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found"
)
app = create_app(Settings(), router, lifespan)
Loading

0 comments on commit 059ccce

Please sign in to comment.