Add swagger-drogon generator
This commit is contained in:
@@ -73,18 +73,32 @@ 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
|
||||||
|
--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
|
||||||
|
${SWAGGER_DROGON_FILES}
|
||||||
${CONTROLLER_HEADERS}
|
${CONTROLLER_HEADERS}
|
||||||
|
${OPENAPI_SOURCE_FILES}
|
||||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
VERBATIM
|
VERBATIM
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,12 +77,16 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon
|
|||||||
|
|
||||||
## OpenAPI
|
## OpenAPI
|
||||||
|
|
||||||
The OpenAPI spec is generated from Drogon controller route macros, not written
|
The OpenAPI spec is generated with `scripts/swagger-drogon/`. The generator
|
||||||
as a static Swagger file.
|
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
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+13
-9
@@ -6,7 +6,9 @@
|
|||||||
- `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/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.
|
- `scripts/lab/`: local Streamlit frontend for visual endpoint testing.
|
||||||
- `docs/`: product, API, schema and milestone documentation.
|
- `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.
|
Legacy `sha256$` password hashes are rejected instead of silently accepted.
|
||||||
|
|
||||||
## OpenAPI Generation
|
## OpenAPI Generation
|
||||||
`scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros and the
|
`scripts/swagger-drogon/` scans Drogon `ADD_METHOD_TO` route macros and the
|
||||||
adjacent `openapi:` metadata blocks in `controllers/*.h`, then generates
|
adjacent `openapi:` metadata blocks in `controllers/*.h`. It also inspects
|
||||||
`build/openapi/visione.openapi.json`.
|
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
|
This is a hybrid generator: C++ is the source of truth for routes and metadata,
|
||||||
route list, not a manually maintained static Swagger document. The current tool
|
while YAML provides schemas that cannot be safely inferred from dynamic
|
||||||
generates paths, methods, path parameters, summaries, descriptions, auth hints
|
`Json::Value` payloads and raw SQL result rows. Generation fails if a route lacks
|
||||||
and common JSON or multipart request bodies. Generation fails if a route lacks
|
metadata, has unresolved schema refs, emits `/photos`, or leaves operation
|
||||||
metadata.
|
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
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
- 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 from Drogon controller route macros and adjacent
|
- Added generated OpenAPI JSON through `scripts/swagger-drogon/`, combining
|
||||||
metadata blocks.
|
Drogon route macros, adjacent metadata blocks, C++ inference and the Visione
|
||||||
|
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
|
||||||
- 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
|
- Consider extracting `scripts/generate_openapi.py` into an open-source standalone
|
||||||
project once the conventions stabilize.
|
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.
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-189
@@ -1,201 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import sys
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
package_root = Path(__file__).resolve().parent / "swagger-drogon"
|
||||||
parser.add_argument("--controllers", type=Path, required=True)
|
sys.path.insert(0, str(package_root))
|
||||||
parser.add_argument("--out", type=Path, required=True)
|
|
||||||
parser.add_argument("--title", default="Visione API")
|
|
||||||
parser.add_argument("--version", default="0.1.0")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
spec = generate(args.controllers, args.title, args.version)
|
from swagger_drogon.cli import main as cli_main
|
||||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
args.out.write_text(json.dumps(spec, indent=2, sort_keys=True) + "\n",
|
if len(sys.argv) > 1 and sys.argv[1] != "generate":
|
||||||
encoding="utf-8")
|
sys.argv.insert(1, "generate")
|
||||||
|
cli_main()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""OpenAPI generator for Drogon HttpController route macros."""
|
||||||
|
|
||||||
|
from .generator import generate
|
||||||
|
|
||||||
|
__all__ = ["generate"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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()]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user