diff --git a/.gitignore b/.gitignore index c5821eb..54e41b8 100644 --- a/.gitignore +++ b/.gitignore @@ -95,7 +95,8 @@ build/ .cache/ .env uploads/ -lab/.venv/ +scripts/lab/.venv/ +scripts/lab/__pycache__/ ### CMake Patch ### # External projects diff --git a/AGENTS.md b/AGENTS.md index eda246c..185bdaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/CMakeLists.txt b/CMakeLists.txt index fb119ef..61ee45b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/README.md b/README.md index bad99cc..c87ded7 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/controllers/AdminController.h b/controllers/AdminController.h index 46ae7a1..92d37df 100644 --- a/controllers/AdminController.h +++ b/controllers/AdminController.h @@ -6,23 +6,113 @@ class AdminController : public drogon::HttpController { 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 std::function &&callback, long long userId); }; - diff --git a/controllers/ApiController.h b/controllers/ApiController.h index ed27b0e..83bb2c7 100644 --- a/controllers/ApiController.h +++ b/controllers/ApiController.h @@ -6,9 +6,45 @@ class ApiController : public drogon::HttpController { 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 diff --git a/controllers/AuthController.h b/controllers/AuthController.h index cfbbd10..192c6f1 100644 --- a/controllers/AuthController.h +++ b/controllers/AuthController.h @@ -6,21 +6,75 @@ class AuthController : public drogon::HttpController { 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 diff --git a/controllers/ReportController.h b/controllers/ReportController.h index d7f90bc..c87bcf0 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -6,27 +6,117 @@ class ReportController : public drogon::HttpController { 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); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e6bb85..43ac3a3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`. 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/`. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 21a9705..efbf2f1 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -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` while the schema +is still evolving. + Important tables: - `users`: identity, personal data, password hash, points and moderation flags. diff --git a/docs/MILESTONE_03.md b/docs/MILESTONE_03.md index a991570..cfabe0c 100644 --- a/docs/MILESTONE_03.md +++ b/docs/MILESTONE_03.md @@ -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. diff --git a/generate_openapi.py b/scripts/generate_openapi.py similarity index 55% rename from generate_openapi.py rename to scripts/generate_openapi.py index 0c85df7..c1330fc 100644 --- a/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -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 diff --git a/lab/app.py b/scripts/lab/app.py similarity index 100% rename from lab/app.py rename to scripts/lab/app.py diff --git a/lab/pyproject.toml b/scripts/lab/pyproject.toml similarity index 100% rename from lab/pyproject.toml rename to scripts/lab/pyproject.toml diff --git a/lab/uv.lock b/scripts/lab/uv.lock similarity index 100% rename from lab/uv.lock rename to scripts/lab/uv.lock