From 21883a4a8f67a763e5fb75e105dbbfd2e7b66ba9 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Wed, 3 Jun 2026 01:52:43 +0200 Subject: [PATCH] Add swagger-drogon generator --- CMakeLists.txt | 14 + README.md | 8 +- docs/ARCHITECTURE.md | 22 +- docs/MILESTONE_03.md | 7 +- docs/openapi/visione.yaml | 746 ++++++++++++++++++ scripts/generate_openapi.py | 198 +---- scripts/swagger-drogon/README.md | 26 + scripts/swagger-drogon/pyproject.toml | 8 + .../swagger-drogon/swagger_drogon/__init__.py | 5 + .../swagger-drogon/swagger_drogon/__main__.py | 4 + scripts/swagger-drogon/swagger_drogon/cli.py | 41 + .../swagger-drogon/swagger_drogon/contract.py | 22 + .../swagger_drogon/generator.py | 77 ++ .../swagger-drogon/swagger_drogon/infer.py | 163 ++++ .../swagger-drogon/swagger_drogon/models.py | 27 + .../swagger-drogon/swagger_drogon/openapi.py | 163 ++++ .../swagger-drogon/swagger_drogon/parser.py | 75 ++ .../swagger-drogon/swagger_drogon/validate.py | 99 +++ .../swagger-drogon/tests/test_generator.py | 170 ++++ 19 files changed, 1672 insertions(+), 203 deletions(-) create mode 100644 docs/openapi/visione.yaml create mode 100644 scripts/swagger-drogon/README.md create mode 100644 scripts/swagger-drogon/pyproject.toml create mode 100644 scripts/swagger-drogon/swagger_drogon/__init__.py create mode 100644 scripts/swagger-drogon/swagger_drogon/__main__.py create mode 100644 scripts/swagger-drogon/swagger_drogon/cli.py create mode 100644 scripts/swagger-drogon/swagger_drogon/contract.py create mode 100644 scripts/swagger-drogon/swagger_drogon/generator.py create mode 100644 scripts/swagger-drogon/swagger_drogon/infer.py create mode 100644 scripts/swagger-drogon/swagger_drogon/models.py create mode 100644 scripts/swagger-drogon/swagger_drogon/openapi.py create mode 100644 scripts/swagger-drogon/swagger_drogon/parser.py create mode 100644 scripts/swagger-drogon/swagger_drogon/validate.py create mode 100644 scripts/swagger-drogon/tests/test_generator.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 61ee45b..8680224 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,18 +73,32 @@ 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 --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} ${CONTROLLER_HEADERS} + ${OPENAPI_SOURCE_FILES} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} VERBATIM ) diff --git a/README.md b/README.md index c87ded7..202c516 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,16 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon ## OpenAPI -The OpenAPI spec is generated from Drogon controller route macros, not written -as a static Swagger file. +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. ```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 ``` The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43ac3a3..b0ee0c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,9 @@ - `repositories/`: database helpers and migrations. - `common/`: reusable HTTP, auth, crypto, email and validation utilities. - `migrations/`: versioned SQL files. -- `scripts/generate_openapi.py`: OpenAPI generator. +- `scripts/swagger-drogon/`: OpenAPI generator package. +- `scripts/generate_openapi.py`: compatibility wrapper for the generator. +- `docs/openapi/`: app-specific OpenAPI schema registry. - `scripts/lab/`: local Streamlit frontend for visual endpoint testing. - `docs/`: product, API, schema and milestone documentation. @@ -29,15 +31,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/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros and the -adjacent `openapi:` metadata blocks in `controllers/*.h`, then generates -`build/openapi/visione.openapi.json`. +`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`. -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, summaries, descriptions, auth hints -and common JSON or multipart request bodies. Generation fails if a route lacks -metadata. +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. ## Visione Lab `scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is diff --git a/docs/MILESTONE_03.md b/docs/MILESTONE_03.md index cfabe0c..114b8af 100644 --- a/docs/MILESTONE_03.md +++ b/docs/MILESTONE_03.md @@ -8,13 +8,14 @@ - 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 from Drogon controller route macros and adjacent - metadata blocks. +- Added generated OpenAPI JSON through `scripts/swagger-drogon/`, combining + Drogon route macros, adjacent metadata blocks, C++ inference and the Visione + schema registry. - Exposed `/api/v1/openapi.json` and `/api/v1/docs`. - Added a small Streamlit API lab for visual endpoint testing. ## To do next -- Add richer OpenAPI schema metadata for request and response bodies. +- Expand OpenAPI schema coverage as endpoints evolve. - Consider extracting `scripts/generate_openapi.py` into an open-source standalone project once the conventions stabilize. - Add minimal media upload integration tests after endpoint contracts settle. diff --git a/docs/openapi/visione.yaml b/docs/openapi/visione.yaml new file mode 100644 index 0000000..dad1574 --- /dev/null +++ b/docs/openapi/visione.yaml @@ -0,0 +1,746 @@ +{ + "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" } + } + } + } +} diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index c1330fc..16ace68 100644 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -1,201 +1,21 @@ #!/usr/bin/env python3 -"""Generate a compact OpenAPI spec from Drogon route macros and metadata blocks.""" +"""Compatibility wrapper for the swagger-drogon package CLI.""" from __future__ import annotations -import argparse -import json -import re +import sys from pathlib import Path -ROUTE_RE = re.compile( - r"ADD_METHOD_TO\(\s*([A-Za-z_]\w*)::([A-Za-z_]\w*)\s*,\s*" - r'"([^"]+)"\s*,\s*drogon::([A-Za-z]+)\s*\)', - re.MULTILINE | re.DOTALL, -) - - -METHODS_WITH_JSON_BODY = {"post", "put", "patch"} -REQUIRED_METADATA = {"tags", "summary", "description", "auth", "body", "response"} - - -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 parse_metadata(header: Path, text: str, route_start: int) -> dict[str, str]: - prefix = text[:route_start].rstrip() - if not prefix.endswith("*/"): - raise ValueError(f"{header}: route is missing an openapi metadata block") - - comment_start = prefix.rfind("/*") - if comment_start == -1: - raise ValueError(f"{header}: route is missing an openapi metadata block") - - block = prefix[comment_start + 2:-2] - metadata: dict[str, str] = {} - in_openapi_block = False - - for raw_line in block.splitlines(): - line = raw_line.strip() - if line.startswith("*"): - line = line[1:].strip() - if not line: - continue - if line == "openapi:": - in_openapi_block = True - continue - if not in_openapi_block: - continue - - match = re.match(r"([A-Za-z_]+):\s*(.+)", line) - if match: - metadata[match.group(1)] = match.group(2).strip() - - missing = sorted(REQUIRED_METADATA - metadata.keys()) - if missing: - names = ", ".join(missing) - raise ValueError(f"{header}: openapi metadata block is missing: {names}") - - return metadata - - -def request_body(body_kind: str, method: str) -> dict | None: - if body_kind == "none": - return None - if body_kind == "multipart": - return { - "required": True, - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": {"type": "string", "format": "binary"}, - } - }, - } - } - }, - } - if body_kind == "json" and method in METHODS_WITH_JSON_BODY: - return { - "required": True, - "content": { - "application/json": {"schema": {"type": "object"}} - }, - } - if body_kind == "json": - raise ValueError(f"JSON body is not supported for {method.upper()} routes") - raise ValueError(f"Unsupported request body kind: {body_kind}") - - -def parse_tags(value: str) -> list[str]: - return [tag.strip() for tag in value.split(",") if tag.strip()] - - -def is_public(metadata: dict[str, str]) -> bool: - auth = metadata["auth"] - if auth == "public": - return True - if auth == "bearer": - return False - raise ValueError(f"Unsupported auth kind: {auth}") - - -def operation( - controller: str, - handler: str, - raw_path: str, - method: str, - metadata: dict[str, str], -) -> dict: - path, params = openapi_path(raw_path) - op = { - "tags": parse_tags(metadata["tags"]) or [controller_tag(controller)], - "operationId": f"{controller}.{handler}", - "summary": metadata["summary"], - "description": metadata["description"], - "parameters": params, - "responses": { - "200": { - "description": metadata["response"], - "content": {"application/json": {"schema": {"type": "object"}}}, - } - }, - } - body = request_body(metadata["body"], method) - if body: - op["requestBody"] = body - if not is_public(metadata): - 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 match in ROUTE_RE.finditer(text): - controller, handler, raw_path, drogon_method = match.groups() - metadata = parse_metadata(header, text, match.start()) - method = drogon_method.lower() - path, op = operation(controller, handler, raw_path, method, metadata) - if "/photos" in path: - raise ValueError(f"{header}: generated OpenAPI path still uses /photos") - 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() + package_root = Path(__file__).resolve().parent / "swagger-drogon" + sys.path.insert(0, str(package_root)) - 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") + 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() if __name__ == "__main__": diff --git a/scripts/swagger-drogon/README.md b/scripts/swagger-drogon/README.md new file mode 100644 index 0000000..10e65c7 --- /dev/null +++ b/scripts/swagger-drogon/README.md @@ -0,0 +1,26 @@ +# 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 +``` diff --git a/scripts/swagger-drogon/pyproject.toml b/scripts/swagger-drogon/pyproject.toml new file mode 100644 index 0000000..612adab --- /dev/null +++ b/scripts/swagger-drogon/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "swagger-drogon" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +swagger-drogon = "swagger_drogon.cli:main" diff --git a/scripts/swagger-drogon/swagger_drogon/__init__.py b/scripts/swagger-drogon/swagger_drogon/__init__.py new file mode 100644 index 0000000..4983302 --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/__init__.py @@ -0,0 +1,5 @@ +"""OpenAPI generator for Drogon HttpController route macros.""" + +from .generator import generate + +__all__ = ["generate"] diff --git a/scripts/swagger-drogon/swagger_drogon/__main__.py b/scripts/swagger-drogon/swagger_drogon/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/scripts/swagger-drogon/swagger_drogon/cli.py b/scripts/swagger-drogon/swagger_drogon/cli.py new file mode 100644 index 0000000..99528b2 --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/cli.py @@ -0,0 +1,41 @@ +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", + ) diff --git a/scripts/swagger-drogon/swagger_drogon/contract.py b/scripts/swagger-drogon/swagger_drogon/contract.py new file mode 100644 index 0000000..6905270 --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/contract.py @@ -0,0 +1,22 @@ +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 diff --git a/scripts/swagger-drogon/swagger_drogon/generator.py b/scripts/swagger-drogon/swagger_drogon/generator.py new file mode 100644 index 0000000..c0b5fe8 --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/generator.py @@ -0,0 +1,77 @@ +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 diff --git a/scripts/swagger-drogon/swagger_drogon/infer.py b/scripts/swagger-drogon/swagger_drogon/infer.py new file mode 100644 index 0000000..7a930dc --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/infer.py @@ -0,0 +1,163 @@ +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"} diff --git a/scripts/swagger-drogon/swagger_drogon/models.py b/scripts/swagger-drogon/swagger_drogon/models.py new file mode 100644 index 0000000..343c3df --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/models.py @@ -0,0 +1,27 @@ +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 diff --git a/scripts/swagger-drogon/swagger_drogon/openapi.py b/scripts/swagger-drogon/swagger_drogon/openapi.py new file mode 100644 index 0000000..3e3f9a9 --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/openapi.py @@ -0,0 +1,163 @@ +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") diff --git a/scripts/swagger-drogon/swagger_drogon/parser.py b/scripts/swagger-drogon/swagger_drogon/parser.py new file mode 100644 index 0000000..33e5aad --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/parser.py @@ -0,0 +1,75 @@ +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()] diff --git a/scripts/swagger-drogon/swagger_drogon/validate.py b/scripts/swagger-drogon/swagger_drogon/validate.py new file mode 100644 index 0000000..13681dd --- /dev/null +++ b/scripts/swagger-drogon/swagger_drogon/validate.py @@ -0,0 +1,99 @@ +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 diff --git a/scripts/swagger-drogon/tests/test_generator.py b/scripts/swagger-drogon/tests/test_generator.py new file mode 100644 index 0000000..37174ad --- /dev/null +++ b/scripts/swagger-drogon/tests/test_generator.py @@ -0,0 +1,170 @@ +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()