Add OpenAPI generator
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user