33 Commits
v0.1.1 ... dev

Author SHA1 Message Date
e2432ff347 Merge branch 'dev' of https://git.theprivateserver.de/PHB/semapform_api into dev 2025-12-03 14:54:17 +01:00
e05af11db8 fix(api): enforce query formatting 2025-12-03 14:53:54 +01:00
Gitea CI
9e36a6020e chore: bump pyproject version for dev -> v0.2.0 2025-11-26 13:38:47 +00:00
27347f8249 formatting 2025-11-26 14:14:21 +01:00
400a471c6b add custom words 2025-11-26 14:06:30 +01:00
287735d8db Merge branch 'dev' of https://git.theprivateserver.de/PHB/semapform_api into dev 2025-11-26 11:09:23 +01:00
1df81d5350 chore: bump uv-setup to v7 2025-11-26 11:08:46 +01:00
Gitea CI
6a69efb025 chore: bump pyproject version for dev -> v0.1.4 2025-11-25 13:37:44 +00:00
4f9522d35b feat: add redis cache
All checks were successful
PR tests / build-image (pull_request) Successful in 1m31s
PR tests / smoke-tests (pull_request) Successful in 29s
/ build (pull_request) Has been skipped
2025-11-25 14:27:51 +01:00
4911faf3ae add dev push version update, manual version bump 2025-11-25 11:27:08 +01:00
e0988cf23b lint
Some checks failed
PR tests / build-image (pull_request) Successful in 2m44s
PR tests / smoke-tests (pull_request) Successful in 34s
/ build (pull_request) Failing after 42s
2025-11-25 10:52:59 +01:00
22813bab65 feat: add API prefix for prefixes in url 2025-11-25 10:52:21 +01:00
d6aeabc0a9 new download image
All checks were successful
PR tests / build-image (pull_request) Successful in 1m15s
PR tests / smoke-tests (pull_request) Successful in 37s
/ build (pull_request) Has been skipped
2025-11-25 10:48:41 +01:00
5456f8e740 new upload artifact image
Some checks failed
PR tests / build-image (pull_request) Successful in 1m15s
PR tests / smoke-tests (pull_request) Failing after 31s
2025-11-25 10:46:21 +01:00
26ebe3b40e increase version of upload-artifact
Some checks failed
PR tests / build-image (pull_request) Failing after 1m27s
PR tests / smoke-tests (pull_request) Has been skipped
2025-11-25 10:41:24 +01:00
25bc94dfa5 test: split workflow into parts
Some checks failed
PR tests / build-image (pull_request) Failing after 2m19s
PR tests / smoke-tests (pull_request) Has been skipped
2025-11-25 10:21:23 +01:00
0c251ac807 feat: only install bibapi catalogue dependencies 2025-11-25 10:20:45 +01:00
36f5dd14b5 check
All checks were successful
PR tests / build-and-smoke (pull_request) Successful in 1m4s
/ build (pull_request) Successful in 16m56s
2025-11-25 10:03:13 +01:00
811313d1ef fix: add uvicorn dependency
All checks were successful
PR tests / build-and-smoke (pull_request) Successful in 1m43s
feat: add test_api file
2025-11-25 09:59:24 +01:00
c44dc5a61c test new validation test
All checks were successful
PR tests / build-and-smoke (pull_request) Successful in 54s
2025-11-25 09:52:51 +01:00
c9f139d29c re-add port declaration
All checks were successful
PR tests / build-and-smoke (pull_request) Successful in 47s
2025-11-25 09:49:07 +01:00
6ef463458f use python instead of uv
All checks were successful
PR tests / build-and-smoke (pull_request) Successful in 1m19s
2025-11-25 09:46:46 +01:00
7a74ead335 chore: new service check using signature
Some checks failed
PR tests / build-and-smoke (pull_request) Failing after 1m19s
2025-11-25 09:41:11 +01:00
6f38dd482e chore: only import catalogue if needed
Some checks failed
PR tests / build-and-smoke (pull_request) Failing after 1m20s
2025-11-25 09:23:29 +01:00
e1da6085ed test new workflow
Some checks failed
PR tests / build-and-smoke (pull_request) Failing after 22s
2025-11-25 09:20:15 +01:00
af1ee0ce71 test: lazy initiation of catalogue
Some checks failed
PR tests / build-and-smoke (pull_request) Failing after 2m5s
2025-11-25 09:15:54 +01:00
0e7c45182a chore: add uv, generate requirements.txt for tests
Some checks failed
PR tests / build-and-smoke (pull_request) Failing after 54s
2025-11-25 09:12:51 +01:00
c21afa0776 chore: add custom pypi url
Some checks failed
PR tests / build-and-smoke (pull_request) Failing after 32s
2025-11-25 09:10:03 +01:00
df527b30da feat: add pr test workflow 2025-11-25 09:09:44 +01:00
0cf88113be maintenance: add .venv to dockerignore 2025-11-25 09:03:29 +01:00
0d9ffb8539 chore: replace apk with apt
Some checks failed
/ build (pull_request) Failing after 12m43s
2025-11-25 08:59:46 +01:00
2ee575aff4 test: inspired by other Dockerfile
Some checks failed
/ build (pull_request) Failing after 11m19s
2025-11-25 08:50:07 +01:00
6837d0b4b3 test: add path, switch to uv
Some checks failed
/ build (pull_request) Failing after 11m20s
2025-11-25 08:35:46 +01:00
8 changed files with 347 additions and 73 deletions

View File

@@ -13,3 +13,4 @@ dist/
.gitignore
.env
*.sock
.venv

View File

@@ -58,7 +58,7 @@ jobs:
fetch-tags: true # Fetch all tags (refs/tags)
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install
with:
@@ -75,6 +75,18 @@ jobs:
run: |
git config user.name "Gitea CI"
git config user.email "ci@git.theprivateserver.de"
- name: Determine branch to push to
id: push_branch
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# workflow_dispatch runs on a branch ref like refs/heads/main
BRANCH=${GITHUB_REF#refs/heads/}
else
# for a merged PR use the PR base ref (target branch)
BRANCH=${{ github.event.pull_request.base.ref }}
fi
echo "PUSH_BRANCH=$BRANCH" >> $GITHUB_ENV
- name: Bump version
id: bump
run: |
@@ -133,7 +145,31 @@ jobs:
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
branch: main
- name: Update `dev` branch with new version
run: |
# ensure we have the latest remote refs
git fetch origin dev || true
# switch to dev if it exists remotely, otherwise create it
if git rev-parse --verify origin/dev >/dev/null 2>&1; then
git checkout dev
git pull origin dev
else
git checkout -b dev
fi
# replace the version line inside pyproject.toml
sed -E -i "s/^(version\s*=\s*)\".*\"/\1\"${{ env.VERSION }}\"/" pyproject.toml || true
git add pyproject.toml || true
git commit -m "chore: bump pyproject version for dev -> v${{ env.VERSION }}" || echo "no changes to commit"
- name: Push dev changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: dev
- name: Build Changelog
id: build_changelog
uses: https://github.com/mikepenz/release-changelog-builder-action@v5

View File

@@ -0,0 +1,98 @@
name: PR tests
on:
pull_request:
types: [opened, synchronize, edited, reopened]
jobs:
build-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: (optional) Prepare dependencies
run: |
uv python install --python-version-file pyproject.toml || true
uv sync --all-groups || true
uv add pip || true
uv export --format requirements.txt -o requirements.txt || true
- name: Build image
run: |
docker build -t semapform-api:test-pr .
- name: Save image artifact
run: |
docker save semapform-api:test-pr -o semapform-api.tar
- name: Upload image artifact
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
with:
name: semapform-image
path: semapform-api.tar
retention-days: 1
smoke-tests:
needs: build-image
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download image artifact
uses: christopherhx/gitea-download-artifact@v4
with:
name: semapform-image
- name: Restore image
run: |
docker load -i semapform-api.tar
- name: Start container (background)
run: |
docker run -d --name semapform-test semapform-api:test-pr sleep infinity
- name: Start server in container and smoke test HTTP (in-container)
run: |
set -x
# start the server inside the container (detached)
docker exec -d semapform-test python api_service.py || true
# show container status to aid debugging
docker ps -a --filter name=semapform-test || true
echo "waiting for service to become ready inside container"
set -e
READY=0
for i in $(seq 1 20); do
echo "ready attempt $i"
if docker exec semapform-test python -c 'import urllib.request,sys; urllib.request.urlopen("http://127.0.0.1:8001/health", timeout=1); print("ok")' ; then
READY=1
break
fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "service did not become ready"
docker logs semapform-test --tail 200 || true
exit 1
fi
echo "running test_api.py inside container"
docker exec semapform-test python test_api.py || true
docker logs semapform-test --tail 200 || true
- name: Cleanup container
if: always()
run: |
docker rm -f semapform-test || true

13
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"cSpell.words": [
"buildx",
"Buildx",
"elif",
"gitea",
"Gitea",
"github",
"linux",
"pyproject",
"semapform"
]
}

View File

@@ -1,43 +1,13 @@
FROM python:3.13-slim AS builder
FROM python:3.13.9-slim
# Prevent Python from writing .pyc files and enable unbuffered stdout/stderr
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
RUN apt update
RUN apt upgrade -y
WORKDIR /app
# Install build deps required to build wheels
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt --extra-index-url https://git.theprivateserver.de/api/packages/PHB/pypi/simple/
# Upgrade packaging tools and install runtime dependencies into a separate prefix
COPY requirements.txt ./
RUN pip install --upgrade pip setuptools wheel \
&& pip install --prefix=/install --no-cache-dir \
--extra-index-url https://git.theprivateserver.de/api/packages/PHB/pypi/simple/ \
-r requirements.txt \
&& rm -rf /root/.cache/pip
COPY . .
# Copy application source (only used to include app files in final image)
COPY . /app
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
API_PORT=8001
WORKDIR /app
# Copy installed packages from the builder image into the final image
COPY --from=builder /install /usr/local
# Copy application sources
COPY --from=builder /app /app
EXPOSE 8001
# Run using uvicorn; the app is defined in `api_service.py` as `app`
CMD ["uvicorn", "api_service:app", "--host", "0.0.0.0", "--port", "8001"]
ENTRYPOINT [ "python", "api_service.py" ]

View File

@@ -1,17 +1,91 @@
"""
Lightweight Python API service for signature validation
This can run independently to support the PHP application
"""Lightweight Python API service for signature validation.
This can run independently to support the PHP application.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
import urllib.parse
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from bibapi import catalogue
from fastapi import FastAPI, Query
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
# Avoid importing heavy modules at top-level to keep `import api_service` lightweight
from fastapi import FastAPI, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
app = FastAPI(title="Signature Validation API")
CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL", str(72 * 3600)))
REDIS_URL = os.getenv("REDIS_URL", "")
redis_client = None
@asynccontextmanager
async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Lifespan handler: connect to Redis on startup and close on shutdown."""
global redis_client # type: ignore[PLW0603]
if REDIS_URL:
try:
import redis.asyncio as aioredis
redis_client = aioredis.from_url(REDIS_URL)
try:
pong = redis_client.ping()
if asyncio.iscoroutine(pong) or asyncio.isfuture(pong):
pong = await pong
if not pong:
logging.exception("redis ping failed")
redis_client = None
except Exception:
logging.exception("redis ping failed")
redis_client = None
except Exception:
logging.exception("failed to create redis client")
redis_client = None
yield
if redis_client is not None:
try:
await redis_client.close()
except Exception:
logging.exception("failed to close redis client")
app = FastAPI(title="Signature Validation API", lifespan=_lifespan)
# Optional path prefix support: when behind a reverse-proxy that uses a
# URL prefix (eg. `https://api.example.tld/library/...`) set `API_PREFIX` to
# that prefix (example: `/library`) so incoming requests are rewritten to the
# application root. This keeps route definitions unchanged while supporting
# both proxied and direct deployments.
_api_prefix_raw = os.getenv("API_PREFIX", "").strip()
api_prefix = ""
if _api_prefix_raw:
if not _api_prefix_raw.startswith("/"):
_api_prefix_raw = "/" + _api_prefix_raw
api_prefix = _api_prefix_raw.rstrip("/")
@app.middleware("http")
async def _strip_api_prefix(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
if api_prefix and request.url.path.startswith(api_prefix):
new_path = request.url.path[len(api_prefix) :]
request.scope["path"] = new_path or "/"
request.scope["root_path"] = api_prefix
return await call_next(request)
# Allow PHP application to call this API
app.add_middleware(
@@ -22,15 +96,82 @@ app.add_middleware(
allow_headers=["*"],
)
# Initialize catalogue for signature validation
cat = catalogue.Catalogue()
# Catalogue is expensive to initialize at import time; instantiate lazily
cat = None
def _get_catalogue() -> Any:
global cat
if cat is None:
# import inside function to avoid expensive work during module import
from bibapi import catalogue as _catalogue
cat = _catalogue.Catalogue()
return cat
# ---- Caching support ----------------------------------------------
# Uses an async Redis client when `REDIS_URL` is set, otherwise falls
# back to a small in-memory store with TTL. Cache TTL defaults to 72h.
CacheValue = dict[str, Any]
_in_memory_cache: dict[str, tuple[float, CacheValue]] = {}
_in_memory_lock = asyncio.Lock()
async def _cache_get(key: str) -> CacheValue | None:
if redis_client:
try:
val = await redis_client.get(key)
if val is None:
return None
return json.loads(val)
except Exception:
logging.exception("redis get failed")
return None
# fallback in-memory
async with _in_memory_lock:
entry = _in_memory_cache.get(key)
if not entry:
return None
expires_at, value = entry
if time.time() >= expires_at:
del _in_memory_cache[key]
return None
return value
async def _cache_set(key: str, value: CacheValue, ttl: int = CACHE_TTL_SECONDS) -> None:
if redis_client:
try:
await redis_client.set(key, json.dumps(value), ex=ttl)
return
except Exception:
logging.exception("redis set failed")
async with _in_memory_lock:
_in_memory_cache[key] = (time.time() + ttl, value)
# Redis lifecycle is handled by the lifespan context manager defined earlier
@app.get("/api/validate-signature")
async def validate_signature(signature: str = Query(...)):
"""Validate a book signature and return total pages"""
async def validate_signature(signature: str = Query(...)) -> JSONResponse:
"""Validate a book signature and return total pages."""
# check cache first
# ensure signature is stripped of leading/trailing whitespace
signature = signature.strip()
# enforce url quotes
signature = urllib.parse.quote(signature)
cache_key = f"signature:{signature}"
cached = await _cache_get(cache_key)
if cached is not None:
return JSONResponse(cached)
try:
book_result = cat.get_book_with_data(signature)
book_result = _get_catalogue().get_book_with_data(signature)
if book_result and hasattr(book_result, "pages") and book_result.pages:
# Try to extract numeric page count
pages_str = str(book_result.pages)
@@ -38,31 +179,36 @@ async def validate_signature(signature: str = Query(...)):
match = re.search(r"(\d+)", pages_str)
if match:
total_pages = int(match.group(1))
return JSONResponse(
{"valid": True, "total_pages": total_pages, "signature": signature}
)
result: CacheValue = {
"valid": True,
"total_pages": total_pages,
"signature": signature,
}
await _cache_set(cache_key, result)
return JSONResponse(result)
return JSONResponse(
{
"valid": False,
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
"signature": signature,
}
)
result: CacheValue = {
"valid": False,
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
"signature": signature,
}
await _cache_set(cache_key, result)
return JSONResponse(result)
except Exception as e:
return JSONResponse(
{
"valid": False,
"error": f"Fehler bei der Validierung: {str(e)}",
"signature": signature,
},
status_code=500,
)
logging.exception("validate_signature failure")
result: CacheValue = {
"valid": False,
"error": f"Fehler bei der Validierung: {e!s}",
"signature": signature,
}
# store a failed response in cache as well so we avoid replaying errors
await _cache_set(cache_key, result)
return JSONResponse(result, status_code=500)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
async def health_check() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok", "service": "signature-validation"}

View File

@@ -1,19 +1,21 @@
[project]
name = "semapform-api"
version = "0.1.0"
version = "0.2.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"bibapi[catalogue]>=0.0.5",
"bibapi[catalogue]>=0.0.6",
"fastapi>=0.122.0",
"uvicorn>=0.38.0",
"redis>=4.6.0",
]
[[tool.uv.index]]
name = "gitea"
url = "https://git.theprivateserver.de/api/packages/PHB/pypi/simple/"
[tool.bumpversion]
current_version = "0.1.0"
current_version = "0.1.3"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"

8
test_api.py Normal file
View File

@@ -0,0 +1,8 @@
# test api endpoint with a signature, print the response
import urllib.request
response = urllib.request.urlopen(
"http://localhost:8001/api/validate-signature?signature=ST%20250%20U42%20%2815%29",
)
print(response.read().decode("utf-8"))