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 file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/controllers/*.h ${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) set(OPENAPI_OUTPUT ${CMAKE_BINARY_DIR}/openapi/visione.openapi.json)
add_custom_command( add_custom_command(
OUTPUT ${OPENAPI_OUTPUT} OUTPUT ${OPENAPI_OUTPUT}
COMMAND ${Python3_EXECUTABLE} COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
--controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers --controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers
--sources ${CMAKE_CURRENT_SOURCE_DIR}/controllers ${CMAKE_CURRENT_SOURCE_DIR}/services --manual ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.openapi.yaml
--contract ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.yaml
--out ${OPENAPI_OUTPUT} --out ${OPENAPI_OUTPUT}
--title "Visione API" --title "Visione API"
--version ${PROJECT_VERSION} --version ${PROJECT_VERSION}
DEPENDS DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.yaml ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.openapi.yaml
${SWAGGER_DROGON_FILES}
${CONTROLLER_HEADERS} ${CONTROLLER_HEADERS}
${OPENAPI_SOURCE_FILES}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
VERBATIM VERBATIM
) )
+7 -6
View File
@@ -77,16 +77,17 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon
## OpenAPI ## OpenAPI
The OpenAPI spec is generated with `scripts/swagger-drogon/`. The generator The OpenAPI spec is generated from Drogon controller route macros plus the
discovers Drogon controller route macros, reads adjacent `openapi:` metadata, manual standard OpenAPI document in `docs/openapi/visione.openapi.yaml`.
infers simple query/body/response shapes from C++ code, then merges the Visione The script extracts paths and methods from C++ and lets the manual file own
schema registry in `docs/openapi/visione.yaml`. Generation fails if an endpoint summaries, request bodies, responses, schemas and examples.
still has generic request or response schemas. The manual file uses JSON-compatible YAML so the CMake build does not require
extra Python packages.
```bash ```bash
cmake --build build --target openapi cmake --build build --target openapi
python3 -m json.tool build/openapi/visione.openapi.json >/dev/null 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 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: public:
METHOD_LIST_BEGIN 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, ADD_METHOD_TO(AdminController::listCategories,
"/api/v1/admin/categories", "/api/v1/admin/categories",
drogon::Get); 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, ADD_METHOD_TO(AdminController::createCategory,
"/api/v1/admin/categories", "/api/v1/admin/categories",
drogon::Post); 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); 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); 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); 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); 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, ADD_METHOD_TO(AdminController::overviewStats,
"/api/v1/admin/stats/overview", "/api/v1/admin/stats/overview",
drogon::Get); 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); 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, ADD_METHOD_TO(AdminController::updateUserRoles,
"/api/v1/admin/users/{1}/roles", "/api/v1/admin/users/{1}/roles",
drogon::Patch); 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, ADD_METHOD_TO(AdminController::updateUserBan,
"/api/v1/admin/users/{1}/ban", "/api/v1/admin/users/{1}/ban",
drogon::Patch); drogon::Patch);
-36
View File
@@ -6,45 +6,9 @@ class ApiController : public drogon::HttpController<ApiController>
{ {
public: public:
METHOD_LIST_BEGIN 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); 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); 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); 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); ADD_METHOD_TO(ApiController::docs, "/api/v1/docs", drogon::Get);
METHOD_LIST_END METHOD_LIST_END
-54
View File
@@ -6,75 +6,21 @@ class AuthController : public drogon::HttpController<AuthController>
{ {
public: public:
METHOD_LIST_BEGIN 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, ADD_METHOD_TO(AuthController::registerCitizen,
"/api/v1/auth/register", "/api/v1/auth/register",
drogon::Post); 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, ADD_METHOD_TO(AuthController::requestOtp,
"/api/v1/auth/otp/request", "/api/v1/auth/otp/request",
drogon::Post); 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, ADD_METHOD_TO(AuthController::verifyOtp,
"/api/v1/auth/otp/verify", "/api/v1/auth/otp/verify",
drogon::Post); 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, ADD_METHOD_TO(AuthController::loginWithPassword,
"/api/v1/auth/login/password", "/api/v1/auth/login/password",
drogon::Post); 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, ADD_METHOD_TO(AuthController::changePassword,
"/api/v1/auth/change-password", "/api/v1/auth/change-password",
drogon::Post); 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); ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
METHOD_LIST_END METHOD_LIST_END
-90
View File
@@ -6,117 +6,27 @@ class ReportController : public drogon::HttpController<ReportController>
{ {
public: public:
METHOD_LIST_BEGIN 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); 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); 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); 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, ADD_METHOD_TO(ReportController::updateStatus,
"/api/v1/reports/{1}/status", "/api/v1/reports/{1}/status",
drogon::Patch); 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, ADD_METHOD_TO(ReportController::takeReport,
"/api/v1/reports/{1}/take", "/api/v1/reports/{1}/take",
drogon::Post); 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, ADD_METHOD_TO(ReportController::addMessage,
"/api/v1/reports/{1}/messages", "/api/v1/reports/{1}/messages",
drogon::Post); 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, ADD_METHOD_TO(ReportController::listMessages,
"/api/v1/reports/{1}/messages", "/api/v1/reports/{1}/messages",
drogon::Get); 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, ADD_METHOD_TO(ReportController::assignTeam,
"/api/v1/reports/{1}/assignments", "/api/v1/reports/{1}/assignments",
drogon::Post); 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, ADD_METHOD_TO(ReportController::addFeedback,
"/api/v1/reports/{1}/feedback", "/api/v1/reports/{1}/feedback",
drogon::Post); 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, ADD_METHOD_TO(ReportController::uploadMedia,
"/api/v1/reports/{1}/media", "/api/v1/reports/{1}/media",
drogon::Post); drogon::Post);
+12 -13
View File
@@ -6,9 +6,8 @@
- `repositories/`: database helpers and migrations. - `repositories/`: database helpers and migrations.
- `common/`: reusable HTTP, auth, crypto, email and validation utilities. - `common/`: reusable HTTP, auth, crypto, email and validation utilities.
- `migrations/`: versioned SQL files. - `migrations/`: versioned SQL files.
- `scripts/swagger-drogon/`: OpenAPI generator package. - `scripts/generate_openapi.py`: minimal OpenAPI route extractor.
- `scripts/generate_openapi.py`: compatibility wrapper for the generator. - `docs/openapi/`: manual OpenAPI contract documents.
- `docs/openapi/`: app-specific OpenAPI schema registry.
- `scripts/lab/`: local Streamlit frontend for visual endpoint testing. - `scripts/lab/`: local Streamlit frontend for visual endpoint testing.
- `docs/`: product, API, schema and milestone documentation. - `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. Legacy `sha256$` password hashes are rejected instead of silently accepted.
## OpenAPI Generation ## OpenAPI Generation
`scripts/swagger-drogon/` scans Drogon `ADD_METHOD_TO` route macros and the `scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros in
adjacent `openapi:` metadata blocks in `controllers/*.h`. It also inspects `controllers/*.h` and extracts only paths, HTTP methods, operation ids, tags and
controller/service `.cc` files for simple query, JSON field and response field path parameters. It merges those route skeletons with
patterns, then merges the app-specific registry in `docs/openapi/visione.yaml`. `docs/openapi/visione.openapi.yaml`, which owns summaries, request bodies,
The CMake `openapi` target writes `build/openapi/visione.openapi.json`. 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, If the manual YAML contains an operation that no longer exists in the Drogon
while YAML provides schemas that cannot be safely inferred from dynamic route list, generation fails unless that operation is explicitly marked
`Json::Value` payloads and raw SQL result rows. Generation fails if a route lacks `x-manual-only: true`.
metadata, has unresolved schema refs, emits `/photos`, or leaves operation
request/response schemas as generic objects.
## Visione Lab ## Visione Lab
`scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is `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`. - Added report media uploads at `POST /api/v1/reports/{id}/media`.
- Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads. - Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads.
- Report attachments now use the `/media` upload path. - Report attachments now use the `/media` upload path.
- Added generated OpenAPI JSON through `scripts/swagger-drogon/`, combining - Added generated OpenAPI JSON from Drogon route macros plus the manual
Drogon route macros, adjacent metadata blocks, C++ inference and the Visione `docs/openapi/visione.openapi.yaml` contract.
schema registry.
- Exposed `/api/v1/openapi.json` and `/api/v1/docs`. - Exposed `/api/v1/openapi.json` and `/api/v1/docs`.
- Added a small Streamlit API lab for visual endpoint testing. - Added a small Streamlit API lab for visual endpoint testing.
## To do next ## To do next
- Expand OpenAPI schema coverage as endpoints evolve. - Expand the manual OpenAPI contract 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. - Add minimal media upload integration tests after endpoint contracts settle.
- Keep API and docs on `media` terminology only. - Keep API and docs on `media` terminology only.
- Keep the Streamlit lab small and replace it with the real frontend later. - 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 #!/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 from __future__ import annotations
import sys import argparse
import copy
import json
import re
from pathlib import Path 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: def main() -> None:
package_root = Path(__file__).resolve().parent / "swagger-drogon" parser = argparse.ArgumentParser()
sys.path.insert(0, str(package_root)) 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 spec = generate(
controllers_dir=args.controllers,
if len(sys.argv) > 1 and sys.argv[1] != "generate": manual_path=args.manual,
sys.argv.insert(1, "generate") title=args.title,
cli_main() 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__": 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()