Skip to content
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
13 changes: 13 additions & 0 deletions .github/workflows/code_quality.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Code Quality Checks

on:
pull_request:
branches:
- main
workflow_dispatch:

jobs:
code-quality:
uses: k-candidate/gha-workflows/.github/workflows/python-code-quality.yaml@main
with:
python-version: '3.14'
35 changes: 35 additions & 0 deletions .github/workflows/docker-bake.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Bake Docker Image

on:
workflow_dispatch:
inputs:
version:
description: 'Version (auto-filled on release)'
required: false
default: ''
release:
types: [published]

jobs:
determine-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set-version.outputs.version }}
steps:
- id: set-version
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
else
echo "version=${{ github.event.inputs.version || 'manually-triggered' }}" >> $GITHUB_OUTPUT
fi

bake:
needs: determine-version
uses: k-candidate/gha-workflows/.github/workflows/docker-bake.yaml@main
secrets:
dockerhub_username: ${{ secrets.DOCKERHUB_USER }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
with:
bake-file: ./docker-bake.hcl
version: ${{ needs.determine-version.outputs.version }}
30 changes: 30 additions & 0 deletions .github/workflows/integration-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Run integration test

on:
pull_request:
branches:
- main
workflow_dispatch:

jobs:
integration:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v7

- name: Install dependencies and dev tools
run: uv sync --locked --all-extras

- name: Install pytest annotate plugin
run: uv pip install pytest-github-actions-annotate-failures

- name: Run integration tests
run: ./scripts/run_tests.sh integration
31 changes: 31 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Release

on:
workflow_dispatch:
push:
branches:
- main

jobs:
release:
name: Release
runs-on: ubuntu-latest
# Skip running release workflow on forks
if: github.repository_owner == 'k-candidate'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0

- name: Release
uses: cycjimmy/semantic-release-action@v4
with:
semantic_version: 23.0.2
extra_plugins: |
@semantic-release/changelog@6.0.3
@semantic-release/git@10.0.1
conventional-changelog-conventionalcommits@7.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 changes: 30 additions & 0 deletions .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Run unit tests

on:
pull_request:
branches:
- main
workflow_dispatch:

jobs:
unit-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v7

- name: Install dependencies and dev tools
run: uv sync --locked --all-extras

- name: Install plugin
run: uv pip install pytest-github-actions-annotate-failures

- name: Run unit tests
run: uv run pytest tests/unit -v
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv/
.pytest_cache/
__pycache__/
*.py[cod]
33 changes: 33 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.10.12
hooks:
- id: uv-lock
name: "Ensure lock file is up-to-date"

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.7
hooks:
- id: ruff
name: "Organize imports"
args: [--select, I, --fix]
- id: ruff-check
name: "Lint with ruff"
args: [--fix]
- id: ruff-format
name: "Format with ruff"

- repo: local
hooks:
- id: mypy
name: "Check for type consistency"
entry: uv run mypy app
language: system
pass_filenames: false

- id: pytest
name: pytest
entry: ./scripts/run_tests.sh all
language: system
pass_filenames: false
always_run: true
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
37 changes: 37 additions & 0 deletions .releaserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"branches": [
"main"
],
"ci": false,
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/github",
{
"successComment": "This ${issue.pull_request ? 'PR is included' : 'issue has been resolved'} in version ${nextRelease.version} :tada:",
"labels": false,
"releasedLabels": false
}
],
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md"
],
"message": "chore(release): version ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM python:3.14-slim

WORKDIR /app

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

# Copy dependency files
COPY pyproject.toml uv.lock ./

# Install dependencies
RUN uv sync --frozen --no-install-project --no-dev

# Copy source code
COPY . .

# Install the project itself
RUN uv sync --frozen --no-dev

EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,47 @@
# fastapi-app
# fastapi-app

A small birthday API using FastAPI, Pydantic, SQLAlchemy, and PostgreSQL.

## Endpoints

PUT /hello/{username}
- body: `{ "dateOfBirth": "YYYY-MM-DD" }`
- validation: username letters only, date before today
- response: 204 No Content

GET /hello/{username}
- response: `200` with message
- `Hello, <username>! Happy birthday!` if today
- `Hello, <username>! Your birthday is in N day(s)` otherwise

## Dev

1. Install dependencies:
`uv sync --locked --all-extras`
2. Install pre commit hooks
`pre-commit install`

## Run locally

1. Install dependencies:
`uv sync --locked --all-extras`
2. Start PostgreSQL:
`docker compose up db -d`
3. Set up `DATABASE_URL` if you are not using the default local container value.
4. Run app:
`uv run uvicorn app.main:app --reload`

## Tests

Run unit tests:
`uv run pytest tests/unit -v`

Run integration tests against the containers:
1. `docker compose up --build -d`
2. `uv run pytest tests/integration -v`

## Manual testing

`docker compose up --build -d`

Go to http://localhost:8000/docs
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .main import app

__all__ = ["app"]
25 changes: 25 additions & 0 deletions app/crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from datetime import date

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from .models import User


async def get_user(session: AsyncSession, username: str) -> User | None:
result = await session.execute(
select(User).where(User.username == username)
)
return result.scalars().first()


async def upsert_user(session: AsyncSession, username: str, dob: date) -> None:
existing_user = await get_user(session, username)
if existing_user:
existing_user.date_of_birth = dob
await session.commit()
return

user = User(username=username, date_of_birth=dob)
session.add(user)
await session.commit()
21 changes: 21 additions & 0 deletions app/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import os
from collections.abc import AsyncIterator

from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)

DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi_app",
)

engine = create_async_engine(DATABASE_URL)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)


async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
Loading
Loading