diff --git a/CMakeLists.txt b/CMakeLists.txt index 8680224..5d66a24 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,32 +73,20 @@ target_link_libraries(${PROJECT_NAME} file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/controllers/*.h ) -file(GLOB OPENAPI_SOURCE_FILES CONFIGURE_DEPENDS - ${CMAKE_CURRENT_SOURCE_DIR}/controllers/*.cc - ${CMAKE_CURRENT_SOURCE_DIR}/services/*.cc -) -file(GLOB_RECURSE SWAGGER_DROGON_FILES CONFIGURE_DEPENDS - ${CMAKE_CURRENT_SOURCE_DIR}/scripts/swagger-drogon/*.py - ${CMAKE_CURRENT_SOURCE_DIR}/scripts/swagger-drogon/*.toml - ${CMAKE_CURRENT_SOURCE_DIR}/scripts/swagger-drogon/*.md -) set(OPENAPI_OUTPUT ${CMAKE_BINARY_DIR}/openapi/visione.openapi.json) add_custom_command( OUTPUT ${OPENAPI_OUTPUT} COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py --controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers - --sources ${CMAKE_CURRENT_SOURCE_DIR}/controllers ${CMAKE_CURRENT_SOURCE_DIR}/services - --contract ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.yaml + --manual ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.openapi.yaml --out ${OPENAPI_OUTPUT} --title "Visione API" --version ${PROJECT_VERSION} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py - ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.yaml - ${SWAGGER_DROGON_FILES} + ${CMAKE_CURRENT_SOURCE_DIR}/docs/openapi/visione.openapi.yaml ${CONTROLLER_HEADERS} - ${OPENAPI_SOURCE_FILES} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} VERBATIM ) diff --git a/README.md b/README.md index 202c516..517c685 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,17 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon ## OpenAPI -The OpenAPI spec is generated with `scripts/swagger-drogon/`. The generator -discovers Drogon controller route macros, reads adjacent `openapi:` metadata, -infers simple query/body/response shapes from C++ code, then merges the Visione -schema registry in `docs/openapi/visione.yaml`. Generation fails if an endpoint -still has generic request or response schemas. +The OpenAPI spec is generated from Drogon controller route macros plus the +manual standard OpenAPI document in `docs/openapi/visione.openapi.yaml`. +The script extracts paths and methods from C++ and lets the manual file own +summaries, request bodies, responses, schemas and examples. +The manual file uses JSON-compatible YAML so the CMake build does not require +extra Python packages. ```bash cmake --build build --target openapi python3 -m json.tool build/openapi/visione.openapi.json >/dev/null -python3 -m unittest discover scripts/swagger-drogon/tests +python3 -m unittest scripts/test_generate_openapi.py ``` The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at diff --git a/controllers/AdminController.h b/controllers/AdminController.h index 92d37df..28ba1ba 100644 --- a/controllers/AdminController.h +++ b/controllers/AdminController.h @@ -6,113 +6,23 @@ class AdminController : public drogon::HttpController { public: METHOD_LIST_BEGIN - /* - * openapi: - * tags: Admin - * summary: List categories - * description: Lists active report categories. - * auth: bearer - * body: none - * response: Report category list. - */ ADD_METHOD_TO(AdminController::listCategories, "/api/v1/admin/categories", drogon::Get); - /* - * openapi: - * tags: Admin - * summary: Create category - * description: Creates a report category. - * auth: bearer - * body: json - * response: Created category result. - */ ADD_METHOD_TO(AdminController::createCategory, "/api/v1/admin/categories", drogon::Post); - /* - * openapi: - * tags: Admin - * summary: List areas - * description: Lists configured municipal areas. - * auth: bearer - * body: none - * response: Area list. - */ ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get); - /* - * openapi: - * tags: Admin - * summary: Create area - * description: Creates a municipal area with GeoJSON boundary. - * auth: bearer - * body: json - * response: Created area result. - */ ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post); - /* - * openapi: - * tags: Admin - * summary: List teams - * description: Lists intervention teams. - * auth: bearer - * body: none - * response: Team list. - */ ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get); - /* - * openapi: - * tags: Admin - * summary: Create team - * description: Creates an intervention team. - * auth: bearer - * body: json - * response: Created team result. - */ ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post); - /* - * openapi: - * tags: Admin - * summary: Overview stats - * description: Returns report statistics for administration dashboards. - * auth: bearer - * body: none - * response: Aggregated report statistics. - */ ADD_METHOD_TO(AdminController::overviewStats, "/api/v1/admin/stats/overview", drogon::Get); - /* - * openapi: - * tags: Admin - * summary: List users - * description: Lists platform users with role information. - * auth: bearer - * body: none - * response: User list. - */ ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get); - /* - * openapi: - * tags: Admin - * summary: Update user roles - * description: Replaces the roles assigned to a user. - * auth: bearer - * body: json - * response: Role update result. - */ ADD_METHOD_TO(AdminController::updateUserRoles, "/api/v1/admin/users/{1}/roles", drogon::Patch); - /* - * openapi: - * tags: Admin - * summary: Ban or unban user - * description: Updates a user's banned flag. - * auth: bearer - * body: json - * response: Ban update result. - */ ADD_METHOD_TO(AdminController::updateUserBan, "/api/v1/admin/users/{1}/ban", drogon::Patch); diff --git a/controllers/ApiController.h b/controllers/ApiController.h index 83bb2c7..ed27b0e 100644 --- a/controllers/ApiController.h +++ b/controllers/ApiController.h @@ -6,45 +6,9 @@ class ApiController : public drogon::HttpController { public: METHOD_LIST_BEGIN - /* - * openapi: - * tags: Technical - * summary: Health check - * description: Returns process health without checking dependencies. - * auth: public - * body: none - * response: Service health status. - */ ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get); - /* - * openapi: - * tags: Technical - * summary: Readiness check - * description: Checks whether MySQL is reachable. - * auth: public - * body: none - * response: Dependency readiness status. - */ ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", drogon::Get); - /* - * openapi: - * tags: Technical - * summary: OpenAPI document - * description: Returns the generated OpenAPI JSON document. - * auth: public - * body: none - * response: OpenAPI 3 JSON document. - */ ADD_METHOD_TO(ApiController::openApi, "/api/v1/openapi.json", drogon::Get); - /* - * openapi: - * tags: Technical - * summary: Swagger UI - * description: Serves a local Swagger UI page backed by openapi.json. - * auth: public - * body: none - * response: HTML documentation page. - */ ADD_METHOD_TO(ApiController::docs, "/api/v1/docs", drogon::Get); METHOD_LIST_END diff --git a/controllers/AuthController.h b/controllers/AuthController.h index 192c6f1..cfbbd10 100644 --- a/controllers/AuthController.h +++ b/controllers/AuthController.h @@ -6,75 +6,21 @@ class AuthController : public drogon::HttpController { public: METHOD_LIST_BEGIN - /* - * openapi: - * tags: Auth - * summary: Register citizen - * description: Creates a citizen account with personal data and password. - * auth: public - * body: json - * response: Registration result. - */ ADD_METHOD_TO(AuthController::registerCitizen, "/api/v1/auth/register", drogon::Post); - /* - * openapi: - * tags: Auth - * summary: Request OTP - * description: Sends a one-time code by email through MailHog locally. - * auth: public - * body: json - * response: OTP challenge result. - */ ADD_METHOD_TO(AuthController::requestOtp, "/api/v1/auth/otp/request", drogon::Post); - /* - * openapi: - * tags: Auth - * summary: Verify OTP - * description: Verifies an email OTP and creates an API token. - * auth: public - * body: json - * response: Bearer token when verification succeeds. - */ ADD_METHOD_TO(AuthController::verifyOtp, "/api/v1/auth/otp/verify", drogon::Post); - /* - * openapi: - * tags: Auth - * summary: Password login - * description: Password-based login fallback for local development. - * auth: public - * body: json - * response: Bearer token when credentials are valid. - */ ADD_METHOD_TO(AuthController::loginWithPassword, "/api/v1/auth/login/password", drogon::Post); - /* - * openapi: - * tags: Auth - * summary: Change password - * description: Changes the authenticated user's password. - * auth: bearer - * body: json - * response: Password change result. - */ ADD_METHOD_TO(AuthController::changePassword, "/api/v1/auth/change-password", drogon::Post); - /* - * openapi: - * tags: Auth - * summary: Current user - * description: Returns the authenticated user's profile and permissions. - * auth: bearer - * body: none - * response: Authenticated user context. - */ ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get); METHOD_LIST_END diff --git a/controllers/ReportController.h b/controllers/ReportController.h index c87bcf0..d7f90bc 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -6,117 +6,27 @@ class ReportController : public drogon::HttpController { public: METHOD_LIST_BEGIN - /* - * openapi: - * tags: Reports - * summary: Create report - * description: Creates a citizen urban issue report. - * auth: bearer - * body: json - * response: Created report id and inferred area. - */ ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post); - /* - * openapi: - * tags: Reports - * summary: List reports - * description: Lists reports visible to the authenticated user. - * auth: bearer - * body: none - * response: Report list. - */ ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get); - /* - * openapi: - * tags: Reports - * summary: Report detail - * description: Returns report detail, status history and media. - * auth: bearer - * body: none - * response: Report detail. - */ ADD_METHOD_TO(ReportController::detail, "/api/v1/reports/{1}", drogon::Get); - /* - * openapi: - * tags: Reports - * summary: Update report status - * description: Updates a report status following allowed transitions. - * auth: bearer - * body: json - * response: Status update result. - */ ADD_METHOD_TO(ReportController::updateStatus, "/api/v1/reports/{1}/status", drogon::Patch); - /* - * openapi: - * tags: Reports - * summary: Take report - * description: Assigns an open report to the current operator. - * auth: bearer - * body: none - * response: Take report result. - */ ADD_METHOD_TO(ReportController::takeReport, "/api/v1/reports/{1}/take", drogon::Post); - /* - * openapi: - * tags: Reports - * summary: Add message - * description: Adds a citizen/operator message to a report. - * auth: bearer - * body: json - * response: Message creation result. - */ ADD_METHOD_TO(ReportController::addMessage, "/api/v1/reports/{1}/messages", drogon::Post); - /* - * openapi: - * tags: Reports - * summary: List messages - * description: Lists messages for a report conversation. - * auth: bearer - * body: none - * response: Message list. - */ ADD_METHOD_TO(ReportController::listMessages, "/api/v1/reports/{1}/messages", drogon::Get); - /* - * openapi: - * tags: Reports - * summary: Assign team - * description: Assigns an intervention team to a report. - * auth: bearer - * body: json - * response: Assignment result. - */ ADD_METHOD_TO(ReportController::assignTeam, "/api/v1/reports/{1}/assignments", drogon::Post); - /* - * openapi: - * tags: Reports - * summary: Add feedback - * description: Adds citizen feedback after report resolution. - * auth: bearer - * body: json - * response: Feedback result. - */ ADD_METHOD_TO(ReportController::addFeedback, "/api/v1/reports/{1}/feedback", drogon::Post); - /* - * openapi: - * tags: Reports - * summary: Upload media - * description: Uploads report images or videos as multipart files. - * auth: bearer - * body: multipart - * response: Uploaded media list. - */ ADD_METHOD_TO(ReportController::uploadMedia, "/api/v1/reports/{1}/media", drogon::Post); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b0ee0c6..a3b447e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,9 +6,8 @@ - `repositories/`: database helpers and migrations. - `common/`: reusable HTTP, auth, crypto, email and validation utilities. - `migrations/`: versioned SQL files. -- `scripts/swagger-drogon/`: OpenAPI generator package. -- `scripts/generate_openapi.py`: compatibility wrapper for the generator. -- `docs/openapi/`: app-specific OpenAPI schema registry. +- `scripts/generate_openapi.py`: minimal OpenAPI route extractor. +- `docs/openapi/`: manual OpenAPI contract documents. - `scripts/lab/`: local Streamlit frontend for visual endpoint testing. - `docs/`: product, API, schema and milestone documentation. @@ -31,17 +30,17 @@ available through OpenSSL, which is already a Drogon dependency in this project. Legacy `sha256$` password hashes are rejected instead of silently accepted. ## OpenAPI Generation -`scripts/swagger-drogon/` scans Drogon `ADD_METHOD_TO` route macros and the -adjacent `openapi:` metadata blocks in `controllers/*.h`. It also inspects -controller/service `.cc` files for simple query, JSON field and response field -patterns, then merges the app-specific registry in `docs/openapi/visione.yaml`. -The CMake `openapi` target writes `build/openapi/visione.openapi.json`. +`scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros in +`controllers/*.h` and extracts only paths, HTTP methods, operation ids, tags and +path parameters. It merges those route skeletons with +`docs/openapi/visione.openapi.yaml`, which owns summaries, request bodies, +responses, schemas and examples. The manual file uses JSON-compatible YAML so +the CMake `openapi` target can run without extra Python packages and writes +`build/openapi/visione.openapi.json`. -This is a hybrid generator: C++ is the source of truth for routes and metadata, -while YAML provides schemas that cannot be safely inferred from dynamic -`Json::Value` payloads and raw SQL result rows. Generation fails if a route lacks -metadata, has unresolved schema refs, emits `/photos`, or leaves operation -request/response schemas as generic objects. +If the manual YAML contains an operation that no longer exists in the Drogon +route list, generation fails unless that operation is explicitly marked +`x-manual-only: true`. ## Visione Lab `scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is diff --git a/docs/MILESTONE_03.md b/docs/MILESTONE_03.md index 114b8af..7cbc5b6 100644 --- a/docs/MILESTONE_03.md +++ b/docs/MILESTONE_03.md @@ -8,16 +8,13 @@ - Added report media uploads at `POST /api/v1/reports/{id}/media`. - Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads. - Report attachments now use the `/media` upload path. -- Added generated OpenAPI JSON through `scripts/swagger-drogon/`, combining - Drogon route macros, adjacent metadata blocks, C++ inference and the Visione - schema registry. +- Added generated OpenAPI JSON from Drogon route macros plus the manual + `docs/openapi/visione.openapi.yaml` contract. - Exposed `/api/v1/openapi.json` and `/api/v1/docs`. - Added a small Streamlit API lab for visual endpoint testing. ## To do next -- Expand OpenAPI schema coverage as endpoints evolve. -- Consider extracting `scripts/generate_openapi.py` into an open-source standalone - project once the conventions stabilize. +- Expand the manual OpenAPI contract as endpoints evolve. - Add minimal media upload integration tests after endpoint contracts settle. - Keep API and docs on `media` terminology only. - Keep the Streamlit lab small and replace it with the real frontend later. diff --git a/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml new file mode 100644 index 0000000..c8938bf --- /dev/null +++ b/docs/openapi/visione.openapi.yaml @@ -0,0 +1,2394 @@ +{ + "openapi": "3.0.3", + "info": { + "description": "Visione OpenAPI contract maintained manually and merged with Drogon route extraction.", + "title": "Visione API", + "version": "0.1.0" + }, + "servers": [ + { + "url": "http://localhost:5555" + } + ], + "paths": { + "/api/v1/admin/areas": { + "get": { + "description": "Lists configured municipal areas.", + "operationId": "AdminController.listAreas", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AreaListResponse" + } + } + }, + "description": "Area list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List areas", + "tags": [ + "Admin" + ] + }, + "post": { + "description": "Creates a municipal area with GeoJSON boundary.", + "operationId": "AdminController.createArea", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAreaRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Created area result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create area", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/categories": { + "get": { + "description": "Lists active report categories.", + "operationId": "AdminController.listCategories", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryListResponse" + } + } + }, + "description": "Report category list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List categories", + "tags": [ + "Admin" + ] + }, + "post": { + "description": "Creates a report category.", + "operationId": "AdminController.createCategory", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Created category result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create category", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/stats/overview": { + "get": { + "description": "Returns report statistics for administration dashboards.", + "operationId": "AdminController.overviewStats", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OverviewStatsResponse" + } + } + }, + "description": "Aggregated report statistics." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Overview stats", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/teams": { + "get": { + "description": "Lists intervention teams.", + "operationId": "AdminController.listTeams", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamListResponse" + } + } + }, + "description": "Team list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List teams", + "tags": [ + "Admin" + ] + }, + "post": { + "description": "Creates an intervention team.", + "operationId": "AdminController.createTeam", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Created team result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create team", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/users": { + "get": { + "description": "Lists platform users with role information.", + "operationId": "AdminController.listUsers", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserListResponse" + } + } + }, + "description": "User list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List users", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/users/{id}/ban": { + "patch": { + "description": "Updates a user's banned flag.", + "operationId": "AdminController.updateUserBan", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserBanRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Ban update result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Ban or unban user", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/users/{id}/roles": { + "patch": { + "description": "Replaces the roles assigned to a user.", + "operationId": "AdminController.updateUserRoles", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRolesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Role update result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update user roles", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/auth/change-password": { + "post": { + "description": "Changes the authenticated user's password.", + "operationId": "AuthController.changePassword", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Password change result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Change password", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/auth/login/password": { + "post": { + "description": "Password-based login fallback for local development.", + "operationId": "AuthController.loginWithPassword", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "description": "Bearer token when credentials are valid." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "summary": "Password login", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/auth/otp/request": { + "post": { + "description": "Sends a one-time code by email through MailHog locally.", + "operationId": "AuthController.requestOtp", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OtpRequestResponse" + } + } + }, + "description": "OTP challenge result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "summary": "Request OTP", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/auth/otp/verify": { + "post": { + "description": "Verifies an email OTP and creates an API token.", + "operationId": "AuthController.verifyOtp", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyOtpRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "description": "Bearer token when verification succeeds." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "summary": "Verify OTP", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Creates a citizen account with personal data and password.", + "operationId": "AuthController.registerCitizen", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterCitizenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterCitizenResponse" + } + } + }, + "description": "Registration result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "summary": "Register citizen", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/docs": { + "get": { + "description": "Serves a local Swagger UI page backed by openapi.json.", + "operationId": "ApiController.docs", + "parameters": [], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "HTML documentation page." + } + }, + "summary": "Swagger UI", + "tags": [ + "Technical" + ] + } + }, + "/api/v1/health": { + "get": { + "description": "Returns process health without checking dependencies.", + "operationId": "ApiController.health", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Service health status." + } + }, + "summary": "Health check", + "tags": [ + "Technical" + ] + } + }, + "/api/v1/me": { + "get": { + "description": "Returns the authenticated user's profile and permissions.", + "operationId": "AuthController.me", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserResponse" + } + } + }, + "description": "Authenticated user context." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Current user", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/openapi.json": { + "get": { + "description": "Returns the generated OpenAPI JSON document.", + "operationId": "ApiController.openApi", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenApiDocument" + } + } + }, + "description": "OpenAPI 3 JSON document." + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "summary": "OpenAPI document", + "tags": [ + "Technical" + ] + } + }, + "/api/v1/ready": { + "get": { + "description": "Checks whether MySQL is reachable.", + "operationId": "ApiController.ready", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadyResponse" + } + } + }, + "description": "Dependency readiness status." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadyResponse" + } + } + }, + "description": "Dependency readiness failure." + } + }, + "summary": "Readiness check", + "tags": [ + "Technical" + ] + } + }, + "/api/v1/reports": { + "get": { + "description": "Lists reports visible to the authenticated user.", + "operationId": "ReportController.list", + "parameters": [ + { + "in": "query", + "name": "area_id", + "required": false, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "in": "query", + "name": "category_id", + "required": false, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportListResponse" + } + } + }, + "description": "Report list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List reports", + "tags": [ + "Reports" + ] + }, + "post": { + "description": "Creates a citizen urban issue report.", + "operationId": "ReportController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportResponse" + } + } + }, + "description": "Created report id and inferred area." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create report", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}": { + "get": { + "description": "Returns report detail, status history and media.", + "operationId": "ReportController.detail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportDetailResponse" + } + } + }, + "description": "Report detail." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Report detail", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}/assignments": { + "post": { + "description": "Assigns an intervention team to a report.", + "operationId": "ReportController.assignTeam", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignTeamRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Assignment result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Assign team", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}/feedback": { + "post": { + "description": "Adds citizen feedback after report resolution.", + "operationId": "ReportController.addFeedback", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Feedback result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Add feedback", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}/media": { + "post": { + "description": "Uploads report images or videos as multipart files.", + "operationId": "ReportController.uploadMedia", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MediaUploadRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaUploadResponse" + } + } + }, + "description": "Uploaded media list." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Upload media", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}/messages": { + "get": { + "description": "Lists messages for a report conversation.", + "operationId": "ReportController.listMessages", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageListResponse" + } + } + }, + "description": "Message list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List messages", + "tags": [ + "Reports" + ] + }, + "post": { + "description": "Adds a citizen/operator message to a report.", + "operationId": "ReportController.addMessage", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Message creation result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Add message", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}/status": { + "patch": { + "description": "Updates a report status following allowed transitions.", + "operationId": "ReportController.updateStatus", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Status update result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update report status", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/reports/{id}/take": { + "post": { + "description": "Assigns an open report to the current operator.", + "operationId": "ReportController.takeReport", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Take report result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Take report", + "tags": [ + "Reports" + ] + } + } + }, + "components": { + "responses": { + "BadRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request or business validation failure." + }, + "Forbidden": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authenticated user cannot perform this action." + }, + "NotFound": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Requested resource was not found." + }, + "Unauthorized": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Missing or invalid bearer token." + } + }, + "schemas": { + "AdminUser": { + "additionalProperties": true, + "properties": { + "banned": { + "type": "boolean" + }, + "email": { + "format": "email", + "type": "string" + }, + "first_name": { + "type": "string" + }, + "fiscal_code": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "must_change_password": { + "type": "boolean" + }, + "phone": { + "type": "string" + }, + "points": { + "type": "integer" + }, + "roles": { + "description": "Comma-separated role names from SQL GROUP_CONCAT.", + "type": "string" + } + }, + "type": "object" + }, + "Area": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "boundary_geojson": { + "$ref": "#/components/schemas/GeoJsonObject" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "AreaListResponse": { + "additionalProperties": false, + "properties": { + "areas": { + "items": { + "$ref": "#/components/schemas/Area" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "areas" + ], + "type": "object" + }, + "AssignTeamRequest": { + "additionalProperties": false, + "properties": { + "team_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "team_id" + ], + "type": "object" + }, + "Category": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "CategoryListResponse": { + "additionalProperties": false, + "properties": { + "categories": { + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "categories" + ], + "type": "object" + }, + "ChangePasswordRequest": { + "additionalProperties": false, + "properties": { + "current_password": { + "format": "password", + "type": "string" + }, + "new_password": { + "format": "password", + "minLength": 10, + "type": "string" + } + }, + "required": [ + "current_password", + "new_password" + ], + "type": "object" + }, + "CreateAreaRequest": { + "additionalProperties": false, + "properties": { + "boundary_geojson": { + "$ref": "#/components/schemas/GeoJsonObject" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "boundary_geojson" + ], + "type": "object" + }, + "CreateCategoryRequest": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CreateReportRequest": { + "additionalProperties": false, + "properties": { + "address": { + "type": "string" + }, + "category_id": { + "format": "int64", + "type": "integer" + }, + "description": { + "type": "string" + }, + "latitude": { + "format": "double", + "type": "number" + }, + "longitude": { + "format": "double", + "type": "number" + }, + "media": { + "items": { + "format": "uri-reference", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "category_id", + "latitude", + "longitude", + "address", + "description" + ], + "type": "object" + }, + "CreateReportResponse": { + "additionalProperties": false, + "properties": { + "area_id": { + "format": "int64", + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "report_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "ok", + "report_id" + ], + "type": "object" + }, + "CreateTeamRequest": { + "additionalProperties": false, + "properties": { + "area_id": { + "format": "int64", + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "team_category_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "team_category_id", + "name" + ], + "type": "object" + }, + "CurrentUser": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "first_name": { + "type": "string" + }, + "fiscal_code": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "must_change_password": { + "type": "boolean" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "phone": { + "type": "string" + }, + "points": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "roles": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "first_name", + "last_name", + "email", + "fiscal_code", + "points", + "must_change_password", + "role", + "roles", + "permissions" + ], + "type": "object" + }, + "CurrentUserResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/CurrentUser" + } + }, + "required": [ + "ok", + "user" + ], + "type": "object" + }, + "EmailRequest": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "ErrorResponse": { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "FeedbackRequest": { + "additionalProperties": false, + "properties": { + "comment": { + "type": "string" + }, + "rating": { + "maximum": 5, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "rating" + ], + "type": "object" + }, + "GeoJsonObject": { + "additionalProperties": true, + "description": "GeoJSON polygon or multipolygon boundary.", + "type": "object" + }, + "HealthResponse": { + "additionalProperties": false, + "properties": { + "service": { + "example": "visione-backend", + "type": "string" + }, + "status": { + "example": "ok", + "type": "string" + } + }, + "required": [ + "status", + "service" + ], + "type": "object" + }, + "Media": { + "additionalProperties": true, + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "media_kind": { + "enum": [ + "IMAGE", + "VIDEO" + ], + "type": "string" + }, + "original_filename": { + "type": "string" + }, + "report_id": { + "format": "int64", + "type": "integer" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "MediaUploadRequest": { + "additionalProperties": false, + "properties": { + "files": { + "items": { + "format": "binary", + "type": "string" + }, + "maxItems": 5, + "type": "array" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "MediaUploadResponse": { + "additionalProperties": false, + "properties": { + "media": { + "items": { + "$ref": "#/components/schemas/Media" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "media" + ], + "type": "object" + }, + "Message": { + "additionalProperties": true, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "report_id": { + "format": "int64", + "type": "integer" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "MessageListResponse": { + "additionalProperties": false, + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/Message" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "messages" + ], + "type": "object" + }, + "MessageRequest": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "OkResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "OpenApiDocument": { + "additionalProperties": true, + "description": "Generated OpenAPI 3 document.", + "type": "object" + }, + "OtpRequestResponse": { + "additionalProperties": false, + "properties": { + "delivery": { + "example": "mailhog", + "type": "string" + }, + "expires_in_minutes": { + "type": "integer" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "delivery", + "expires_in_minutes" + ], + "type": "object" + }, + "OverviewStatsResponse": { + "additionalProperties": false, + "properties": { + "avg_resolution_hours": { + "items": { + "additionalProperties": true, + "properties": { + "value": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "reports_by_area": { + "items": { + "additionalProperties": true, + "properties": { + "area": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "reports_by_status": { + "items": { + "additionalProperties": true, + "properties": { + "status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "ok", + "reports_by_status", + "reports_by_area", + "avg_resolution_hours" + ], + "type": "object" + }, + "PasswordLoginRequest": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "password": { + "format": "password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + }, + "ReadyResponse": { + "additionalProperties": false, + "properties": { + "dependencies": { + "additionalProperties": false, + "properties": { + "mysql": { + "type": "boolean" + } + }, + "required": [ + "mysql" + ], + "type": "object" + }, + "errors": { + "additionalProperties": false, + "properties": { + "mysql": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "enum": [ + "ready", + "not_ready" + ], + "type": "string" + } + }, + "required": [ + "status", + "dependencies" + ], + "type": "object" + }, + "RegisterCitizenRequest": { + "additionalProperties": false, + "properties": { + "birth_date": { + "format": "date", + "type": "string" + }, + "birth_place": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "first_name": { + "type": "string" + }, + "fiscal_code": { + "maxLength": 16, + "minLength": 16, + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "format": "password", + "minLength": 10, + "type": "string" + }, + "phone": { + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password", + "fiscal_code" + ], + "type": "object" + }, + "RegisterCitizenResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "ok", + "user_id" + ], + "type": "object" + }, + "Report": { + "additionalProperties": true, + "properties": { + "address": { + "type": "string" + }, + "area_id": { + "format": "int64", + "type": "integer" + }, + "area_name": { + "type": "string" + }, + "assigned_operator_id": { + "format": "int64", + "type": "integer" + }, + "category_id": { + "format": "int64", + "type": "integer" + }, + "category_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "category_id", + "latitude", + "longitude", + "address", + "description", + "status" + ], + "type": "object" + }, + "ReportDetailResponse": { + "additionalProperties": false, + "properties": { + "history": { + "items": { + "$ref": "#/components/schemas/StatusHistoryItem" + }, + "type": "array" + }, + "media": { + "items": { + "$ref": "#/components/schemas/Media" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "report": { + "$ref": "#/components/schemas/Report" + } + }, + "required": [ + "ok", + "report", + "history", + "media" + ], + "type": "object" + }, + "ReportListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "reports": { + "items": { + "$ref": "#/components/schemas/Report" + }, + "type": "array" + } + }, + "required": [ + "ok", + "reports" + ], + "type": "object" + }, + "ReportStatus": { + "enum": [ + "OPEN", + "UNDER_REVIEW", + "ASSIGNED", + "IN_PROGRESS", + "RESOLVED", + "REJECTED", + "CLOSED" + ], + "type": "string" + }, + "StatusHistoryItem": { + "additionalProperties": true, + "properties": { + "changed_by_user_id": { + "format": "int64", + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "new_status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "note": { + "type": "string" + }, + "old_status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "report_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "Team": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "area_id": { + "format": "int64", + "type": "integer" + }, + "area_name": { + "type": "string" + }, + "category_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + }, + "team_category_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "TeamListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "teams": { + "items": { + "$ref": "#/components/schemas/Team" + }, + "type": "array" + } + }, + "required": [ + "ok", + "teams" + ], + "type": "object" + }, + "TokenResponse": { + "additionalProperties": false, + "properties": { + "expires_in_hours": { + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "token": { + "type": "string" + }, + "token_type": { + "example": "Bearer", + "type": "string" + } + }, + "required": [ + "ok", + "token", + "token_type", + "expires_in_hours" + ], + "type": "object" + }, + "UpdateStatusRequest": { + "additionalProperties": false, + "properties": { + "note": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ReportStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "UpdateUserBanRequest": { + "additionalProperties": false, + "properties": { + "banned": { + "type": "boolean" + } + }, + "required": [ + "banned" + ], + "type": "object" + }, + "UpdateUserRolesRequest": { + "additionalProperties": false, + "properties": { + "roles": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "roles" + ], + "type": "object" + }, + "UserListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "users": { + "items": { + "$ref": "#/components/schemas/AdminUser" + }, + "type": "array" + } + }, + "required": [ + "ok", + "users" + ], + "type": "object" + }, + "VerifyOtpRequest": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + } + }, + "required": [ + "email", + "code" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + } + } + } +} diff --git a/docs/openapi/visione.yaml b/docs/openapi/visione.yaml deleted file mode 100644 index dad1574..0000000 --- a/docs/openapi/visione.yaml +++ /dev/null @@ -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" } - } - } - } -} diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index 16ace68..d4063ae 100644 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -1,21 +1,205 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for the swagger-drogon package CLI.""" +"""Generate OpenAPI JSON from Drogon routes plus a manual OpenAPI YAML file.""" from __future__ import annotations -import sys +import argparse +import copy +import json +import re from pathlib import Path +from typing import Any + + +ROUTE_RE = re.compile( + r"ADD_METHOD_TO\(\s*([A-Za-z_]\w*)::([A-Za-z_]\w*)\s*,\s*" + r'"([^"]+)"\s*,\s*drogon::([A-Za-z]+)\s*\)', + re.MULTILINE | re.DOTALL, +) + +METHODS = {"get", "post", "put", "patch", "delete"} + + +def controller_tag(controller: str) -> str: + return controller.removesuffix("Controller") + + +def operation_id(controller: str, handler: str) -> str: + return f"{controller}.{handler}" + + +def openapi_path(path: str) -> tuple[str, list[dict[str, Any]]]: + parameters: list[dict[str, Any]] = [] + + def replace(match: re.Match[str]) -> str: + index = int(match.group(1)) + name = "id" if index == 1 else f"param{index}" + parameters.append( + { + "name": name, + "in": "path", + "required": True, + "schema": {"type": "integer", "format": "int64"}, + } + ) + return "{" + name + "}" + + return re.sub(r"\{(\d+)\}", replace, path), parameters + + +def generated_operation( + controller: str, + handler: str, + parameters: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "tags": [controller_tag(controller)], + "operationId": operation_id(controller, handler), + "summary": handler_to_summary(handler), + "parameters": parameters, + "responses": {"200": {"description": "Response"}}, + } + + +def handler_to_summary(handler: str) -> str: + words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", handler).replace("_", " ") + return words[:1].upper() + words[1:] + + +def parse_routes(controllers_dir: Path) -> dict[tuple[str, str], dict[str, Any]]: + operations: dict[tuple[str, str], dict[str, Any]] = {} + for header in sorted(controllers_dir.glob("*.h")): + text = header.read_text(encoding="utf-8") + for controller, handler, raw_path, drogon_method in ROUTE_RE.findall(text): + method = drogon_method.lower() + path, parameters = openapi_path(raw_path) + key = (path, method) + if key in operations: + raise ValueError(f"duplicate route for {method.upper()} {path}") + operations[key] = generated_operation(controller, handler, parameters) + return operations + + +def load_manual(path: Path | None) -> dict[str, Any]: + if path is None: + return {} + text = path.read_text(encoding="utf-8") + try: + import yaml # type: ignore[import-untyped] + except ImportError as error: + try: + data = json.loads(text) + except json.JSONDecodeError: + raise SystemExit( + "PyYAML is required unless the manual OpenAPI YAML uses " + "JSON-compatible syntax" + ) from error + else: + data = yaml.safe_load(text) + + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError(f"{path}: OpenAPI document must be an object") + return data + + +def merge_openapi( + generated: dict[tuple[str, str], dict[str, Any]], + manual: dict[str, Any], + title: str, + version: str, + server: str, +) -> dict[str, Any]: + spec: dict[str, Any] = { + "openapi": manual.get("openapi", "3.0.3"), + "info": copy.deepcopy( + manual.get("info", {"title": title, "version": version}) + ), + "servers": copy.deepcopy(manual.get("servers", [{"url": server}])), + "paths": {}, + } + + for key in ("components", "tags", "externalDocs"): + if key in manual: + spec[key] = copy.deepcopy(manual[key]) + + manual_paths = manual.get("paths", {}) + if not isinstance(manual_paths, dict): + raise ValueError("manual OpenAPI paths must be an object") + + seen_manual: set[tuple[str, str]] = set() + for path, path_item in manual_paths.items(): + if not isinstance(path_item, dict): + raise ValueError(f"{path}: path item must be an object") + for method, manual_operation in path_item.items(): + method_lower = method.lower() + if method_lower not in METHODS: + continue + key = (path, method_lower) + seen_manual.add(key) + if key not in generated and not manual_operation.get("x-manual-only", False): + raise ValueError( + f"{method_upper(method_lower)} {path}: manual operation has no matching Drogon route" + ) + + for key, operation in sorted(generated.items()): + path, method = key + manual_operation = manual_paths.get(path, {}).get(method, {}) + merged = copy.deepcopy(operation) + merged.update(copy.deepcopy(manual_operation)) + merged.pop("x-manual-only", None) + spec["paths"].setdefault(path, {})[method] = merged + + for key in sorted(seen_manual - generated.keys()): + path, method = key + manual_operation = copy.deepcopy(manual_paths[path][method]) + if not manual_operation.get("x-manual-only", False): + continue + manual_operation.pop("x-manual-only", None) + spec["paths"].setdefault(path, {})[method] = manual_operation + + return spec + + +def method_upper(method: str) -> str: + return method.upper() + + +def generate( + controllers_dir: Path, + manual_path: Path | None, + title: str, + version: str, + server: str, +) -> dict[str, Any]: + generated = parse_routes(controllers_dir) + manual = load_manual(manual_path) + return merge_openapi(generated, manual, title, version, server) def main() -> None: - package_root = Path(__file__).resolve().parent / "swagger-drogon" - sys.path.insert(0, str(package_root)) + parser = argparse.ArgumentParser() + parser.add_argument("--controllers", type=Path, required=True) + parser.add_argument("--manual", type=Path) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--title", default="Visione API") + parser.add_argument("--version", default="0.1.0") + parser.add_argument("--server", default="http://localhost:5555") + args = parser.parse_args() - from swagger_drogon.cli import main as cli_main - - if len(sys.argv) > 1 and sys.argv[1] != "generate": - sys.argv.insert(1, "generate") - cli_main() + spec = generate( + controllers_dir=args.controllers, + manual_path=args.manual, + title=args.title, + version=args.version, + server=args.server, + ) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(spec, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) if __name__ == "__main__": diff --git a/scripts/swagger-drogon/README.md b/scripts/swagger-drogon/README.md deleted file mode 100644 index 10e65c7..0000000 --- a/scripts/swagger-drogon/README.md +++ /dev/null @@ -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 -``` diff --git a/scripts/swagger-drogon/pyproject.toml b/scripts/swagger-drogon/pyproject.toml deleted file mode 100644 index 612adab..0000000 --- a/scripts/swagger-drogon/pyproject.toml +++ /dev/null @@ -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" diff --git a/scripts/swagger-drogon/swagger_drogon/__init__.py b/scripts/swagger-drogon/swagger_drogon/__init__.py deleted file mode 100644 index 4983302..0000000 --- a/scripts/swagger-drogon/swagger_drogon/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""OpenAPI generator for Drogon HttpController route macros.""" - -from .generator import generate - -__all__ = ["generate"] diff --git a/scripts/swagger-drogon/swagger_drogon/__main__.py b/scripts/swagger-drogon/swagger_drogon/__main__.py deleted file mode 100644 index 9ae637f..0000000 --- a/scripts/swagger-drogon/swagger_drogon/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .cli import main - -if __name__ == "__main__": - main() diff --git a/scripts/swagger-drogon/swagger_drogon/cli.py b/scripts/swagger-drogon/swagger_drogon/cli.py deleted file mode 100644 index 99528b2..0000000 --- a/scripts/swagger-drogon/swagger_drogon/cli.py +++ /dev/null @@ -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", - ) diff --git a/scripts/swagger-drogon/swagger_drogon/contract.py b/scripts/swagger-drogon/swagger_drogon/contract.py deleted file mode 100644 index 6905270..0000000 --- a/scripts/swagger-drogon/swagger_drogon/contract.py +++ /dev/null @@ -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 diff --git a/scripts/swagger-drogon/swagger_drogon/generator.py b/scripts/swagger-drogon/swagger_drogon/generator.py deleted file mode 100644 index c0b5fe8..0000000 --- a/scripts/swagger-drogon/swagger_drogon/generator.py +++ /dev/null @@ -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 diff --git a/scripts/swagger-drogon/swagger_drogon/infer.py b/scripts/swagger-drogon/swagger_drogon/infer.py deleted file mode 100644 index 7a930dc..0000000 --- a/scripts/swagger-drogon/swagger_drogon/infer.py +++ /dev/null @@ -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"} diff --git a/scripts/swagger-drogon/swagger_drogon/models.py b/scripts/swagger-drogon/swagger_drogon/models.py deleted file mode 100644 index 343c3df..0000000 --- a/scripts/swagger-drogon/swagger_drogon/models.py +++ /dev/null @@ -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 diff --git a/scripts/swagger-drogon/swagger_drogon/openapi.py b/scripts/swagger-drogon/swagger_drogon/openapi.py deleted file mode 100644 index 3e3f9a9..0000000 --- a/scripts/swagger-drogon/swagger_drogon/openapi.py +++ /dev/null @@ -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") diff --git a/scripts/swagger-drogon/swagger_drogon/parser.py b/scripts/swagger-drogon/swagger_drogon/parser.py deleted file mode 100644 index 33e5aad..0000000 --- a/scripts/swagger-drogon/swagger_drogon/parser.py +++ /dev/null @@ -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()] diff --git a/scripts/swagger-drogon/swagger_drogon/validate.py b/scripts/swagger-drogon/swagger_drogon/validate.py deleted file mode 100644 index 13681dd..0000000 --- a/scripts/swagger-drogon/swagger_drogon/validate.py +++ /dev/null @@ -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 diff --git a/scripts/swagger-drogon/tests/test_generator.py b/scripts/swagger-drogon/tests/test_generator.py deleted file mode 100644 index 37174ad..0000000 --- a/scripts/swagger-drogon/tests/test_generator.py +++ /dev/null @@ -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() diff --git a/scripts/test_generate_openapi.py b/scripts/test_generate_openapi.py new file mode 100644 index 0000000..74a0717 --- /dev/null +++ b/scripts/test_generate_openapi.py @@ -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()