Clean up OpenAPI scripts

This commit is contained in:
gabdevele
2026-06-03 01:34:27 +02:00 Unverified
parent 74ff09fefe
commit 1043768da7
15 changed files with 379 additions and 48 deletions
+2 -1
View File
@@ -95,7 +95,8 @@ build/
.cache/
.env
uploads/
lab/.venv/
scripts/lab/.venv/
scripts/lab/__pycache__/
### CMake Patch ###
# External projects
+1 -1
View File
@@ -24,7 +24,7 @@
- Build: `cmake --build build`
- Test: `ctest --test-dir build --output-on-failure`
- OpenAPI: `cmake --build build --target openapi`
- Lab frontend: `cd lab && uv run streamlit run app.py`
- Lab frontend: `cd scripts/lab && uv run streamlit run app.py`
- Local services: `docker compose up -d`
- App: `./build/visione-backend`
+2 -2
View File
@@ -77,13 +77,13 @@ set(OPENAPI_OUTPUT ${CMAKE_BINARY_DIR}/openapi/visione.openapi.json)
add_custom_command(
OUTPUT ${OPENAPI_OUTPUT}
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/generate_openapi.py
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
--controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers
--out ${OPENAPI_OUTPUT}
--title "Visione API"
--version ${PROJECT_VERSION}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/generate_openapi.py
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
${CONTROLLER_HEADERS}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
VERBATIM
+3 -3
View File
@@ -90,11 +90,11 @@ The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at
## Visione Lab
`lab/` is a small Streamlit frontend used only to test the backend visually
while the real frontend is not ready.
`scripts/lab/` is a small Streamlit frontend used only to test the backend
visually while the real frontend is not ready.
```bash
cd lab
cd scripts/lab
uv sync
uv run streamlit run app.py
```
+90 -1
View File
@@ -6,23 +6,113 @@ class AdminController : public drogon::HttpController<AdminController>
{
public:
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Admin
* summary: List categories
* description: Lists active report categories.
* auth: bearer
* body: none
* response: Report category list.
*/
ADD_METHOD_TO(AdminController::listCategories,
"/api/v1/admin/categories",
drogon::Get);
/*
* openapi:
* tags: Admin
* summary: Create category
* description: Creates a report category.
* auth: bearer
* body: json
* response: Created category result.
*/
ADD_METHOD_TO(AdminController::createCategory,
"/api/v1/admin/categories",
drogon::Post);
/*
* openapi:
* tags: Admin
* summary: List areas
* description: Lists configured municipal areas.
* auth: bearer
* body: none
* response: Area list.
*/
ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get);
/*
* openapi:
* tags: Admin
* summary: Create area
* description: Creates a municipal area with GeoJSON boundary.
* auth: bearer
* body: json
* response: Created area result.
*/
ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post);
/*
* openapi:
* tags: Admin
* summary: List teams
* description: Lists intervention teams.
* auth: bearer
* body: none
* response: Team list.
*/
ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get);
/*
* openapi:
* tags: Admin
* summary: Create team
* description: Creates an intervention team.
* auth: bearer
* body: json
* response: Created team result.
*/
ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post);
/*
* openapi:
* tags: Admin
* summary: Overview stats
* description: Returns report statistics for administration dashboards.
* auth: bearer
* body: none
* response: Aggregated report statistics.
*/
ADD_METHOD_TO(AdminController::overviewStats,
"/api/v1/admin/stats/overview",
drogon::Get);
/*
* openapi:
* tags: Admin
* summary: List users
* description: Lists platform users with role information.
* auth: bearer
* body: none
* response: User list.
*/
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
/*
* openapi:
* tags: Admin
* summary: Update user roles
* description: Replaces the roles assigned to a user.
* auth: bearer
* body: json
* response: Role update result.
*/
ADD_METHOD_TO(AdminController::updateUserRoles,
"/api/v1/admin/users/{1}/roles",
drogon::Patch);
/*
* openapi:
* tags: Admin
* summary: Ban or unban user
* description: Updates a user's banned flag.
* auth: bearer
* body: json
* response: Ban update result.
*/
ADD_METHOD_TO(AdminController::updateUserBan,
"/api/v1/admin/users/{1}/ban",
drogon::Patch);
@@ -61,4 +151,3 @@ class AdminController : public drogon::HttpController<AdminController>
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long userId);
};
+36
View File
@@ -6,9 +6,45 @@ class ApiController : public drogon::HttpController<ApiController>
{
public:
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Technical
* summary: Health check
* description: Returns process health without checking dependencies.
* auth: public
* body: none
* response: Service health status.
*/
ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get);
/*
* openapi:
* tags: Technical
* summary: Readiness check
* description: Checks whether MySQL is reachable.
* auth: public
* body: none
* response: Dependency readiness status.
*/
ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", drogon::Get);
/*
* openapi:
* tags: Technical
* summary: OpenAPI document
* description: Returns the generated OpenAPI JSON document.
* auth: public
* body: none
* response: OpenAPI 3 JSON document.
*/
ADD_METHOD_TO(ApiController::openApi, "/api/v1/openapi.json", drogon::Get);
/*
* openapi:
* tags: Technical
* summary: Swagger UI
* description: Serves a local Swagger UI page backed by openapi.json.
* auth: public
* body: none
* response: HTML documentation page.
*/
ADD_METHOD_TO(ApiController::docs, "/api/v1/docs", drogon::Get);
METHOD_LIST_END
+54
View File
@@ -6,21 +6,75 @@ class AuthController : public drogon::HttpController<AuthController>
{
public:
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Auth
* summary: Register citizen
* description: Creates a citizen account with personal data and password.
* auth: public
* body: json
* response: Registration result.
*/
ADD_METHOD_TO(AuthController::registerCitizen,
"/api/v1/auth/register",
drogon::Post);
/*
* openapi:
* tags: Auth
* summary: Request OTP
* description: Sends a one-time code by email through MailHog locally.
* auth: public
* body: json
* response: OTP challenge result.
*/
ADD_METHOD_TO(AuthController::requestOtp,
"/api/v1/auth/otp/request",
drogon::Post);
/*
* openapi:
* tags: Auth
* summary: Verify OTP
* description: Verifies an email OTP and creates an API token.
* auth: public
* body: json
* response: Bearer token when verification succeeds.
*/
ADD_METHOD_TO(AuthController::verifyOtp,
"/api/v1/auth/otp/verify",
drogon::Post);
/*
* openapi:
* tags: Auth
* summary: Password login
* description: Password-based login fallback for local development.
* auth: public
* body: json
* response: Bearer token when credentials are valid.
*/
ADD_METHOD_TO(AuthController::loginWithPassword,
"/api/v1/auth/login/password",
drogon::Post);
/*
* openapi:
* tags: Auth
* summary: Change password
* description: Changes the authenticated user's password.
* auth: bearer
* body: json
* response: Password change result.
*/
ADD_METHOD_TO(AuthController::changePassword,
"/api/v1/auth/change-password",
drogon::Post);
/*
* openapi:
* tags: Auth
* summary: Current user
* description: Returns the authenticated user's profile and permissions.
* auth: bearer
* body: none
* response: Authenticated user context.
*/
ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
METHOD_LIST_END
+90
View File
@@ -6,27 +6,117 @@ class ReportController : public drogon::HttpController<ReportController>
{
public:
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Reports
* summary: Create report
* description: Creates a citizen urban issue report.
* auth: bearer
* body: json
* response: Created report id and inferred area.
*/
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
/*
* openapi:
* tags: Reports
* summary: List reports
* description: Lists reports visible to the authenticated user.
* auth: bearer
* body: none
* response: Report list.
*/
ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get);
/*
* openapi:
* tags: Reports
* summary: Report detail
* description: Returns report detail, status history and media.
* auth: bearer
* body: none
* response: Report detail.
*/
ADD_METHOD_TO(ReportController::detail, "/api/v1/reports/{1}", drogon::Get);
/*
* openapi:
* tags: Reports
* summary: Update report status
* description: Updates a report status following allowed transitions.
* auth: bearer
* body: json
* response: Status update result.
*/
ADD_METHOD_TO(ReportController::updateStatus,
"/api/v1/reports/{1}/status",
drogon::Patch);
/*
* openapi:
* tags: Reports
* summary: Take report
* description: Assigns an open report to the current operator.
* auth: bearer
* body: none
* response: Take report result.
*/
ADD_METHOD_TO(ReportController::takeReport,
"/api/v1/reports/{1}/take",
drogon::Post);
/*
* openapi:
* tags: Reports
* summary: Add message
* description: Adds a citizen/operator message to a report.
* auth: bearer
* body: json
* response: Message creation result.
*/
ADD_METHOD_TO(ReportController::addMessage,
"/api/v1/reports/{1}/messages",
drogon::Post);
/*
* openapi:
* tags: Reports
* summary: List messages
* description: Lists messages for a report conversation.
* auth: bearer
* body: none
* response: Message list.
*/
ADD_METHOD_TO(ReportController::listMessages,
"/api/v1/reports/{1}/messages",
drogon::Get);
/*
* openapi:
* tags: Reports
* summary: Assign team
* description: Assigns an intervention team to a report.
* auth: bearer
* body: json
* response: Assignment result.
*/
ADD_METHOD_TO(ReportController::assignTeam,
"/api/v1/reports/{1}/assignments",
drogon::Post);
/*
* openapi:
* tags: Reports
* summary: Add feedback
* description: Adds citizen feedback after report resolution.
* auth: bearer
* body: json
* response: Feedback result.
*/
ADD_METHOD_TO(ReportController::addFeedback,
"/api/v1/reports/{1}/feedback",
drogon::Post);
/*
* openapi:
* tags: Reports
* summary: Upload media
* description: Uploads report images or videos as multipart files.
* auth: bearer
* body: multipart
* response: Uploaded media list.
*/
ADD_METHOD_TO(ReportController::uploadMedia,
"/api/v1/reports/{1}/media",
drogon::Post);
+14 -13
View File
@@ -6,17 +6,17 @@
- `repositories/`: database helpers and migrations.
- `common/`: reusable HTTP, auth, crypto, email and validation utilities.
- `migrations/`: versioned SQL files.
- `lab/`: local Streamlit frontend for visual endpoint testing.
- `scripts/generate_openapi.py`: OpenAPI generator.
- `scripts/lab/`: local Streamlit frontend for visual endpoint testing.
- `docs/`: product, API, schema and milestone documentation.
## Drogon Usage
Controllers use Drogon route macros and return JSON with
`HttpResponse::newHttpJsonResponse`. Database access uses Drogon's MySQL client
with parameterized SQL placeholders.
`HttpResponse::newHttpJsonResponse`. Database access uses
`drogon::orm::DbClient` with raw parameterized SQL placeholders.
The codebase starts with raw SQL repositories because the schema is still
evolving. Drogon ORM/codegen can be introduced later after the database model
stabilizes.
The codebase does not use Drogon generated models or `Mapper<T>`. Raw SQL stays
in repository/database helpers because the schema is still evolving.
## Local Email
OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and
@@ -29,16 +29,17 @@ available through OpenSSL, which is already a Drogon dependency in this project.
Legacy `sha256$` password hashes are rejected instead of silently accepted.
## OpenAPI Generation
`generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros in
`controllers/*.h` and generates `build/openapi/visione.openapi.json`.
`scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros and the
adjacent `openapi:` metadata blocks in `controllers/*.h`, then generates
`build/openapi/visione.openapi.json`.
This is a contract-last generator: the source of truth is the C++ controller
route list, not a manually maintained static Swagger document. The current tool
generates paths, methods, path parameters, bearer auth hints and common JSON or
multipart request bodies. Deeper request/response schemas can be added later by
teaching the generator project-specific conventions or comments.
generates paths, methods, path parameters, summaries, descriptions, auth hints
and common JSON or multipart request bodies. Generation fails if a route lacks
metadata.
## Visione Lab
`lab/` is a Streamlit dev frontend for visual endpoint testing. It is
`scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is
intentionally separate from the real product frontend and should stay small:
forms, uploads and response rendering only. Run it with uv from `lab/`.
forms, uploads and response rendering only. Run it with uv from `scripts/lab/`.
+4
View File
@@ -2,6 +2,10 @@
The schema is stored in `migrations/001_initial_schema.sql`.
Persistence currently uses `drogon::orm::DbClient` with raw parameterized SQL.
The project does not use Drogon generated models or `Mapper<T>` while the schema
is still evolving.
Important tables:
- `users`: identity, personal data, password hash, points and moderation flags.
+4 -3
View File
@@ -7,14 +7,15 @@
than plain SHA-256 and available through the existing OpenSSL dependency.
- Added report media uploads at `POST /api/v1/reports/{id}/media`.
- Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads.
- Removed the old `/photos` upload path; report attachments now use `/media`.
- Added generated OpenAPI JSON from Drogon controller route macros.
- Report attachments now use the `/media` upload path.
- Added generated OpenAPI JSON from Drogon controller route macros and adjacent
metadata blocks.
- Exposed `/api/v1/openapi.json` and `/api/v1/docs`.
- Added a small Streamlit API lab for visual endpoint testing.
## To do next
- Add richer OpenAPI schema metadata for request and response bodies.
- Consider extracting `generate_openapi.py` into an open-source standalone
- Consider extracting `scripts/generate_openapi.py` into an open-source standalone
project once the conventions stabilize.
- Add minimal media upload integration tests after endpoint contracts settle.
- Keep API and docs on `media` terminology only.
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Generate a compact OpenAPI spec from Drogon HttpController route macros."""
"""Generate a compact OpenAPI spec from Drogon route macros and metadata blocks."""
from __future__ import annotations
@@ -12,21 +12,12 @@ from pathlib import Path
ROUTE_RE = re.compile(
r"ADD_METHOD_TO\(\s*([A-Za-z_]\w*)::([A-Za-z_]\w*)\s*,\s*"
r'"([^"]+)"\s*,\s*drogon::([A-Za-z]+)\s*\)',
re.MULTILINE,
re.MULTILINE | re.DOTALL,
)
METHODS_WITH_JSON_BODY = {"post", "put", "patch"}
PUBLIC_OPERATIONS = {
"health",
"ready",
"openApi",
"docs",
"registerCitizen",
"requestOtp",
"verifyOtp",
"loginWithPassword",
}
REQUIRED_METADATA = {"tags", "summary", "description", "auth", "body", "response"}
def controller_tag(controller: str) -> str:
@@ -51,10 +42,47 @@ def openapi_path(path: str) -> tuple[str, list[dict]]:
return re.sub(r"\{(\d+)\}", replace, path), parameters
def request_body(path: str, method: str) -> dict | None:
if method != "post" and method != "patch":
def parse_metadata(header: Path, text: str, route_start: int) -> dict[str, str]:
prefix = text[:route_start].rstrip()
if not prefix.endswith("*/"):
raise ValueError(f"{header}: route is missing an openapi metadata block")
comment_start = prefix.rfind("/*")
if comment_start == -1:
raise ValueError(f"{header}: route is missing an openapi metadata block")
block = prefix[comment_start + 2:-2]
metadata: dict[str, str] = {}
in_openapi_block = False
for raw_line in block.splitlines():
line = raw_line.strip()
if line.startswith("*"):
line = line[1:].strip()
if not line:
continue
if line == "openapi:":
in_openapi_block = True
continue
if not in_openapi_block:
continue
match = re.match(r"([A-Za-z_]+):\s*(.+)", line)
if match:
metadata[match.group(1)] = match.group(2).strip()
missing = sorted(REQUIRED_METADATA - metadata.keys())
if missing:
names = ", ".join(missing)
raise ValueError(f"{header}: openapi metadata block is missing: {names}")
return metadata
def request_body(body_kind: str, method: str) -> dict | None:
if body_kind == "none":
return None
if path.endswith("/media"):
if body_kind == "multipart":
return {
"required": True,
"content": {
@@ -71,33 +99,56 @@ def request_body(path: str, method: str) -> dict | None:
}
},
}
if method in METHODS_WITH_JSON_BODY:
if body_kind == "json" and method in METHODS_WITH_JSON_BODY:
return {
"required": True,
"content": {
"application/json": {"schema": {"type": "object"}}
},
}
return None
if body_kind == "json":
raise ValueError(f"JSON body is not supported for {method.upper()} routes")
raise ValueError(f"Unsupported request body kind: {body_kind}")
def operation(controller: str, handler: str, raw_path: str, method: str) -> dict:
def parse_tags(value: str) -> list[str]:
return [tag.strip() for tag in value.split(",") if tag.strip()]
def is_public(metadata: dict[str, str]) -> bool:
auth = metadata["auth"]
if auth == "public":
return True
if auth == "bearer":
return False
raise ValueError(f"Unsupported auth kind: {auth}")
def operation(
controller: str,
handler: str,
raw_path: str,
method: str,
metadata: dict[str, str],
) -> dict:
path, params = openapi_path(raw_path)
op = {
"tags": [controller_tag(controller)],
"tags": parse_tags(metadata["tags"]) or [controller_tag(controller)],
"operationId": f"{controller}.{handler}",
"summary": metadata["summary"],
"description": metadata["description"],
"parameters": params,
"responses": {
"200": {
"description": "JSON response",
"description": metadata["response"],
"content": {"application/json": {"schema": {"type": "object"}}},
}
},
}
body = request_body(path, method)
body = request_body(metadata["body"], method)
if body:
op["requestBody"] = body
if handler not in PUBLIC_OPERATIONS:
if not is_public(metadata):
op["security"] = [{"bearerAuth": []}]
if method == "post":
op["responses"]["201"] = op["responses"]["200"]
@@ -122,9 +173,13 @@ def generate(controllers_dir: Path, title: str, version: str) -> dict:
}
for header in sorted(controllers_dir.glob("*.h")):
text = header.read_text(encoding="utf-8")
for controller, handler, raw_path, drogon_method in ROUTE_RE.findall(text):
for match in ROUTE_RE.finditer(text):
controller, handler, raw_path, drogon_method = match.groups()
metadata = parse_metadata(header, text, match.start())
method = drogon_method.lower()
path, op = operation(controller, handler, raw_path, method)
path, op = operation(controller, handler, raw_path, method, metadata)
if "/photos" in path:
raise ValueError(f"{header}: generated OpenAPI path still uses /photos")
spec["paths"].setdefault(path, {})[method] = op
return spec
View File
View File