Add OpenAPI generator

This commit is contained in:
gabdevele
2026-06-02 20:40:45 +02:00 Unverified
parent 1ef41bb2a3
commit 3f0a5ecf9b
9 changed files with 278 additions and 1 deletions
+1 -1
View File
@@ -23,6 +23,7 @@
- Configure: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug` - Configure: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug`
- Build: `cmake --build build` - Build: `cmake --build build`
- Test: `ctest --test-dir build --output-on-failure` - Test: `ctest --test-dir build --output-on-failure`
- OpenAPI: `cmake --build build --target openapi`
- Local services: `docker compose up -d` - Local services: `docker compose up -d`
- App: `./build/visione-backend config.json` - App: `./build/visione-backend config.json`
@@ -31,4 +32,3 @@
- After each completed and verified feature, create a short commit. - After each completed and verified feature, create a short commit.
- Keep commit messages concise, for example `Add MailHog config` or `Add report API`. - 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. - Do not spend excessive time on exhaustive tests; cover high-risk behavior and regressions.
+23
View File
@@ -7,6 +7,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 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) option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF)
if (USE_SYSTEM_DROGON) if (USE_SYSTEM_DROGON)
@@ -68,5 +70,26 @@ target_link_libraries(${PROJECT_NAME}
Drogon::Drogon 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() enable_testing()
add_subdirectory(test) add_subdirectory(test)
+17
View File
@@ -31,6 +31,8 @@ MailHog listens on `http://localhost:8025`.
Local CORS allows `http://localhost:5173` by default for the future Vue frontend. 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 Multipart uploads currently accept report images and videos up to the app body
limit configured in `config.json`. 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: 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/DATABASE_SCHEMA.md`: schema overview.
- `docs/ARCHITECTURE.md`: backend structure. - `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 ## Development Workflow
1. Keep MySQL running with `docker compose up -d`. 1. Keep MySQL running with `docker compose up -d`.
@@ -89,6 +104,8 @@ cmake --build build
ctest --test-dir build --output-on-failure 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 The `RequestLogger` plugin logs every response as method, path, status and
client address. client address.
+3
View File
@@ -59,6 +59,9 @@
"public_base_path": "/uploads", "public_base_path": "/uploads",
"reports_path": "uploads/reports" "reports_path": "uploads/reports"
}, },
"openapi": {
"spec_path": "build/openapi/visione.openapi.json"
},
"cors": { "cors": {
"allowed_origin": "http://localhost:5173" "allowed_origin": "http://localhost:5173"
} }
+61
View File
@@ -4,7 +4,10 @@
#include <drogon/orm/Exception.h> #include <drogon/orm/Exception.h>
#include <json/json.h> #include <json/json.h>
#include <filesystem>
#include <fstream>
#include <memory> #include <memory>
#include <sstream>
using drogon::HttpResponse; using drogon::HttpResponse;
using drogon::HttpResponsePtr; using drogon::HttpResponsePtr;
@@ -27,6 +30,12 @@ Json::Value mysqlReadyBody(bool ready)
body["dependencies"]["mysql"] = ready; body["dependencies"]["mysql"] = ready;
return body; 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( void ApiController::health(
@@ -70,3 +79,55 @@ void ApiController::ready(
jsonResponse(std::move(body), k503ServiceUnavailable)); jsonResponse(std::move(body), k503ServiceUnavailable));
} }
} }
void ApiController::openApi(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
{
static const std::string html = R"(<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Visione API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.ui = SwaggerUIBundle({ url: "/api/v1/openapi.json", dom_id: "#swagger-ui" });
</script>
</body>
</html>)";
auto response = HttpResponse::newHttpResponse();
response->setStatusCode(drogon::k200OK);
response->setContentTypeCode(drogon::CT_TEXT_HTML);
response->setBody(html);
callback(response);
}
+8
View File
@@ -8,6 +8,8 @@ class ApiController : public drogon::HttpController<ApiController>
METHOD_LIST_BEGIN METHOD_LIST_BEGIN
ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get); ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get);
ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", 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 METHOD_LIST_END
void health(const drogon::HttpRequestPtr &req, void health(const drogon::HttpRequestPtr &req,
@@ -15,4 +17,10 @@ class ApiController : public drogon::HttpController<ApiController>
void ready(const drogon::HttpRequestPtr &req, void ready(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback); std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void openApi(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void docs(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
}; };
+2
View File
@@ -5,6 +5,8 @@ Base path: `/api/v1`.
## Technical ## Technical
- `GET /health`: process health. - `GET /health`: process health.
- `GET /ready`: MySQL readiness. - `GET /ready`: MySQL readiness.
- `GET /openapi.json`: generated OpenAPI 3.0 JSON.
- `GET /docs`: Swagger UI page backed by the generated spec.
## Auth ## Auth
- `POST /auth/register`: create a citizen account. - `POST /auth/register`: create a citizen account.
+16
View File
@@ -6,6 +6,7 @@
- `repositories/`: database helpers and migrations. - `repositories/`: database helpers and migrations.
- `common/`: reusable HTTP, auth, crypto, email and validation utilities. - `common/`: reusable HTTP, auth, crypto, email and validation utilities.
- `migrations/`: versioned SQL files. - `migrations/`: versioned SQL files.
- `tools/`: local developer tools such as OpenAPI generation.
- `docs/`: product, API, schema and milestone documentation. - `docs/`: product, API, schema and milestone documentation.
## Drogon Usage ## Drogon Usage
@@ -21,3 +22,18 @@ stabilizes.
OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and
inspectable through its web UI. 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.
+147
View File
@@ -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()