13 Commits
dev ... v0.1.3

Author SHA1 Message Date
Gitea CI
d0dafec4cb Bump version: 0.1.2 → 0.1.3 2025-11-25 10:06:03 +00:00
075041f371 Update pyproject.toml 2025-11-25 10:00:51 +00:00
981d5211b6 Merge pull request 'Add new env variable to use prefixes in url [release-patch]' (#8) from dev into main
Reviewed-on: #8
2025-11-25 09:57:16 +00:00
d5cf0a8636 Merge pull request 'dev' (#7) from dev into main
Reviewed-on: #7
2025-11-25 09:51:05 +00:00
5a1c19ccbe Merge pull request 'dev [release-patch]' (#6) from dev into main
Reviewed-on: #6
2025-11-25 09:05:53 +00:00
59c90ca471 Merge pull request 'chore: replace apk with apt [release-patch]' (#5) from dev into main
Reviewed-on: #5
2025-11-25 08:00:20 +00:00
1e7d45ec49 Merge pull request 'test: inspired by other Dockerfile [release-patch]' (#4) from dev into main
Reviewed-on: #4
2025-11-25 07:51:22 +00:00
69e463c9a4 Merge pull request 'test: add path, switch to uv [release-patch]' (#3) from dev into main
Reviewed-on: #3
2025-11-25 07:39:30 +00:00
b1cdeffb4c Update pyproject.toml 2025-11-25 07:39:24 +00:00
e2cd6d7ba3 Update pyproject.toml 2025-11-25 07:38:30 +00:00
Gitea CI
70a867e4a1 Bump version: 0.1.0 → 0.1.1 2025-11-25 04:13:49 +00:00
16b1c131a4 Merge pull request 'change dockerfile' (#2) from dev into main
Reviewed-on: #2
2025-11-25 04:13:20 +00:00
809ce9bdd9 Merge pull request 'add versioning' (#1) from dev into main
Reviewed-on: #1
2025-11-25 03:52:15 +00:00
5 changed files with 33 additions and 185 deletions

View File

@@ -58,7 +58,7 @@ jobs:
fetch-tags: true # Fetch all tags (refs/tags)
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
- name: Set up Python
run: uv python install
with:
@@ -145,31 +145,7 @@ jobs:
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
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
branch: ${{ env.PUSH_BRANCH }}
- name: Build Changelog
id: build_changelog
uses: https://github.com/mikepenz/release-changelog-builder-action@v5

View File

@@ -16,7 +16,7 @@ jobs:
uses: docker/setup-buildx-action@v2
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
- name: (optional) Prepare dependencies
run: |

13
.vscode/settings.json vendored
View File

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

View File

@@ -1,66 +1,16 @@
"""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
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 import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
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)
app = FastAPI(title="Signature Validation API")
# Optional path prefix support: when behind a reverse-proxy that uses a
# URL prefix (eg. `https://api.example.tld/library/...`) set `API_PREFIX` to
@@ -76,10 +26,7 @@ if _api_prefix_raw:
@app.middleware("http")
async def _strip_api_prefix(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
async def _strip_api_prefix(request, call_next):
if api_prefix and request.url.path.startswith(api_prefix):
new_path = request.url.path[len(api_prefix) :]
request.scope["path"] = new_path or "/"
@@ -100,7 +47,7 @@ app.add_middleware(
cat = None
def _get_catalogue() -> Any:
def _get_catalogue():
global cat
if cat is None:
# import inside function to avoid expensive work during module import
@@ -110,66 +57,9 @@ def _get_catalogue() -> Any:
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(...)) -> 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)
async def validate_signature(signature: str = Query(...)):
"""Validate a book signature and return total pages"""
try:
book_result = _get_catalogue().get_book_with_data(signature)
if book_result and hasattr(book_result, "pages") and book_result.pages:
@@ -179,36 +69,31 @@ async def validate_signature(signature: str = Query(...)) -> JSONResponse:
match = re.search(r"(\d+)", pages_str)
if match:
total_pages = int(match.group(1))
result: CacheValue = {
"valid": True,
"total_pages": total_pages,
"signature": signature,
}
await _cache_set(cache_key, result)
return JSONResponse(result)
return JSONResponse(
{"valid": True, "total_pages": total_pages, "signature": signature},
)
result: CacheValue = {
return JSONResponse(
{
"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:
logging.exception("validate_signature failure")
result: CacheValue = {
return JSONResponse(
{
"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)
},
status_code=500,
)
@app.get("/health")
async def health_check() -> dict[str, str]:
"""Health check endpoint."""
async def health_check():
"""Health check endpoint"""
return {"status": "ok", "service": "signature-validation"}

View File

@@ -1,14 +1,14 @@
[project]
name = "semapform-api"
version = "0.2.0"
version = "0.1.3"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"bibapi[catalogue]>=0.0.6",
"fastapi>=0.122.0",
"pip>=25.3",
"uvicorn>=0.38.0",
"redis>=4.6.0",
]
[[tool.uv.index]]
name = "gitea"