From 3f0a5ecf9b5faba95dc8e963b4a49c83a2ea3586 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Tue, 2 Jun 2026 20:40:45 +0200 Subject: [PATCH] Add OpenAPI generator --- AGENTS.md | 2 +- CMakeLists.txt | 23 +++++ README.md | 17 ++++ config.json | 3 + controllers/ApiController.cc | 61 +++++++++++ controllers/ApiController.h | 8 ++ docs/API_SPEC.md | 2 + docs/ARCHITECTURE.md | 16 +++ tools/drogon-openapi-gen/generate.py | 147 +++++++++++++++++++++++++++ 9 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 tools/drogon-openapi-gen/generate.py diff --git a/AGENTS.md b/AGENTS.md index 19570bc..8d56782 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ - Configure: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug` - Build: `cmake --build build` - Test: `ctest --test-dir build --output-on-failure` +- OpenAPI: `cmake --build build --target openapi` - Local services: `docker compose up -d` - App: `./build/visione-backend config.json` @@ -31,4 +32,3 @@ - After each completed and verified feature, create a short commit. - Keep commit messages concise, for example `Add MailHog config` or `Add report API`. - Do not spend excessive time on exhaustive tests; cover high-risk behavior and regressions. - diff --git a/CMakeLists.txt b/CMakeLists.txt index 53d8c13..e0730b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +find_package(Python3 COMPONENTS Interpreter REQUIRED) + option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF) if (USE_SYSTEM_DROGON) @@ -68,5 +70,26 @@ target_link_libraries(${PROJECT_NAME} Drogon::Drogon ) +file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/controllers/*.h +) +set(OPENAPI_OUTPUT ${CMAKE_BINARY_DIR}/openapi/visione.openapi.json) +add_custom_command( + OUTPUT ${OPENAPI_OUTPUT} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/drogon-openapi-gen/generate.py + --controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers + --out ${OPENAPI_OUTPUT} + --title "Visione API" + --version ${PROJECT_VERSION} + DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/tools/drogon-openapi-gen/generate.py + ${CONTROLLER_HEADERS} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + VERBATIM +) +add_custom_target(openapi ALL DEPENDS ${OPENAPI_OUTPUT}) +add_dependencies(${PROJECT_NAME} openapi) + enable_testing() add_subdirectory(test) diff --git a/README.md b/README.md index a08dbff..bc028ad 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ MailHog listens on `http://localhost:8025`. Local CORS allows `http://localhost:5173` by default for the future Vue frontend. Multipart uploads currently accept report images and videos up to the app body limit configured in `config.json`. +Swagger UI is available at `http://localhost:5555/api/v1/docs` after the app +has been built. Local seeded admin: @@ -71,6 +73,19 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon - `docs/DATABASE_SCHEMA.md`: schema overview. - `docs/ARCHITECTURE.md`: backend structure. +## OpenAPI + +The OpenAPI spec is generated from Drogon controller route macros, not written +as a static Swagger file. + +```bash +cmake --build build --target openapi +python3 -m json.tool build/openapi/visione.openapi.json >/dev/null +``` + +The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at +`/api/v1/docs`. + ## Development Workflow 1. Keep MySQL running with `docker compose up -d`. @@ -89,6 +104,8 @@ cmake --build build ctest --test-dir build --output-on-failure ``` +8. After each completed and verified feature, create a short commit. + The `RequestLogger` plugin logs every response as method, path, status and client address. diff --git a/config.json b/config.json index 45324ad..66782bc 100644 --- a/config.json +++ b/config.json @@ -59,6 +59,9 @@ "public_base_path": "/uploads", "reports_path": "uploads/reports" }, + "openapi": { + "spec_path": "build/openapi/visione.openapi.json" + }, "cors": { "allowed_origin": "http://localhost:5173" } diff --git a/controllers/ApiController.cc b/controllers/ApiController.cc index 0f8e533..db076c0 100644 --- a/controllers/ApiController.cc +++ b/controllers/ApiController.cc @@ -4,7 +4,10 @@ #include #include +#include +#include #include +#include using drogon::HttpResponse; using drogon::HttpResponsePtr; @@ -27,6 +30,12 @@ Json::Value mysqlReadyBody(bool ready) body["dependencies"]["mysql"] = ready; return body; } + +std::string openApiSpecPath() +{ + const auto &config = drogon::app().getCustomConfig()["openapi"]; + return config.get("spec_path", "build/openapi/visione.openapi.json").asString(); +} } void ApiController::health( @@ -70,3 +79,55 @@ void ApiController::ready( jsonResponse(std::move(body), k503ServiceUnavailable)); } } + +void ApiController::openApi( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + const auto path = openApiSpecPath(); + std::ifstream file(path, std::ios::binary); + if (!file) + { + Json::Value body; + body["error"] = "openapi_spec_not_generated"; + body["message"] = "Run cmake --build build or the openapi target."; + body["path"] = path; + callback(jsonResponse(std::move(body), drogon::k404NotFound)); + return; + } + + std::ostringstream buffer; + buffer << file.rdbuf(); + auto response = HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k200OK); + response->setContentTypeCode(drogon::CT_APPLICATION_JSON); + response->setBody(buffer.str()); + callback(response); +} + +void ApiController::docs( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + static const std::string html = R"( + + + + Visione API Docs + + + +
+ + + +)"; + + auto response = HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k200OK); + response->setContentTypeCode(drogon::CT_TEXT_HTML); + response->setBody(html); + callback(response); +} diff --git a/controllers/ApiController.h b/controllers/ApiController.h index 05a27b7..ed27b0e 100644 --- a/controllers/ApiController.h +++ b/controllers/ApiController.h @@ -8,6 +8,8 @@ class ApiController : public drogon::HttpController METHOD_LIST_BEGIN ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get); ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", drogon::Get); + ADD_METHOD_TO(ApiController::openApi, "/api/v1/openapi.json", drogon::Get); + ADD_METHOD_TO(ApiController::docs, "/api/v1/docs", drogon::Get); METHOD_LIST_END void health(const drogon::HttpRequestPtr &req, @@ -15,4 +17,10 @@ class ApiController : public drogon::HttpController void ready(const drogon::HttpRequestPtr &req, std::function &&callback); + + void openApi(const drogon::HttpRequestPtr &req, + std::function &&callback); + + void docs(const drogon::HttpRequestPtr &req, + std::function &&callback); }; diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 76e2768..fbea04d 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -5,6 +5,8 @@ Base path: `/api/v1`. ## Technical - `GET /health`: process health. - `GET /ready`: MySQL readiness. +- `GET /openapi.json`: generated OpenAPI 3.0 JSON. +- `GET /docs`: Swagger UI page backed by the generated spec. ## Auth - `POST /auth/register`: create a citizen account. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c4b869..33cb631 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,6 +6,7 @@ - `repositories/`: database helpers and migrations. - `common/`: reusable HTTP, auth, crypto, email and validation utilities. - `migrations/`: versioned SQL files. +- `tools/`: local developer tools such as OpenAPI generation. - `docs/`: product, API, schema and milestone documentation. ## Drogon Usage @@ -21,3 +22,18 @@ stabilizes. OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and inspectable through its web UI. +## Password Hashing +User passwords use PBKDF2-HMAC-SHA256 with per-password random salts and 210000 +iterations. This is intentionally slower than a plain SHA-256 digest and is +available through OpenSSL, which is already a Drogon dependency in this project. +Legacy `sha256$` password hashes are rejected instead of silently accepted. + +## OpenAPI Generation +`tools/drogon-openapi-gen/generate.py` scans Drogon `ADD_METHOD_TO` route macros +in `controllers/*.h` and 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. diff --git a/tools/drogon-openapi-gen/generate.py b/tools/drogon-openapi-gen/generate.py new file mode 100644 index 0000000..26597ac --- /dev/null +++ b/tools/drogon-openapi-gen/generate.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Generate a compact OpenAPI spec from Drogon HttpController route macros.""" + +from __future__ import annotations + +import argparse +import json +import re +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, +) + + +METHODS_WITH_JSON_BODY = {"post", "put", "patch"} +PUBLIC_OPERATIONS = { + "health", + "ready", + "openApi", + "docs", + "registerCitizen", + "requestOtp", + "verifyOtp", + "loginWithPassword", +} + + +def controller_tag(controller: str) -> str: + return controller.removesuffix("Controller") + + +def openapi_path(path: str) -> tuple[str, list[dict]]: + parameters: list[dict] = [] + + def replace(match: re.Match[str]) -> str: + name = "id" if match.group(1) == "1" else f"param{match.group(1)}" + parameters.append( + { + "name": name, + "in": "path", + "required": True, + "schema": {"type": "integer", "format": "int64"}, + } + ) + return "{" + name + "}" + + return re.sub(r"\{(\d+)\}", replace, path), parameters + + +def request_body(path: str, method: str) -> dict | None: + if method != "post" and method != "patch": + return None + if path.endswith("/media") or path.endswith("/photos"): + return { + "required": True, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": {"type": "string", "format": "binary"}, + } + }, + } + } + }, + } + if method in METHODS_WITH_JSON_BODY: + return { + "required": True, + "content": { + "application/json": {"schema": {"type": "object"}} + }, + } + return None + + +def operation(controller: str, handler: str, raw_path: str, method: str) -> dict: + path, params = openapi_path(raw_path) + op = { + "tags": [controller_tag(controller)], + "operationId": f"{controller}.{handler}", + "parameters": params, + "responses": { + "200": { + "description": "JSON response", + "content": {"application/json": {"schema": {"type": "object"}}}, + } + }, + } + body = request_body(path, method) + if body: + op["requestBody"] = body + if handler not in PUBLIC_OPERATIONS: + op["security"] = [{"bearerAuth": []}] + if method == "post": + op["responses"]["201"] = op["responses"]["200"] + return path, op + + +def generate(controllers_dir: Path, title: str, version: str) -> dict: + spec = { + "openapi": "3.0.3", + "info": { + "title": title, + "version": version, + "description": "Generated from Drogon controller route macros.", + }, + "servers": [{"url": "http://localhost:5555"}], + "components": { + "securitySchemes": { + "bearerAuth": {"type": "http", "scheme": "bearer"} + } + }, + "paths": {}, + } + 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): + method = drogon_method.lower() + path, op = operation(controller, handler, raw_path, method) + spec["paths"].setdefault(path, {})[method] = op + return spec + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--controllers", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--title", default="Visione API") + parser.add_argument("--version", default="0.1.0") + args = parser.parse_args() + + spec = generate(args.controllers, args.title, args.version) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(spec, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + + +if __name__ == "__main__": + main()