Compare commits
6 Commits
v0.1.3
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5a9868b8 | |||
|
|
2ee5715b7a | ||
|
|
e3cc82568a | ||
| 4ad04cdf49 | |||
|
4f9522d35b
|
|||
|
4911faf3ae
|
@@ -145,7 +145,31 @@ jobs:
|
|||||||
uses: ad-m/github-push-action@master
|
uses: ad-m/github-push-action@master
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: ${{ env.PUSH_BRANCH }}
|
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
|
- name: Build Changelog
|
||||||
id: build_changelog
|
id: build_changelog
|
||||||
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
|
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
|
||||||
|
|||||||
153
api_service.py
153
api_service.py
@@ -1,16 +1,64 @@
|
|||||||
"""Lightweight Python API service for signature validation
|
"""Lightweight Python API service for signature validation.
|
||||||
This can run independently to support the PHP application
|
|
||||||
|
This can run independently to support the PHP application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator, Callable, Awaitable
|
||||||
|
|
||||||
# Avoid importing heavy modules at top-level to keep `import api_service` lightweight
|
# Avoid importing heavy modules at top-level to keep `import api_service` lightweight
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
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
|
||||||
|
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
|
# Optional path prefix support: when behind a reverse-proxy that uses a
|
||||||
# URL prefix (eg. `https://api.example.tld/library/...`) set `API_PREFIX` to
|
# URL prefix (eg. `https://api.example.tld/library/...`) set `API_PREFIX` to
|
||||||
@@ -26,7 +74,10 @@ if _api_prefix_raw:
|
|||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def _strip_api_prefix(request, call_next):
|
async def _strip_api_prefix(
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[Response]],
|
||||||
|
) -> Response:
|
||||||
if api_prefix and request.url.path.startswith(api_prefix):
|
if api_prefix and request.url.path.startswith(api_prefix):
|
||||||
new_path = request.url.path[len(api_prefix) :]
|
new_path = request.url.path[len(api_prefix) :]
|
||||||
request.scope["path"] = new_path or "/"
|
request.scope["path"] = new_path or "/"
|
||||||
@@ -47,7 +98,7 @@ app.add_middleware(
|
|||||||
cat = None
|
cat = None
|
||||||
|
|
||||||
|
|
||||||
def _get_catalogue():
|
def _get_catalogue() -> Any:
|
||||||
global cat
|
global cat
|
||||||
if cat is None:
|
if cat is None:
|
||||||
# import inside function to avoid expensive work during module import
|
# import inside function to avoid expensive work during module import
|
||||||
@@ -57,9 +108,62 @@ def _get_catalogue():
|
|||||||
return cat
|
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")
|
@app.get("/api/validate-signature")
|
||||||
async def validate_signature(signature: str = Query(...)):
|
async def validate_signature(signature: str = Query(...)) -> JSONResponse:
|
||||||
"""Validate a book signature and return total pages"""
|
"""Validate a book signature and return total pages."""
|
||||||
|
# check cache first
|
||||||
|
cache_key = f"signature:{signature}"
|
||||||
|
cached = await _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return JSONResponse(cached)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
book_result = _get_catalogue().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:
|
if book_result and hasattr(book_result, "pages") and book_result.pages:
|
||||||
@@ -69,31 +173,36 @@ async def validate_signature(signature: str = Query(...)):
|
|||||||
match = re.search(r"(\d+)", pages_str)
|
match = re.search(r"(\d+)", pages_str)
|
||||||
if match:
|
if match:
|
||||||
total_pages = int(match.group(1))
|
total_pages = int(match.group(1))
|
||||||
return JSONResponse(
|
result: CacheValue = {
|
||||||
{"valid": True, "total_pages": total_pages, "signature": signature},
|
"valid": True,
|
||||||
)
|
"total_pages": total_pages,
|
||||||
|
"signature": signature,
|
||||||
|
}
|
||||||
|
await _cache_set(cache_key, result)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
return JSONResponse(
|
result: CacheValue = {
|
||||||
{
|
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
|
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
|
||||||
"signature": signature,
|
"signature": signature,
|
||||||
},
|
}
|
||||||
)
|
await _cache_set(cache_key, result)
|
||||||
|
return JSONResponse(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse(
|
logging.exception("validate_signature failure")
|
||||||
{
|
result: CacheValue = {
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"error": f"Fehler bei der Validierung: {e!s}",
|
"error": f"Fehler bei der Validierung: {e!s}",
|
||||||
"signature": signature,
|
"signature": signature,
|
||||||
},
|
}
|
||||||
status_code=500,
|
# 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")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check() -> dict[str, str]:
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint."""
|
||||||
return {"status": "ok", "service": "signature-validation"}
|
return {"status": "ok", "service": "signature-validation"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "semapform-api"
|
name = "semapform-api"
|
||||||
version = "0.1.3"
|
version = "0.2.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
@@ -9,13 +9,14 @@ dependencies = [
|
|||||||
"fastapi>=0.122.0",
|
"fastapi>=0.122.0",
|
||||||
"pip>=25.3",
|
"pip>=25.3",
|
||||||
"uvicorn>=0.38.0",
|
"uvicorn>=0.38.0",
|
||||||
|
"redis>=4.6.0",
|
||||||
]
|
]
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "gitea"
|
name = "gitea"
|
||||||
url = "https://git.theprivateserver.de/api/packages/PHB/pypi/simple/"
|
url = "https://git.theprivateserver.de/api/packages/PHB/pypi/simple/"
|
||||||
|
|
||||||
[tool.bumpversion]
|
[tool.bumpversion]
|
||||||
current_version = "0.1.3"
|
current_version = "0.2.0"
|
||||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||||
serialize = ["{major}.{minor}.{patch}"]
|
serialize = ["{major}.{minor}.{patch}"]
|
||||||
search = "{current_version}"
|
search = "{current_version}"
|
||||||
|
|||||||
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user