Simplify OpenAPI generation

This commit is contained in:
gabdevele
2026-06-03 02:04:56 +02:00 Unverified
parent 21883a4a8f
commit 25c280f38b
25 changed files with 2695 additions and 1944 deletions
+2 -14
View File
@@ -73,32 +73,20 @@ target_link_libraries(${PROJECT_NAME}
file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/controllers/*.h
)
file(GLOB OPENAPI_SOURCE_FILES CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/controllers/*.cc
${CMAKE_CURRENT_SOURCE_DIR}/services/*.cc
)
file(GLOB_RECURSE SWAGGER_DROGON_FILES CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/scripts/swagger-drogon/*.py
${CMAKE_CURRENT_SOURCE_DIR}/scripts/swagger-drogon/*.toml
${CMAKE_CURRENT_SOURCE_DIR}/scripts/swagger-drogon/*.md
)
set(OPENAPI_OUTPUT ${CMAKE_BINARY_DIR}/openapi/visione.openapi.json)
add_custom_command(
OUTPUT ${OPENAPI_OUTPUT}
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
--controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers
--sources ${CMAKE_CURRENT_SOURCE_DIR}/controllers ${CMAKE_CURRENT_SOURCE_DIR}/services
--contract ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.yaml
--manual ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.openapi.yaml
--out ${OPENAPI_OUTPUT}
--title "Visione API"
--version ${PROJECT_VERSION}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.yaml
${SWAGGER_DROGON_FILES}
${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.openapi.yaml
${CONTROLLER_HEADERS}
${OPENAPI_SOURCE_FILES}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
VERBATIM
)
+7 -6
View File
@@ -77,16 +77,17 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon
## OpenAPI
The OpenAPI spec is generated with `scripts/swagger-drogon/`. The generator
discovers Drogon controller route macros, reads adjacent `openapi:` metadata,
infers simple query/body/response shapes from C++ code, then merges the Visione
schema registry in `docs/openapi/visione.yaml`. Generation fails if an endpoint
still has generic request or response schemas.
The OpenAPI spec is generated from Drogon controller route macros plus the
manual standard OpenAPI document in `docs/openapi/visione.openapi.yaml`.
The script extracts paths and methods from C++ and lets the manual file own
summaries, request bodies, responses, schemas and examples.
The manual file uses JSON-compatible YAML so the CMake build does not require
extra Python packages.
```bash
cmake --build build --target openapi
python3 -m json.tool build/openapi/visione.openapi.json >/dev/null
python3 -m unittest discover scripts/swagger-drogon/tests
python3 -m unittest scripts/test_generate_openapi.py
```
The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at
-90
View File
@@ -6,113 +6,23 @@ 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);
-36
View File
@@ -6,45 +6,9 @@ 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,75 +6,21 @@ 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,117 +6,27 @@ 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);
+12 -13
View File
@@ -6,9 +6,8 @@
- `repositories/`: database helpers and migrations.
- `common/`: reusable HTTP, auth, crypto, email and validation utilities.
- `migrations/`: versioned SQL files.
- `scripts/swagger-drogon/`: OpenAPI generator package.
- `scripts/generate_openapi.py`: compatibility wrapper for the generator.
- `docs/openapi/`: app-specific OpenAPI schema registry.
- `scripts/generate_openapi.py`: minimal OpenAPI route extractor.
- `docs/openapi/`: manual OpenAPI contract documents.
- `scripts/lab/`: local Streamlit frontend for visual endpoint testing.
- `docs/`: product, API, schema and milestone documentation.
@@ -31,17 +30,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
`scripts/swagger-drogon/` scans Drogon `ADD_METHOD_TO` route macros and the
adjacent `openapi:` metadata blocks in `controllers/*.h`. It also inspects
controller/service `.cc` files for simple query, JSON field and response field
patterns, then merges the app-specific registry in `docs/openapi/visione.yaml`.
The CMake `openapi` target writes `build/openapi/visione.openapi.json`.
`scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros in
`controllers/*.h` and extracts only paths, HTTP methods, operation ids, tags and
path parameters. It merges those route skeletons with
`docs/openapi/visione.openapi.yaml`, which owns summaries, request bodies,
responses, schemas and examples. The manual file uses JSON-compatible YAML so
the CMake `openapi` target can run without extra Python packages and writes
`build/openapi/visione.openapi.json`.
This is a hybrid generator: C++ is the source of truth for routes and metadata,
while YAML provides schemas that cannot be safely inferred from dynamic
`Json::Value` payloads and raw SQL result rows. Generation fails if a route lacks
metadata, has unresolved schema refs, emits `/photos`, or leaves operation
request/response schemas as generic objects.
If the manual YAML contains an operation that no longer exists in the Drogon
route list, generation fails unless that operation is explicitly marked
`x-manual-only: true`.
## Visione Lab
`scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is
+3 -6
View File
@@ -8,16 +8,13 @@
- Added report media uploads at `POST /api/v1/reports/{id}/media`.
- Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads.
- Report attachments now use the `/media` upload path.
- Added generated OpenAPI JSON through `scripts/swagger-drogon/`, combining
Drogon route macros, adjacent metadata blocks, C++ inference and the Visione
schema registry.
- Added generated OpenAPI JSON from Drogon route macros plus the manual
`docs/openapi/visione.openapi.yaml` contract.
- Exposed `/api/v1/openapi.json` and `/api/v1/docs`.
- Added a small Streamlit API lab for visual endpoint testing.
## To do next
- Expand OpenAPI schema coverage as endpoints evolve.
- Consider extracting `scripts/generate_openapi.py` into an open-source standalone
project once the conventions stabilize.
- Expand the manual OpenAPI contract as endpoints evolve.
- Add minimal media upload integration tests after endpoint contracts settle.
- Keep API and docs on `media` terminology only.
- Keep the Streamlit lab small and replace it with the real frontend later.
File diff suppressed because it is too large Load Diff
-746
View File
@@ -1,746 +0,0 @@
{
"description": "Visione OpenAPI generated from Drogon routes and the Visione contract registry.",
"components": {
"schemas": {
"OkResponse": {
"type": "object",
"required": ["ok"],
"properties": {
"ok": { "type": "boolean" }
},
"additionalProperties": false
},
"ErrorResponse": {
"type": "object",
"required": ["error"],
"properties": {
"error": { "type": "string" },
"message": { "type": "string" },
"fields": {
"type": "array",
"items": { "type": "string" }
}
},
"additionalProperties": false
},
"HealthResponse": {
"type": "object",
"required": ["status", "service"],
"properties": {
"status": { "type": "string", "example": "ok" },
"service": { "type": "string", "example": "visione-backend" }
},
"additionalProperties": false
},
"ReadyResponse": {
"type": "object",
"required": ["status", "dependencies"],
"properties": {
"status": { "type": "string", "enum": ["ready", "not_ready"] },
"dependencies": {
"type": "object",
"required": ["mysql"],
"properties": {
"mysql": { "type": "boolean" }
},
"additionalProperties": false
},
"errors": {
"type": "object",
"properties": {
"mysql": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"OpenApiDocument": {
"type": "object",
"description": "Generated OpenAPI 3 document.",
"additionalProperties": true
},
"RegisterCitizenRequest": {
"type": "object",
"required": ["first_name", "last_name", "email", "password", "fiscal_code"],
"properties": {
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "format": "password", "minLength": 10 },
"fiscal_code": { "type": "string", "minLength": 16, "maxLength": 16 },
"birth_date": { "type": "string", "format": "date" },
"birth_place": { "type": "string" },
"phone": { "type": "string" }
},
"additionalProperties": false
},
"RegisterCitizenResponse": {
"type": "object",
"required": ["ok", "user_id"],
"properties": {
"ok": { "type": "boolean" },
"user_id": { "type": "integer", "format": "int64" }
},
"additionalProperties": false
},
"EmailRequest": {
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": "string", "format": "email" }
},
"additionalProperties": false
},
"OtpRequestResponse": {
"type": "object",
"required": ["ok", "delivery", "expires_in_minutes"],
"properties": {
"ok": { "type": "boolean" },
"delivery": { "type": "string", "example": "mailhog" },
"expires_in_minutes": { "type": "integer" }
},
"additionalProperties": false
},
"VerifyOtpRequest": {
"type": "object",
"required": ["email", "code"],
"properties": {
"email": { "type": "string", "format": "email" },
"code": { "type": "string" }
},
"additionalProperties": false
},
"PasswordLoginRequest": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "format": "password" }
},
"additionalProperties": false
},
"TokenResponse": {
"type": "object",
"required": ["ok", "token", "token_type", "expires_in_hours"],
"properties": {
"ok": { "type": "boolean" },
"token": { "type": "string" },
"token_type": { "type": "string", "example": "Bearer" },
"expires_in_hours": { "type": "integer" }
},
"additionalProperties": false
},
"ChangePasswordRequest": {
"type": "object",
"required": ["current_password", "new_password"],
"properties": {
"current_password": { "type": "string", "format": "password" },
"new_password": { "type": "string", "format": "password", "minLength": 10 }
},
"additionalProperties": false
},
"CurrentUser": {
"type": "object",
"required": ["id", "first_name", "last_name", "email", "fiscal_code", "points", "must_change_password", "role", "roles", "permissions"],
"properties": {
"id": { "type": "integer", "format": "int64" },
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"fiscal_code": { "type": "string" },
"phone": { "type": "string" },
"points": { "type": "integer" },
"must_change_password": { "type": "boolean" },
"role": { "type": "string" },
"roles": { "type": "array", "items": { "type": "string" } },
"permissions": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"CurrentUserResponse": {
"type": "object",
"required": ["ok", "user"],
"properties": {
"ok": { "type": "boolean" },
"user": { "$ref": "#/components/schemas/CurrentUser" }
},
"additionalProperties": false
},
"ReportStatus": {
"type": "string",
"enum": ["OPEN", "UNDER_REVIEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED", "REJECTED", "CLOSED"]
},
"CreateReportRequest": {
"type": "object",
"required": ["category_id", "latitude", "longitude", "address", "description"],
"properties": {
"category_id": { "type": "integer", "format": "int64" },
"latitude": { "type": "number", "format": "double" },
"longitude": { "type": "number", "format": "double" },
"address": { "type": "string" },
"description": { "type": "string" },
"media": { "type": "array", "items": { "type": "string", "format": "uri-reference" } }
},
"additionalProperties": false
},
"CreateReportResponse": {
"type": "object",
"required": ["ok", "report_id"],
"properties": {
"ok": { "type": "boolean" },
"report_id": { "type": "integer", "format": "int64" },
"area_id": { "type": "integer", "format": "int64" }
},
"additionalProperties": false
},
"Report": {
"type": "object",
"required": ["id", "category_id", "latitude", "longitude", "address", "description", "status"],
"properties": {
"id": { "type": "integer", "format": "int64" },
"user_id": { "type": "integer", "format": "int64" },
"category_id": { "type": "integer", "format": "int64" },
"category_name": { "type": "string" },
"area_id": { "type": "integer", "format": "int64" },
"area_name": { "type": "string" },
"assigned_operator_id": { "type": "integer", "format": "int64" },
"latitude": { "type": "number" },
"longitude": { "type": "number" },
"address": { "type": "string" },
"description": { "type": "string" },
"status": { "$ref": "#/components/schemas/ReportStatus" },
"created_at": { "type": "string" },
"updated_at": { "type": "string" }
},
"additionalProperties": true
},
"ReportListResponse": {
"type": "object",
"required": ["ok", "reports"],
"properties": {
"ok": { "type": "boolean" },
"reports": { "type": "array", "items": { "$ref": "#/components/schemas/Report" } }
},
"additionalProperties": false
},
"StatusHistoryItem": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"report_id": { "type": "integer", "format": "int64" },
"old_status": { "$ref": "#/components/schemas/ReportStatus" },
"new_status": { "$ref": "#/components/schemas/ReportStatus" },
"changed_by_user_id": { "type": "integer", "format": "int64" },
"note": { "type": "string" },
"created_at": { "type": "string" }
},
"additionalProperties": true
},
"Media": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"report_id": { "type": "integer", "format": "int64" },
"url": { "type": "string" },
"original_filename": { "type": "string" },
"content_type": { "type": "string" },
"media_kind": { "type": "string", "enum": ["IMAGE", "VIDEO"] },
"size_bytes": { "type": "integer", "format": "int64" },
"created_at": { "type": "string" }
},
"additionalProperties": true
},
"ReportDetailResponse": {
"type": "object",
"required": ["ok", "report", "history", "media"],
"properties": {
"ok": { "type": "boolean" },
"report": { "$ref": "#/components/schemas/Report" },
"history": { "type": "array", "items": { "$ref": "#/components/schemas/StatusHistoryItem" } },
"media": { "type": "array", "items": { "$ref": "#/components/schemas/Media" } }
},
"additionalProperties": false
},
"UpdateStatusRequest": {
"type": "object",
"required": ["status"],
"properties": {
"status": { "$ref": "#/components/schemas/ReportStatus" },
"note": { "type": "string" }
},
"additionalProperties": false
},
"MessageRequest": {
"type": "object",
"required": ["body"],
"properties": {
"body": { "type": "string" }
},
"additionalProperties": false
},
"Message": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"report_id": { "type": "integer", "format": "int64" },
"user_id": { "type": "integer", "format": "int64" },
"body": { "type": "string" },
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"created_at": { "type": "string" }
},
"additionalProperties": true
},
"MessageListResponse": {
"type": "object",
"required": ["ok", "messages"],
"properties": {
"ok": { "type": "boolean" },
"messages": { "type": "array", "items": { "$ref": "#/components/schemas/Message" } }
},
"additionalProperties": false
},
"AssignTeamRequest": {
"type": "object",
"required": ["team_id"],
"properties": {
"team_id": { "type": "integer", "format": "int64" }
},
"additionalProperties": false
},
"FeedbackRequest": {
"type": "object",
"required": ["rating"],
"properties": {
"rating": { "type": "integer", "minimum": 1, "maximum": 5 },
"comment": { "type": "string" }
},
"additionalProperties": false
},
"MediaUploadRequest": {
"type": "object",
"required": ["files"],
"properties": {
"files": {
"type": "array",
"maxItems": 5,
"items": { "type": "string", "format": "binary" }
}
},
"additionalProperties": false
},
"MediaUploadResponse": {
"type": "object",
"required": ["ok", "media"],
"properties": {
"ok": { "type": "boolean" },
"media": { "type": "array", "items": { "$ref": "#/components/schemas/Media" } }
},
"additionalProperties": false
},
"Category": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"name": { "type": "string" },
"description": { "type": "string" },
"active": { "type": "boolean" }
},
"additionalProperties": true
},
"CategoryListResponse": {
"type": "object",
"required": ["ok", "categories"],
"properties": {
"ok": { "type": "boolean" },
"categories": { "type": "array", "items": { "$ref": "#/components/schemas/Category" } }
},
"additionalProperties": false
},
"CreateCategoryRequest": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" }
},
"additionalProperties": false
},
"GeoJsonObject": {
"type": "object",
"description": "GeoJSON polygon or multipolygon boundary.",
"additionalProperties": true
},
"Area": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"name": { "type": "string" },
"description": { "type": "string" },
"boundary_geojson": { "$ref": "#/components/schemas/GeoJsonObject" },
"active": { "type": "boolean" }
},
"additionalProperties": true
},
"AreaListResponse": {
"type": "object",
"required": ["ok", "areas"],
"properties": {
"ok": { "type": "boolean" },
"areas": { "type": "array", "items": { "$ref": "#/components/schemas/Area" } }
},
"additionalProperties": false
},
"CreateAreaRequest": {
"type": "object",
"required": ["name", "boundary_geojson"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"boundary_geojson": { "$ref": "#/components/schemas/GeoJsonObject" }
},
"additionalProperties": false
},
"Team": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"team_category_id": { "type": "integer", "format": "int64" },
"category_name": { "type": "string" },
"area_id": { "type": "integer", "format": "int64" },
"area_name": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" },
"active": { "type": "boolean" }
},
"additionalProperties": true
},
"TeamListResponse": {
"type": "object",
"required": ["ok", "teams"],
"properties": {
"ok": { "type": "boolean" },
"teams": { "type": "array", "items": { "$ref": "#/components/schemas/Team" } }
},
"additionalProperties": false
},
"CreateTeamRequest": {
"type": "object",
"required": ["team_category_id", "name"],
"properties": {
"team_category_id": { "type": "integer", "format": "int64" },
"area_id": { "type": "integer", "format": "int64" },
"name": { "type": "string" },
"description": { "type": "string" }
},
"additionalProperties": false
},
"OverviewStatsResponse": {
"type": "object",
"required": ["ok", "reports_by_status", "reports_by_area", "avg_resolution_hours"],
"properties": {
"ok": { "type": "boolean" },
"reports_by_status": {
"type": "array",
"items": {
"type": "object",
"properties": {
"status": { "$ref": "#/components/schemas/ReportStatus" },
"total": { "type": "integer" }
},
"additionalProperties": true
}
},
"reports_by_area": {
"type": "array",
"items": {
"type": "object",
"properties": {
"area": { "type": "string" },
"total": { "type": "integer" }
},
"additionalProperties": true
}
},
"avg_resolution_hours": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": { "type": "number" }
},
"additionalProperties": true
}
}
},
"additionalProperties": false
},
"AdminUser": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"fiscal_code": { "type": "string" },
"phone": { "type": "string" },
"points": { "type": "integer" },
"banned": { "type": "boolean" },
"must_change_password": { "type": "boolean" },
"roles": { "type": "string", "description": "Comma-separated role names from SQL GROUP_CONCAT." }
},
"additionalProperties": true
},
"UserListResponse": {
"type": "object",
"required": ["ok", "users"],
"properties": {
"ok": { "type": "boolean" },
"users": { "type": "array", "items": { "$ref": "#/components/schemas/AdminUser" } }
},
"additionalProperties": false
},
"UpdateUserRolesRequest": {
"type": "object",
"required": ["roles"],
"properties": {
"roles": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"UpdateUserBanRequest": {
"type": "object",
"required": ["banned"],
"properties": {
"banned": { "type": "boolean" }
},
"additionalProperties": false
}
},
"responses": {
"BadRequest": {
"description": "Invalid request or business validation failure.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
},
"Unauthorized": {
"description": "Missing or invalid bearer token.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
},
"Forbidden": {
"description": "Authenticated user cannot perform this action.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
},
"NotFound": {
"description": "Requested resource was not found.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
}
}
},
"operations": {
"ApiController.health": {
"response": "#/components/schemas/HealthResponse"
},
"ApiController.ready": {
"response": "#/components/schemas/ReadyResponse",
"errors": {
"503": {
"description": "Dependency readiness failure.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReadyResponse" } } }
}
}
},
"ApiController.openApi": {
"response": "#/components/schemas/OpenApiDocument",
"errors": { "404": { "$ref": "#/components/responses/NotFound" } }
},
"ApiController.docs": {
"responseContentType": "text/html",
"response": { "type": "string" }
},
"AuthController.registerCitizen": {
"request": "#/components/schemas/RegisterCitizenRequest",
"response": "#/components/schemas/RegisterCitizenResponse",
"errors": { "400": { "$ref": "#/components/responses/BadRequest" } }
},
"AuthController.requestOtp": {
"request": "#/components/schemas/EmailRequest",
"response": "#/components/schemas/OtpRequestResponse",
"errors": { "400": { "$ref": "#/components/responses/BadRequest" } }
},
"AuthController.verifyOtp": {
"request": "#/components/schemas/VerifyOtpRequest",
"response": "#/components/schemas/TokenResponse",
"errors": { "400": { "$ref": "#/components/responses/BadRequest" } }
},
"AuthController.loginWithPassword": {
"request": "#/components/schemas/PasswordLoginRequest",
"response": "#/components/schemas/TokenResponse",
"errors": { "400": { "$ref": "#/components/responses/BadRequest" } }
},
"AuthController.changePassword": {
"request": "#/components/schemas/ChangePasswordRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AuthController.me": {
"response": "#/components/schemas/CurrentUserResponse",
"errors": { "401": { "$ref": "#/components/responses/Unauthorized" } }
},
"ReportController.create": {
"request": "#/components/schemas/CreateReportRequest",
"response": "#/components/schemas/CreateReportResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.list": {
"response": "#/components/schemas/ReportListResponse",
"errors": { "401": { "$ref": "#/components/responses/Unauthorized" } }
},
"ReportController.detail": {
"response": "#/components/schemas/ReportDetailResponse",
"errors": {
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.updateStatus": {
"request": "#/components/schemas/UpdateStatusRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.takeReport": {
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.addMessage": {
"request": "#/components/schemas/MessageRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.listMessages": {
"response": "#/components/schemas/MessageListResponse",
"errors": {
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.assignTeam": {
"request": "#/components/schemas/AssignTeamRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.addFeedback": {
"request": "#/components/schemas/FeedbackRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"ReportController.uploadMedia": {
"request": "#/components/schemas/MediaUploadRequest",
"response": "#/components/schemas/MediaUploadResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.listCategories": {
"response": "#/components/schemas/CategoryListResponse",
"errors": { "401": { "$ref": "#/components/responses/Unauthorized" } }
},
"AdminController.createCategory": {
"request": "#/components/schemas/CreateCategoryRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.listAreas": {
"response": "#/components/schemas/AreaListResponse",
"errors": { "401": { "$ref": "#/components/responses/Unauthorized" } }
},
"AdminController.createArea": {
"request": "#/components/schemas/CreateAreaRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.listTeams": {
"response": "#/components/schemas/TeamListResponse",
"errors": { "401": { "$ref": "#/components/responses/Unauthorized" } }
},
"AdminController.createTeam": {
"request": "#/components/schemas/CreateTeamRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.overviewStats": {
"response": "#/components/schemas/OverviewStatsResponse",
"errors": {
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.listUsers": {
"response": "#/components/schemas/UserListResponse",
"errors": {
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.updateUserRoles": {
"request": "#/components/schemas/UpdateUserRolesRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"AdminController.updateUserBan": {
"request": "#/components/schemas/UpdateUserBanRequest",
"response": "#/components/schemas/OkResponse",
"errors": {
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
}
}
}
+193 -9
View File
@@ -1,21 +1,205 @@
#!/usr/bin/env python3
"""Compatibility wrapper for the swagger-drogon package CLI."""
"""Generate OpenAPI JSON from Drogon routes plus a manual OpenAPI YAML file."""
from __future__ import annotations
import sys
import argparse
import copy
import json
import re
from pathlib import Path
from typing import Any
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.DOTALL,
)
METHODS = {"get", "post", "put", "patch", "delete"}
def controller_tag(controller: str) -> str:
return controller.removesuffix("Controller")
def operation_id(controller: str, handler: str) -> str:
return f"{controller}.{handler}"
def openapi_path(path: str) -> tuple[str, list[dict[str, Any]]]:
parameters: list[dict[str, Any]] = []
def replace(match: re.Match[str]) -> str:
index = int(match.group(1))
name = "id" if index == 1 else f"param{index}"
parameters.append(
{
"name": name,
"in": "path",
"required": True,
"schema": {"type": "integer", "format": "int64"},
}
)
return "{" + name + "}"
return re.sub(r"\{(\d+)\}", replace, path), parameters
def generated_operation(
controller: str,
handler: str,
parameters: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"tags": [controller_tag(controller)],
"operationId": operation_id(controller, handler),
"summary": handler_to_summary(handler),
"parameters": parameters,
"responses": {"200": {"description": "Response"}},
}
def handler_to_summary(handler: str) -> str:
words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", handler).replace("_", " ")
return words[:1].upper() + words[1:]
def parse_routes(controllers_dir: Path) -> dict[tuple[str, str], dict[str, Any]]:
operations: dict[tuple[str, str], dict[str, Any]] = {}
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, parameters = openapi_path(raw_path)
key = (path, method)
if key in operations:
raise ValueError(f"duplicate route for {method.upper()} {path}")
operations[key] = generated_operation(controller, handler, parameters)
return operations
def load_manual(path: Path | None) -> dict[str, Any]:
if path is None:
return {}
text = path.read_text(encoding="utf-8")
try:
import yaml # type: ignore[import-untyped]
except ImportError as error:
try:
data = json.loads(text)
except json.JSONDecodeError:
raise SystemExit(
"PyYAML is required unless the manual OpenAPI YAML uses "
"JSON-compatible syntax"
) from error
else:
data = yaml.safe_load(text)
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError(f"{path}: OpenAPI document must be an object")
return data
def merge_openapi(
generated: dict[tuple[str, str], dict[str, Any]],
manual: dict[str, Any],
title: str,
version: str,
server: str,
) -> dict[str, Any]:
spec: dict[str, Any] = {
"openapi": manual.get("openapi", "3.0.3"),
"info": copy.deepcopy(
manual.get("info", {"title": title, "version": version})
),
"servers": copy.deepcopy(manual.get("servers", [{"url": server}])),
"paths": {},
}
for key in ("components", "tags", "externalDocs"):
if key in manual:
spec[key] = copy.deepcopy(manual[key])
manual_paths = manual.get("paths", {})
if not isinstance(manual_paths, dict):
raise ValueError("manual OpenAPI paths must be an object")
seen_manual: set[tuple[str, str]] = set()
for path, path_item in manual_paths.items():
if not isinstance(path_item, dict):
raise ValueError(f"{path}: path item must be an object")
for method, manual_operation in path_item.items():
method_lower = method.lower()
if method_lower not in METHODS:
continue
key = (path, method_lower)
seen_manual.add(key)
if key not in generated and not manual_operation.get("x-manual-only", False):
raise ValueError(
f"{method_upper(method_lower)} {path}: manual operation has no matching Drogon route"
)
for key, operation in sorted(generated.items()):
path, method = key
manual_operation = manual_paths.get(path, {}).get(method, {})
merged = copy.deepcopy(operation)
merged.update(copy.deepcopy(manual_operation))
merged.pop("x-manual-only", None)
spec["paths"].setdefault(path, {})[method] = merged
for key in sorted(seen_manual - generated.keys()):
path, method = key
manual_operation = copy.deepcopy(manual_paths[path][method])
if not manual_operation.get("x-manual-only", False):
continue
manual_operation.pop("x-manual-only", None)
spec["paths"].setdefault(path, {})[method] = manual_operation
return spec
def method_upper(method: str) -> str:
return method.upper()
def generate(
controllers_dir: Path,
manual_path: Path | None,
title: str,
version: str,
server: str,
) -> dict[str, Any]:
generated = parse_routes(controllers_dir)
manual = load_manual(manual_path)
return merge_openapi(generated, manual, title, version, server)
def main() -> None:
package_root = Path(__file__).resolve().parent / "swagger-drogon"
sys.path.insert(0, str(package_root))
parser = argparse.ArgumentParser()
parser.add_argument("--controllers", type=Path, required=True)
parser.add_argument("--manual", type=Path)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--title", default="Visione API")
parser.add_argument("--version", default="0.1.0")
parser.add_argument("--server", default="http://localhost:5555")
args = parser.parse_args()
from swagger_drogon.cli import main as cli_main
if len(sys.argv) > 1 and sys.argv[1] != "generate":
sys.argv.insert(1, "generate")
cli_main()
spec = generate(
controllers_dir=args.controllers,
manual_path=args.manual,
title=args.title,
version=args.version,
server=args.server,
)
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__":
-26
View File
@@ -1,26 +0,0 @@
# swagger-drogon
`swagger-drogon` generates OpenAPI 3 documents from Drogon
`HttpController` route macros.
The generator is intentionally hybrid:
- C++ route lists remain the source of truth for paths, methods and handlers.
- Adjacent `openapi:` comment blocks provide operation metadata.
- The generator infers simple path, query and JSON field usage from C++ code.
- An app contract file provides schemas that cannot be safely inferred from
dynamic `Json::Value` and SQL result rows.
- Strict validation fails generation when an operation still has generic request
or response bodies.
The contract file may be normal YAML when `PyYAML` is installed. Without
external dependencies, the CLI also accepts JSON-compatible YAML, which is valid
YAML syntax and keeps local CMake builds self-contained.
```bash
python3 -m swagger_drogon generate \
--controllers ../../controllers \
--sources ../../controllers ../../services \
--contract ../../docs/openapi/visione.yaml \
--out /tmp/visione.openapi.json
```
-8
View File
@@ -1,8 +0,0 @@
[project]
name = "swagger-drogon"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
swagger-drogon = "swagger_drogon.cli:main"
@@ -1,5 +0,0 @@
"""OpenAPI generator for Drogon HttpController route macros."""
from .generator import generate
__all__ = ["generate"]
@@ -1,4 +0,0 @@
from .cli import main
if __name__ == "__main__":
main()
@@ -1,41 +0,0 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from .contract import load_contract
from .generator import generate
def main() -> None:
parser = argparse.ArgumentParser(prog="swagger-drogon")
subparsers = parser.add_subparsers(dest="command")
generate_parser = subparsers.add_parser("generate")
generate_parser.add_argument("--controllers", type=Path, required=True)
generate_parser.add_argument("--sources", type=Path, nargs="*", default=[])
generate_parser.add_argument("--contract", type=Path)
generate_parser.add_argument("--out", type=Path, required=True)
generate_parser.add_argument("--title", default="Drogon API")
generate_parser.add_argument("--version", default="0.1.0")
generate_parser.add_argument("--server", default="http://localhost:5555")
args = parser.parse_args()
if args.command != "generate":
parser.error("expected command: generate")
contract = load_contract(args.contract) if args.contract else {}
spec = generate(
controllers_dir=args.controllers,
source_dirs=args.sources,
contract=contract,
title=args.title,
version=args.version,
server=args.server,
)
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",
)
@@ -1,22 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
def load_contract(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
try:
return json.loads(text)
except json.JSONDecodeError:
try:
import yaml # type: ignore[import-untyped]
except ImportError as error:
raise ValueError(
f"{path}: install PyYAML or use JSON-compatible YAML syntax"
) from error
data = yaml.safe_load(text)
if not isinstance(data, dict):
raise ValueError(f"{path}: contract root must be an object")
return data
@@ -1,77 +0,0 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from .infer import infer_operation, load_sources
from .models import Route
from .openapi import build_operation, build_path, merge_components
from .parser import parse_routes
from .validate import validate_spec
def generate(
controllers_dir: Path,
source_dirs: list[Path],
contract: dict[str, Any],
title: str,
version: str,
server: str,
) -> dict[str, Any]:
sources = load_sources(source_dirs)
spec: dict[str, Any] = {
"openapi": "3.0.3",
"info": {
"title": title,
"version": version,
"description": contract.get(
"description",
"Generated from Drogon route macros, C++ metadata and schemas.",
),
},
"servers": [{"url": server}],
"components": merge_components(contract),
"paths": {},
}
operations_contract = contract.get("operations", {})
if not isinstance(operations_contract, dict):
raise ValueError("contract operations must be an object")
for route in parse_routes(controllers_dir):
inferred = infer_operation(route, sources)
path, path_params = build_path(route.raw_path, _handler_path_param_names(route))
inferred.path_parameters = path_params
operation_contract = operations_contract.get(route.operation_id, {})
if not isinstance(operation_contract, dict):
raise ValueError(f"{route.operation_id}: operation contract must be an object")
operation = build_operation(route, inferred, operation_contract)
spec["paths"].setdefault(path, {})[route.http_method] = operation
validate_spec(spec)
return spec
def _handler_path_param_names(route: Route) -> list[str]:
text = route.header.read_text(encoding="utf-8")
match = re.search(
rf"void\s+{route.handler}\s*\((.*?)\)\s*;",
text,
re.MULTILINE | re.DOTALL,
)
if not match:
return []
params = match.group(1)
names: list[str] = []
for type_name, name in re.findall(r"\b(long long|int|size_t)\s+([A-Za-z_]\w*)", params):
del type_name
if name not in {"req"}:
names.append(_openapi_param_name(name))
return names
def _openapi_param_name(name: str) -> str:
if name.endswith("Id") and len(name) > 2:
return "id" if name == "reportId" else name[:-2] + "_id"
return name
@@ -1,163 +0,0 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from .models import InferredOperation, Route
def load_sources(source_dirs: list[Path]) -> dict[Path, str]:
sources: dict[Path, str] = {}
for directory in source_dirs:
if not directory.exists():
continue
for path in sorted(directory.glob("*.cc")):
sources[path] = path.read_text(encoding="utf-8")
return sources
def infer_operation(route: Route, sources: dict[Path, str]) -> InferredOperation:
controller_body = _find_controller_body(route, sources)
service_body = _find_service_body(controller_body, sources)
combined = "\n".join(part for part in (controller_body, service_body) if part)
inferred = InferredOperation()
inferred.query_parameters = _infer_query_parameters(controller_body)
inferred.request_schema = _infer_request_schema(combined)
inferred.response_schema = _infer_response_schema(combined)
return inferred
def _find_controller_body(route: Route, sources: dict[Path, str]) -> str:
pattern = f"void {route.controller}::{route.handler}"
for path, text in sources.items():
if path.name != f"{route.controller}.cc":
continue
index = text.find(pattern)
if index == -1:
return ""
brace = text.find("{", index)
if brace == -1:
return ""
return _balanced_block(text, brace)
return ""
def _find_service_body(controller_body: str, sources: dict[Path, str]) -> str:
match = re.search(r"\bservice\.([A-Za-z_]\w*)\s*\(", controller_body)
if not match:
return ""
method = match.group(1)
pattern = re.compile(rf"Json::Value\s+[A-Za-z_]\w*Service::{method}\s*\(")
for path, text in sources.items():
if not path.name.endswith("Service.cc"):
continue
match = pattern.search(text)
if not match:
continue
brace = text.find("{", match.start())
if brace == -1:
return ""
return _balanced_block(text, brace)
return ""
def _balanced_block(text: str, open_brace: int) -> str:
depth = 0
for index in range(open_brace, len(text)):
char = text[index]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return text[open_brace + 1:index]
return ""
def _infer_query_parameters(controller_body: str) -> list[dict[str, Any]]:
names = sorted(set(re.findall(r'queryValue\(\s*req\s*,\s*"([^"]+)"\s*\)', controller_body)))
return [
{
"name": name,
"in": "query",
"required": False,
"schema": _schema_for_field(name),
}
for name in names
]
def _infer_request_schema(text: str) -> dict[str, Any] | None:
properties: dict[str, Any] = {}
required: set[str] = set()
for group in re.findall(r"missingStrings\(\s*body\s*,\s*\{([^}]+)\}\s*\)", text):
for field in re.findall(r'"([^"]+)"', group):
properties[field] = {"type": "string"}
required.add(field)
for field in re.findall(r'hasString\(\s*body\s*,\s*"([^"]+)"\s*\)', text):
properties.setdefault(field, {"type": "string"})
for field in re.findall(r'hasNumber\(\s*body\s*,\s*"([^"]+)"\s*\)', text):
properties.setdefault(field, _schema_for_field(field))
required.add(field)
for field in re.findall(r'body\["([^"]+)"\]\.isBool\(\)', text):
properties.setdefault(field, {"type": "boolean"})
for field in re.findall(r'body\["([^"]+)"\]\.isArray\(\)', text):
properties.setdefault(field, {"type": "array", "items": {}})
for field in re.findall(r'body\["([^"]+)"\]\.isObject\(\)', text):
properties.setdefault(field, {"type": "object", "additionalProperties": True})
for field in re.findall(r'body\.get\(\s*"([^"]+)"', text):
properties.setdefault(field, {"type": "string"})
if not properties:
return None
schema: dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"properties": properties,
}
if required:
schema["required"] = sorted(required)
return schema
def _infer_response_schema(text: str) -> dict[str, Any] | None:
properties: dict[str, Any] = {}
if "success()" in text or 'body["ok"] = true' in text:
properties["ok"] = {"type": "boolean"}
for field in re.findall(r'response\["([^"]+)"\]', text):
properties.setdefault(field, _schema_for_field(field))
for field in re.findall(r'body\["([^"]+)"\]', text):
properties.setdefault(field, _schema_for_field(field))
if not properties:
return None
return {
"type": "object",
"additionalProperties": True,
"properties": properties,
}
def _schema_for_field(name: str) -> dict[str, Any]:
if name == "status":
return {"type": "string"}
if name == "id" or name.endswith("_id") or name.endswith("_hours") or name.endswith("_minutes"):
return {"type": "integer", "format": "int64"}
if name in {"latitude", "longitude", "rating", "points", "total", "value"}:
return {"type": "number"}
if name.startswith("is_") or name in {"ok", "banned", "must_change_password"}:
return {"type": "boolean"}
if name.endswith("s") or name in {"roles", "permissions", "reports", "messages", "media"}:
return {"type": "array", "items": {"type": "object", "additionalProperties": True}}
if name.endswith("_geojson") or name in {"user", "report"}:
return {"type": "object", "additionalProperties": True}
return {"type": "string"}
@@ -1,27 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class Route:
controller: str
handler: str
raw_path: str
http_method: str
metadata: dict[str, str]
header: Path
@property
def operation_id(self) -> str:
return f"{self.controller}.{self.handler}"
@dataclass
class InferredOperation:
path_parameters: list[dict[str, Any]] = field(default_factory=list)
query_parameters: list[dict[str, Any]] = field(default_factory=list)
request_schema: dict[str, Any] | None = None
response_schema: dict[str, Any] | None = None
@@ -1,163 +0,0 @@
from __future__ import annotations
import copy
import re
from typing import Any
from .models import InferredOperation, Route
from .parser import parse_tags
METHODS_WITH_JSON_BODY = {"post", "put", "patch"}
def build_path(raw_path: str, names: list[str] | None = None) -> tuple[str, list[dict[str, Any]]]:
parameters: list[dict[str, Any]] = []
names = names or []
def replace(match: re.Match[str]) -> str:
index = int(match.group(1)) - 1
name = names[index] if index < len(names) else ("id" if index == 0 else f"param{index + 1}")
parameters.append(
{
"name": name,
"in": "path",
"required": True,
"schema": {"type": "integer", "format": "int64"},
}
)
return "{" + name + "}"
return re.sub(r"\{(\d+)\}", replace, raw_path), parameters
def build_operation(
route: Route,
inferred: InferredOperation,
operation_contract: dict[str, Any],
) -> dict[str, Any]:
metadata = route.metadata
response_schema = _schema_from_contract(operation_contract, "response")
if response_schema is None:
response_schema = inferred.response_schema
op: dict[str, Any] = {
"tags": parse_tags(metadata["tags"]),
"operationId": route.operation_id,
"summary": metadata["summary"],
"description": metadata["description"],
"parameters": [*inferred.path_parameters, *inferred.query_parameters],
"responses": _responses(metadata["response"], response_schema, operation_contract),
}
request_body = _request_body(route, inferred, operation_contract)
if request_body is not None:
op["requestBody"] = request_body
if metadata["auth"] == "bearer":
op["security"] = [{"bearerAuth": []}]
elif metadata["auth"] != "public":
raise ValueError(f"{route.header}: unsupported auth kind: {metadata['auth']}")
return op
def merge_components(contract: dict[str, Any]) -> dict[str, Any]:
components = copy.deepcopy(contract.get("components", {}))
components.setdefault("securitySchemes", {})
components["securitySchemes"].setdefault(
"bearerAuth", {"type": "http", "scheme": "bearer"}
)
return components
def _request_body(
route: Route,
inferred: InferredOperation,
operation_contract: dict[str, Any],
) -> dict[str, Any] | None:
body_kind = route.metadata["body"]
if body_kind == "none":
return None
explicit = operation_contract.get("requestBody")
if explicit is not None:
return copy.deepcopy(explicit)
schema = _schema_from_contract(operation_contract, "request")
if schema is None:
schema = inferred.request_schema
if body_kind == "multipart":
if schema is None:
schema = {
"type": "object",
"required": ["files"],
"properties": {
"files": {
"type": "array",
"items": {"type": "string", "format": "binary"},
}
},
}
return {
"required": True,
"content": {"multipart/form-data": {"schema": schema}},
}
if body_kind == "json" and route.http_method in METHODS_WITH_JSON_BODY:
return {
"required": True,
"content": {"application/json": {"schema": schema or {"type": "object"}}},
}
raise ValueError(
f"{route.header}: unsupported body kind {body_kind} for {route.http_method.upper()}"
)
def _responses(
description: str,
response_schema: dict[str, Any] | None,
operation_contract: dict[str, Any],
) -> dict[str, Any]:
if "responses" in operation_contract:
return copy.deepcopy(operation_contract["responses"])
content_type = operation_contract.get("responseContentType", "application/json")
if content_type == "text/html":
success = {
"description": description,
"content": {"text/html": {"schema": {"type": "string"}}},
}
else:
success = {
"description": description,
"content": {
content_type: {
"schema": response_schema or {"type": "object"},
}
},
}
responses: dict[str, Any] = {"200": success}
if operation_contract.get("created", False):
responses = {"201": success}
for status, response in operation_contract.get("errors", {}).items():
responses[str(status)] = copy.deepcopy(response)
return responses
def _schema_from_contract(
operation_contract: dict[str, Any],
key: str,
) -> dict[str, Any] | None:
value = operation_contract.get(key)
if value is None:
return None
if isinstance(value, str):
return {"$ref": value}
if isinstance(value, dict):
return copy.deepcopy(value)
raise ValueError(f"operation contract field {key} must be string or object")
@@ -1,75 +0,0 @@
from __future__ import annotations
import re
from pathlib import Path
from .models import Route
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.DOTALL,
)
REQUIRED_METADATA = {"tags", "summary", "description", "auth", "body", "response"}
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 parse_routes(controllers_dir: Path) -> list[Route]:
routes: list[Route] = []
for header in sorted(controllers_dir.glob("*.h")):
text = header.read_text(encoding="utf-8")
for match in ROUTE_RE.finditer(text):
controller, handler, raw_path, drogon_method = match.groups()
routes.append(
Route(
controller=controller,
handler=handler,
raw_path=raw_path,
http_method=drogon_method.lower(),
metadata=parse_metadata(header, text, match.start()),
header=header,
)
)
return routes
def parse_tags(value: str) -> list[str]:
return [tag.strip() for tag in value.split(",") if tag.strip()]
@@ -1,99 +0,0 @@
from __future__ import annotations
from typing import Any
def validate_spec(spec: dict[str, Any]) -> None:
_validate_no_photos(spec)
_validate_refs(spec)
_validate_operations(spec)
def _validate_no_photos(spec: dict[str, Any]) -> None:
for path in spec.get("paths", {}):
if "/photos" in path:
raise ValueError(f"generated OpenAPI path still uses /photos: {path}")
def _validate_refs(spec: dict[str, Any]) -> None:
for ref in _walk_refs(spec):
_resolve_ref(spec, ref)
def _validate_operations(spec: dict[str, Any]) -> None:
for path, path_item in spec.get("paths", {}).items():
for method, operation in path_item.items():
label = f"{method.upper()} {path}"
if method.lower() == "get" and "requestBody" in operation:
raise ValueError(f"{label}: GET operation must not have requestBody")
request_body = operation.get("requestBody")
if request_body is not None:
for schema in _content_schemas(request_body):
if _is_generic_object(spec, schema):
raise ValueError(f"{label}: request schema is generic object")
success = _success_response(operation)
if success is None:
raise ValueError(f"{label}: missing 2xx response")
for schema in _content_schemas(success):
if _is_generic_object(spec, schema):
raise ValueError(f"{label}: response schema is generic object")
def _success_response(operation: dict[str, Any]) -> dict[str, Any] | None:
for status in sorted(operation.get("responses", {})):
if str(status).startswith("2"):
return operation["responses"][status]
return None
def _content_schemas(container: dict[str, Any]) -> list[dict[str, Any]]:
schemas: list[dict[str, Any]] = []
for media in container.get("content", {}).values():
schema = media.get("schema")
if isinstance(schema, dict):
schemas.append(schema)
return schemas
def _is_generic_object(spec: dict[str, Any], schema: dict[str, Any]) -> bool:
resolved = _resolve_schema(spec, schema)
if resolved.get("type") != "object":
return False
meaningful = {"properties", "additionalProperties", "oneOf", "anyOf", "allOf"}
return not any(key in resolved for key in meaningful)
def _resolve_schema(spec: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
if "$ref" not in schema:
return schema
resolved = _resolve_ref(spec, schema["$ref"])
if not isinstance(resolved, dict):
raise ValueError(f"{schema['$ref']}: does not resolve to an object")
return resolved
def _resolve_ref(spec: dict[str, Any], ref: str) -> Any:
if not ref.startswith("#/"):
raise ValueError(f"external refs are not supported: {ref}")
current: Any = spec
for part in ref[2:].split("/"):
if not isinstance(current, dict) or part not in current:
raise ValueError(f"unresolved OpenAPI ref: {ref}")
current = current[part]
return current
def _walk_refs(value: Any) -> list[str]:
refs: list[str] = []
if isinstance(value, dict):
ref = value.get("$ref")
if isinstance(ref, str):
refs.append(ref)
for child in value.values():
refs.extend(_walk_refs(child))
elif isinstance(value, list):
for child in value:
refs.extend(_walk_refs(child))
return refs
@@ -1,170 +0,0 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from swagger_drogon.contract import load_contract
from swagger_drogon.generator import generate
from swagger_drogon.infer import infer_operation, load_sources
from swagger_drogon.parser import parse_routes
class SwaggerDrogonTest(unittest.TestCase):
def test_parse_route_metadata(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
controllers = root / "controllers"
controllers.mkdir()
(controllers / "SampleController.h").write_text(
"""
class SampleController {
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Sample
* summary: List samples
* description: Lists samples.
* auth: public
* body: none
* response: Sample list.
*/
ADD_METHOD_TO(SampleController::list, "/api/v1/samples", drogon::Get);
METHOD_LIST_END
};
""",
encoding="utf-8",
)
routes = parse_routes(controllers)
self.assertEqual(len(routes), 1)
self.assertEqual(routes[0].operation_id, "SampleController.list")
self.assertEqual(routes[0].metadata["summary"], "List samples")
def test_infer_query_and_json_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
controllers = root / "controllers"
services = root / "services"
controllers.mkdir()
services.mkdir()
header = controllers / "SampleController.h"
header.write_text(
"""
class SampleController {
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Sample
* summary: Create sample
* description: Creates a sample.
* auth: bearer
* body: json
* response: Created sample.
*/
ADD_METHOD_TO(SampleController::create, "/api/v1/samples", drogon::Post);
METHOD_LIST_END
};
""",
encoding="utf-8",
)
(controllers / "SampleController.cc").write_text(
"""
void SampleController::create(const drogon::HttpRequestPtr &req) {
auto status = queryValue(req, "status");
SampleService service;
service.create(body);
}
""",
encoding="utf-8",
)
(services / "SampleService.cc").write_text(
"""
Json::Value SampleService::create(const Json::Value &body) {
const auto missing = validation::missingStrings(body, {"name"});
if (!validation::hasNumber(body, "category_id")) {}
auto response = success();
response["sample_id"] = Json::Int64(1);
return response;
}
""",
encoding="utf-8",
)
route = parse_routes(controllers)[0]
inferred = infer_operation(route, load_sources([controllers, services]))
self.assertEqual(inferred.query_parameters[0]["name"], "status")
self.assertIn("name", inferred.request_schema["required"])
self.assertIn("category_id", inferred.request_schema["required"])
self.assertIn("sample_id", inferred.response_schema["properties"])
def test_strict_validation_rejects_generic_contract(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
controllers = root / "controllers"
controllers.mkdir()
(controllers / "SampleController.h").write_text(
"""
class SampleController {
METHOD_LIST_BEGIN
/*
* openapi:
* tags: Sample
* summary: Create sample
* description: Creates a sample.
* auth: public
* body: json
* response: Created sample.
*/
ADD_METHOD_TO(SampleController::create, "/api/v1/samples", drogon::Post);
METHOD_LIST_END
};
""",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "generic object"):
generate(
controllers_dir=controllers,
source_dirs=[],
contract={
"operations": {
"SampleController.create": {
"request": {"type": "object"},
"response": {"type": "object"},
}
}
},
title="Sample",
version="0.1.0",
server="http://localhost",
)
def test_visione_contract_generates_concrete_media_path(self) -> None:
repo = Path(__file__).resolve().parents[3]
spec = generate(
controllers_dir=repo / "controllers",
source_dirs=[repo / "controllers", repo / "services"],
contract=load_contract(repo / "docs/openapi/visione.yaml"),
title="Visione API",
version="0.1.0",
server="http://localhost:5555",
)
self.assertIn("/api/v1/reports/{id}/media", spec["paths"])
self.assertFalse(any("/photos" in path for path in spec["paths"]))
for path, path_item in spec["paths"].items():
for method, operation in path_item.items():
if method == "get":
self.assertNotIn("requestBody", operation, path)
success = next(
response
for status, response in operation["responses"].items()
if str(status).startswith("2")
)
self.assertTrue(success.get("content"), operation["operationId"])
json.dumps(spec)
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
from pathlib import Path
SCRIPT_PATH = Path(__file__).with_name("generate_openapi.py")
SPEC = importlib.util.spec_from_file_location("generate_openapi", SCRIPT_PATH)
generate_openapi = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(generate_openapi)
class GenerateOpenApiTest(unittest.TestCase):
def test_extracts_drogon_route(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
controllers = Path(tmp)
(controllers / "SampleController.h").write_text(
"""
class SampleController {
METHOD_LIST_BEGIN
ADD_METHOD_TO(SampleController::detail,
"/api/v1/samples/{1}",
drogon::Get);
METHOD_LIST_END
};
""",
encoding="utf-8",
)
routes = generate_openapi.parse_routes(controllers)
operation = routes[("/api/v1/samples/{id}", "get")]
self.assertEqual(operation["operationId"], "SampleController.detail")
self.assertEqual(operation["parameters"][0]["name"], "id")
def test_manual_operation_overrides_generated_defaults(self) -> None:
generated = {
("/api/v1/samples", "get"): {
"tags": ["Sample"],
"operationId": "SampleController.list",
"summary": "List",
"parameters": [],
"responses": {"200": {"description": "Response"}},
}
}
manual = {
"openapi": "3.0.3",
"info": {"title": "Manual", "version": "1.0.0"},
"paths": {
"/api/v1/samples": {
"get": {
"summary": "Manual summary",
"responses": {"200": {"description": "Manual response"}},
}
}
},
}
spec = generate_openapi.merge_openapi(
generated, manual, "Generated", "0.1.0", "http://localhost"
)
operation = spec["paths"]["/api/v1/samples"]["get"]
self.assertEqual(operation["summary"], "Manual summary")
self.assertEqual(operation["operationId"], "SampleController.list")
def test_stale_manual_operation_fails_unless_marked_manual_only(self) -> None:
manual = {
"paths": {
"/api/v1/stale": {
"get": {"responses": {"200": {"description": "Stale"}}}
}
}
}
with self.assertRaisesRegex(ValueError, "no matching Drogon route"):
generate_openapi.merge_openapi({}, manual, "API", "0.1.0", "http://x")
manual["paths"]["/api/v1/stale"]["get"]["x-manual-only"] = True
spec = generate_openapi.merge_openapi({}, manual, "API", "0.1.0", "http://x")
self.assertIn("/api/v1/stale", spec["paths"])
self.assertNotIn("x-manual-only", spec["paths"]["/api/v1/stale"]["get"])
if __name__ == "__main__":
unittest.main()