Skip to content
This repository has been archived by the owner on Dec 31, 2024. It is now read-only.

Feature/fastapi-setup #1

Merged
merged 3 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/ISSUE_TEMPLATE/development_task.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: "Development Task"
description: "This is a template for a development task"
# If you want to enable automatic linking to projects,
# uncomment the following line and replace the project ID
# with the ID of your project.
# projects: ["cmi-dair/1"]
projects: ["cmi-dair/4"]
title: "Task: "
labels: ["task"]
body:
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Create and publish a Docker image

on:
push:
branches:
- main
- "v[0-9]+.[0-9]+.[0-9]+*"

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push-image:
runs-on: ubuntu-latest

permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.11-bullseye

EXPOSE 8000

WORKDIR /app

COPY pyproject.toml poetry.lock ./
COPY src ./src

RUN pip install poetry && \
poetry config virtualenvs.create false && \
poetry install --no-dev --no-interaction --no-ansi && \
rm -rf /root/.cache

CMD ["poetry", "run", "uvicorn", "linguaweb_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "src"]
379 changes: 362 additions & 17 deletions poetry.lock

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ packages = [{include = "linguaweb_api", from = "src"}]

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.1"
pydantic-settings = "^2.0.3"
uvicorn = "^0.24.0.post1"

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.3"
mypy = "^1.6.1"
pre-commit = "^3.5.0"
pytest-cov = "^4.1.0"
ruff = "^0.1.4"
httpx = "^0.25.1"

[tool.poetry.group.docs.dependencies]
pdoc = "^14.1.0"
Expand Down Expand Up @@ -68,8 +72,9 @@ target-version = "py311"
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"ANN101", # self should not be annotated.
"ANN102", # cls should not be annotated.
"ANN101", # Self should never be type annotated.
"ANN102", # cls should never be type annotated.
"B008", # Allow function call in arguments; this is common in FastAPI.
]
fixable = ["ALL"]
unfixable = []
Expand All @@ -85,4 +90,10 @@ line-ending = "auto"
convention = "google"

[tool.ruff.per-file-ignores]
"tests/**/*.py" = []
"tests/**/*.py" = [
"S101", # Allow assets
"ARG", # Unused arguments are common in tests (fixtures).
"FBT", #Allow positional booleans
"SLF001", # Allow private member access.
"INP001", # No need for namespace packages in tests.
]
2 changes: 1 addition & 1 deletion src/linguaweb_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
""".. include:: ../../README.md""" # noqa: D415
"""Main module of the Linguaweb API."""
1 change: 1 addition & 0 deletions src/linguaweb_api/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Core configurations of Linguaweb API."""
47 changes: 47 additions & 0 deletions src/linguaweb_api/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Settings for the API."""
import functools
import logging

import pydantic
import pydantic_settings


class Settings(pydantic_settings.BaseSettings): # type: ignore[valid-type, misc]
"""Settings for the API."""

class Config:
"""Configuration variables for the Settings class."""

prefix = "LW_"

LOGGER_NAME: str = pydantic.Field("LinguaWeb API")
LOGGER_VERBOSITY: int | None = pydantic.Field(
logging.DEBUG,
json_schema_extra={"env": "LOGGER_VERBOSITY"},
)


@functools.lru_cache
def get_settings() -> Settings:
"""Cached fetcher for the API settings.

Returns:
The settings for the API.
"""
return Settings() # type: ignore[call-arg]


def initialize_logger() -> None:
"""Initializes the logger for the API."""
settings = get_settings()
logger = logging.getLogger(settings.LOGGER_NAME)
if settings.LOGGER_VERBOSITY is not None:
logger.setLevel(settings.LOGGER_VERBOSITY)

formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)s - %(funcName)s - %(message)s", # noqa: E501
)

handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
56 changes: 56 additions & 0 deletions src/linguaweb_api/core/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Middleware for the FastAPI application."""
import logging
import uuid
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any

import fastapi

from linguaweb_api.core import config

settings = config.get_settings()
logger = logging.getLogger(settings.LOGGER_NAME)


class RequestLoggerMiddleware: # pylint: disable=too-few-public-methods
"""Middleware that logs incoming requests."""

def __init__(self, app: fastapi.FastAPI) -> None:
"""Initializes a new instance of the RequestLoggerMiddleware class.

Args:
app: The FastAPI instance to apply middleware to.
"""
self.app = app

async def __call__(
self,
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
"""Middleware method that handles incoming HTTP requests.

Args:
scope: The ASGI scope of the incoming request.
receive: A coroutine that receives incoming messages.
send: A coroutine that sends outgoing messages.

"""
if scope["type"] != "http":
await self.app(scope, receive, send)
return

request = fastapi.Request(scope, receive=receive)
request_id = uuid.uuid4()

logger.info(
"Starting request: %s - %s - %s",
request_id,
request.method,
request.url.path,
)

await self.app(scope, receive, send)

logger.info("Finished request: %s.", request_id)
34 changes: 34 additions & 0 deletions src/linguaweb_api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Entrypoint for the API."""
import logging

import fastapi
from fastapi.middleware import cors

from linguaweb_api.core import config, middleware
from linguaweb_api.routers.health import views as health_views

settings = config.get_settings()
LOGGER_NAME = settings.LOGGER_NAME

config.initialize_logger()
logger = logging.getLogger(LOGGER_NAME)

logger.info("Initializing API routes.")
api_router = fastapi.APIRouter(prefix="/api/v1")
api_router.include_router(health_views.router)

logger.info("Starting API.")
app = fastapi.FastAPI()
app.include_router(api_router)

logger.info("Adding middleware.")
logger.debug("Adding CORS middleware.")
app.add_middleware(
cors.CORSMiddleware,
allow_origins="*",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger.debug("Adding request logger middleware.")
app.add_middleware(middleware.RequestLoggerMiddleware)
1 change: 1 addition & 0 deletions src/linguaweb_api/routers/health/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Router for health check endpoints."""
5 changes: 5 additions & 0 deletions src/linguaweb_api/routers/health/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Controller to assess the health of the services."""


async def get_api_health() -> None:
"""Returns the health of the API."""
21 changes: 21 additions & 0 deletions src/linguaweb_api/routers/health/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""View definitions for the health router."""
import logging

import fastapi

from linguaweb_api.core import config
from linguaweb_api.routers.health import controller

settings = config.get_settings()
LOGGER_NAME = settings.LOGGER_NAME

logger = logging.getLogger(LOGGER_NAME)

router = fastapi.APIRouter(prefix="/health", tags=["health"])


@router.get("")
async def get_health() -> None:
"""Returns the health of the API."""
logger.debug("Checking health.")
return await controller.get_api_health()
1 change: 1 addition & 0 deletions tests/endpoint/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Endpoint tests."""
27 changes: 27 additions & 0 deletions tests/endpoint/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Fixtures and configurations for testing the endpoints of the CTK API."""
import enum

import pytest
from fastapi import testclient

from linguaweb_api import main

API_ROOT = "/api/v1"


class Endpoints(str, enum.Enum):
"""Enum class that represents the available endpoints for the API."""

GET_HEALTH = f"{API_ROOT}/health"


@pytest.fixture()
def endpoints() -> type[Endpoints]:
"""Returns the Endpoints enum class."""
return Endpoints


@pytest.fixture()
def client() -> testclient.TestClient:
"""Returns a test client for the API."""
return testclient.TestClient(main.app)
11 changes: 11 additions & 0 deletions tests/endpoint/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Tests for the health endpoints."""
from fastapi import status, testclient

from tests.endpoint import conftest


def test_health(client: testclient.TestClient, endpoints: conftest.Endpoints) -> None:
"""Tests the get health endpoint."""
response = client.get(endpoints.GET_HEALTH)

assert response.status_code == status.HTTP_200_OK