Clean up OpenAPI scripts
This commit is contained in:
+2
-1
@@ -95,7 +95,8 @@ build/
|
|||||||
.cache/
|
.cache/
|
||||||
.env
|
.env
|
||||||
uploads/
|
uploads/
|
||||||
lab/.venv/
|
scripts/lab/.venv/
|
||||||
|
scripts/lab/__pycache__/
|
||||||
|
|
||||||
### CMake Patch ###
|
### CMake Patch ###
|
||||||
# External projects
|
# External projects
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
- Build: `cmake --build build`
|
- Build: `cmake --build build`
|
||||||
- Test: `ctest --test-dir build --output-on-failure`
|
- Test: `ctest --test-dir build --output-on-failure`
|
||||||
- OpenAPI: `cmake --build build --target openapi`
|
- OpenAPI: `cmake --build build --target openapi`
|
||||||
- Lab frontend: `cd lab && uv run streamlit run app.py`
|
- Lab frontend: `cd scripts/lab && uv run streamlit run app.py`
|
||||||
- Local services: `docker compose up -d`
|
- Local services: `docker compose up -d`
|
||||||
- App: `./build/visione-backend`
|
- App: `./build/visione-backend`
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -77,13 +77,13 @@ set(OPENAPI_OUTPUT ${CMAKE_BINARY_DIR}/openapi/visione.openapi.json)
|
|||||||
add_custom_command(
|
add_custom_command(
|
||||||
OUTPUT ${OPENAPI_OUTPUT}
|
OUTPUT ${OPENAPI_OUTPUT}
|
||||||
COMMAND ${Python3_EXECUTABLE}
|
COMMAND ${Python3_EXECUTABLE}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_openapi.py
|
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
|
||||||
--controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers
|
--controllers ${CMAKE_CURRENT_SOURCE_DIR}/controllers
|
||||||
--out ${OPENAPI_OUTPUT}
|
--out ${OPENAPI_OUTPUT}
|
||||||
--title "Visione API"
|
--title "Visione API"
|
||||||
--version ${PROJECT_VERSION}
|
--version ${PROJECT_VERSION}
|
||||||
DEPENDS
|
DEPENDS
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_openapi.py
|
${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_openapi.py
|
||||||
${CONTROLLER_HEADERS}
|
${CONTROLLER_HEADERS}
|
||||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
VERBATIM
|
VERBATIM
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at
|
|||||||
|
|
||||||
## Visione Lab
|
## Visione Lab
|
||||||
|
|
||||||
`lab/` is a small Streamlit frontend used only to test the backend visually
|
`scripts/lab/` is a small Streamlit frontend used only to test the backend
|
||||||
while the real frontend is not ready.
|
visually while the real frontend is not ready.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd lab
|
cd scripts/lab
|
||||||
uv sync
|
uv sync
|
||||||
uv run streamlit run app.py
|
uv run streamlit run app.py
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,23 +6,113 @@ class AdminController : public drogon::HttpController<AdminController>
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: List categories
|
||||||
|
* description: Lists active report categories.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Report category list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::listCategories,
|
ADD_METHOD_TO(AdminController::listCategories,
|
||||||
"/api/v1/admin/categories",
|
"/api/v1/admin/categories",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: Create category
|
||||||
|
* description: Creates a report category.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Created category result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::createCategory,
|
ADD_METHOD_TO(AdminController::createCategory,
|
||||||
"/api/v1/admin/categories",
|
"/api/v1/admin/categories",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: List areas
|
||||||
|
* description: Lists configured municipal areas.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Area list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get);
|
ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: Create area
|
||||||
|
* description: Creates a municipal area with GeoJSON boundary.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Created area result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post);
|
ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: List teams
|
||||||
|
* description: Lists intervention teams.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Team list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get);
|
ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: Create team
|
||||||
|
* description: Creates an intervention team.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Created team result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post);
|
ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: Overview stats
|
||||||
|
* description: Returns report statistics for administration dashboards.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Aggregated report statistics.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::overviewStats,
|
ADD_METHOD_TO(AdminController::overviewStats,
|
||||||
"/api/v1/admin/stats/overview",
|
"/api/v1/admin/stats/overview",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: List users
|
||||||
|
* description: Lists platform users with role information.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: User list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
|
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: Update user roles
|
||||||
|
* description: Replaces the roles assigned to a user.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Role update result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::updateUserRoles,
|
ADD_METHOD_TO(AdminController::updateUserRoles,
|
||||||
"/api/v1/admin/users/{1}/roles",
|
"/api/v1/admin/users/{1}/roles",
|
||||||
drogon::Patch);
|
drogon::Patch);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Admin
|
||||||
|
* summary: Ban or unban user
|
||||||
|
* description: Updates a user's banned flag.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Ban update result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AdminController::updateUserBan,
|
ADD_METHOD_TO(AdminController::updateUserBan,
|
||||||
"/api/v1/admin/users/{1}/ban",
|
"/api/v1/admin/users/{1}/ban",
|
||||||
drogon::Patch);
|
drogon::Patch);
|
||||||
@@ -61,4 +151,3 @@ class AdminController : public drogon::HttpController<AdminController>
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||||
long long userId);
|
long long userId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,45 @@ class ApiController : public drogon::HttpController<ApiController>
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Technical
|
||||||
|
* summary: Health check
|
||||||
|
* description: Returns process health without checking dependencies.
|
||||||
|
* auth: public
|
||||||
|
* body: none
|
||||||
|
* response: Service health status.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get);
|
ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Technical
|
||||||
|
* summary: Readiness check
|
||||||
|
* description: Checks whether MySQL is reachable.
|
||||||
|
* auth: public
|
||||||
|
* body: none
|
||||||
|
* response: Dependency readiness status.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", drogon::Get);
|
ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Technical
|
||||||
|
* summary: OpenAPI document
|
||||||
|
* description: Returns the generated OpenAPI JSON document.
|
||||||
|
* auth: public
|
||||||
|
* body: none
|
||||||
|
* response: OpenAPI 3 JSON document.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ApiController::openApi, "/api/v1/openapi.json", drogon::Get);
|
ADD_METHOD_TO(ApiController::openApi, "/api/v1/openapi.json", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Technical
|
||||||
|
* summary: Swagger UI
|
||||||
|
* description: Serves a local Swagger UI page backed by openapi.json.
|
||||||
|
* auth: public
|
||||||
|
* body: none
|
||||||
|
* response: HTML documentation page.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ApiController::docs, "/api/v1/docs", drogon::Get);
|
ADD_METHOD_TO(ApiController::docs, "/api/v1/docs", drogon::Get);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
|
|||||||
@@ -6,21 +6,75 @@ class AuthController : public drogon::HttpController<AuthController>
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Auth
|
||||||
|
* summary: Register citizen
|
||||||
|
* description: Creates a citizen account with personal data and password.
|
||||||
|
* auth: public
|
||||||
|
* body: json
|
||||||
|
* response: Registration result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AuthController::registerCitizen,
|
ADD_METHOD_TO(AuthController::registerCitizen,
|
||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Auth
|
||||||
|
* summary: Request OTP
|
||||||
|
* description: Sends a one-time code by email through MailHog locally.
|
||||||
|
* auth: public
|
||||||
|
* body: json
|
||||||
|
* response: OTP challenge result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AuthController::requestOtp,
|
ADD_METHOD_TO(AuthController::requestOtp,
|
||||||
"/api/v1/auth/otp/request",
|
"/api/v1/auth/otp/request",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Auth
|
||||||
|
* summary: Verify OTP
|
||||||
|
* description: Verifies an email OTP and creates an API token.
|
||||||
|
* auth: public
|
||||||
|
* body: json
|
||||||
|
* response: Bearer token when verification succeeds.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AuthController::verifyOtp,
|
ADD_METHOD_TO(AuthController::verifyOtp,
|
||||||
"/api/v1/auth/otp/verify",
|
"/api/v1/auth/otp/verify",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Auth
|
||||||
|
* summary: Password login
|
||||||
|
* description: Password-based login fallback for local development.
|
||||||
|
* auth: public
|
||||||
|
* body: json
|
||||||
|
* response: Bearer token when credentials are valid.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AuthController::loginWithPassword,
|
ADD_METHOD_TO(AuthController::loginWithPassword,
|
||||||
"/api/v1/auth/login/password",
|
"/api/v1/auth/login/password",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Auth
|
||||||
|
* summary: Change password
|
||||||
|
* description: Changes the authenticated user's password.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Password change result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AuthController::changePassword,
|
ADD_METHOD_TO(AuthController::changePassword,
|
||||||
"/api/v1/auth/change-password",
|
"/api/v1/auth/change-password",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Auth
|
||||||
|
* summary: Current user
|
||||||
|
* description: Returns the authenticated user's profile and permissions.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Authenticated user context.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
|
ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
|
|||||||
@@ -6,27 +6,117 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Create report
|
||||||
|
* description: Creates a citizen urban issue report.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Created report id and inferred area.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
|
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: List reports
|
||||||
|
* description: Lists reports visible to the authenticated user.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Report list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get);
|
ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Report detail
|
||||||
|
* description: Returns report detail, status history and media.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Report detail.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::detail, "/api/v1/reports/{1}", drogon::Get);
|
ADD_METHOD_TO(ReportController::detail, "/api/v1/reports/{1}", drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Update report status
|
||||||
|
* description: Updates a report status following allowed transitions.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Status update result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::updateStatus,
|
ADD_METHOD_TO(ReportController::updateStatus,
|
||||||
"/api/v1/reports/{1}/status",
|
"/api/v1/reports/{1}/status",
|
||||||
drogon::Patch);
|
drogon::Patch);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Take report
|
||||||
|
* description: Assigns an open report to the current operator.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Take report result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::takeReport,
|
ADD_METHOD_TO(ReportController::takeReport,
|
||||||
"/api/v1/reports/{1}/take",
|
"/api/v1/reports/{1}/take",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Add message
|
||||||
|
* description: Adds a citizen/operator message to a report.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Message creation result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::addMessage,
|
ADD_METHOD_TO(ReportController::addMessage,
|
||||||
"/api/v1/reports/{1}/messages",
|
"/api/v1/reports/{1}/messages",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: List messages
|
||||||
|
* description: Lists messages for a report conversation.
|
||||||
|
* auth: bearer
|
||||||
|
* body: none
|
||||||
|
* response: Message list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::listMessages,
|
ADD_METHOD_TO(ReportController::listMessages,
|
||||||
"/api/v1/reports/{1}/messages",
|
"/api/v1/reports/{1}/messages",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Assign team
|
||||||
|
* description: Assigns an intervention team to a report.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Assignment result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::assignTeam,
|
ADD_METHOD_TO(ReportController::assignTeam,
|
||||||
"/api/v1/reports/{1}/assignments",
|
"/api/v1/reports/{1}/assignments",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Add feedback
|
||||||
|
* description: Adds citizen feedback after report resolution.
|
||||||
|
* auth: bearer
|
||||||
|
* body: json
|
||||||
|
* response: Feedback result.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::addFeedback,
|
ADD_METHOD_TO(ReportController::addFeedback,
|
||||||
"/api/v1/reports/{1}/feedback",
|
"/api/v1/reports/{1}/feedback",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
/*
|
||||||
|
* openapi:
|
||||||
|
* tags: Reports
|
||||||
|
* summary: Upload media
|
||||||
|
* description: Uploads report images or videos as multipart files.
|
||||||
|
* auth: bearer
|
||||||
|
* body: multipart
|
||||||
|
* response: Uploaded media list.
|
||||||
|
*/
|
||||||
ADD_METHOD_TO(ReportController::uploadMedia,
|
ADD_METHOD_TO(ReportController::uploadMedia,
|
||||||
"/api/v1/reports/{1}/media",
|
"/api/v1/reports/{1}/media",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
|
|||||||
+14
-13
@@ -6,17 +6,17 @@
|
|||||||
- `repositories/`: database helpers and migrations.
|
- `repositories/`: database helpers and migrations.
|
||||||
- `common/`: reusable HTTP, auth, crypto, email and validation utilities.
|
- `common/`: reusable HTTP, auth, crypto, email and validation utilities.
|
||||||
- `migrations/`: versioned SQL files.
|
- `migrations/`: versioned SQL files.
|
||||||
- `lab/`: local Streamlit frontend for visual endpoint testing.
|
- `scripts/generate_openapi.py`: OpenAPI generator.
|
||||||
|
- `scripts/lab/`: local Streamlit frontend for visual endpoint testing.
|
||||||
- `docs/`: product, API, schema and milestone documentation.
|
- `docs/`: product, API, schema and milestone documentation.
|
||||||
|
|
||||||
## Drogon Usage
|
## Drogon Usage
|
||||||
Controllers use Drogon route macros and return JSON with
|
Controllers use Drogon route macros and return JSON with
|
||||||
`HttpResponse::newHttpJsonResponse`. Database access uses Drogon's MySQL client
|
`HttpResponse::newHttpJsonResponse`. Database access uses
|
||||||
with parameterized SQL placeholders.
|
`drogon::orm::DbClient` with raw parameterized SQL placeholders.
|
||||||
|
|
||||||
The codebase starts with raw SQL repositories because the schema is still
|
The codebase does not use Drogon generated models or `Mapper<T>`. Raw SQL stays
|
||||||
evolving. Drogon ORM/codegen can be introduced later after the database model
|
in repository/database helpers because the schema is still evolving.
|
||||||
stabilizes.
|
|
||||||
|
|
||||||
## Local Email
|
## Local Email
|
||||||
OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and
|
OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and
|
||||||
@@ -29,16 +29,17 @@ available through OpenSSL, which is already a Drogon dependency in this project.
|
|||||||
Legacy `sha256$` password hashes are rejected instead of silently accepted.
|
Legacy `sha256$` password hashes are rejected instead of silently accepted.
|
||||||
|
|
||||||
## OpenAPI Generation
|
## OpenAPI Generation
|
||||||
`generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros in
|
`scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros and the
|
||||||
`controllers/*.h` and generates `build/openapi/visione.openapi.json`.
|
adjacent `openapi:` metadata blocks in `controllers/*.h`, then generates
|
||||||
|
`build/openapi/visione.openapi.json`.
|
||||||
|
|
||||||
This is a contract-last generator: the source of truth is the C++ controller
|
This is a contract-last generator: the source of truth is the C++ controller
|
||||||
route list, not a manually maintained static Swagger document. The current tool
|
route list, not a manually maintained static Swagger document. The current tool
|
||||||
generates paths, methods, path parameters, bearer auth hints and common JSON or
|
generates paths, methods, path parameters, summaries, descriptions, auth hints
|
||||||
multipart request bodies. Deeper request/response schemas can be added later by
|
and common JSON or multipart request bodies. Generation fails if a route lacks
|
||||||
teaching the generator project-specific conventions or comments.
|
metadata.
|
||||||
|
|
||||||
## Visione Lab
|
## Visione Lab
|
||||||
`lab/` is a Streamlit dev frontend for visual endpoint testing. It is
|
`scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is
|
||||||
intentionally separate from the real product frontend and should stay small:
|
intentionally separate from the real product frontend and should stay small:
|
||||||
forms, uploads and response rendering only. Run it with uv from `lab/`.
|
forms, uploads and response rendering only. Run it with uv from `scripts/lab/`.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
The schema is stored in `migrations/001_initial_schema.sql`.
|
The schema is stored in `migrations/001_initial_schema.sql`.
|
||||||
|
|
||||||
|
Persistence currently uses `drogon::orm::DbClient` with raw parameterized SQL.
|
||||||
|
The project does not use Drogon generated models or `Mapper<T>` while the schema
|
||||||
|
is still evolving.
|
||||||
|
|
||||||
Important tables:
|
Important tables:
|
||||||
|
|
||||||
- `users`: identity, personal data, password hash, points and moderation flags.
|
- `users`: identity, personal data, password hash, points and moderation flags.
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
than plain SHA-256 and available through the existing OpenSSL dependency.
|
than plain SHA-256 and available through the existing OpenSSL dependency.
|
||||||
- Added report media uploads at `POST /api/v1/reports/{id}/media`.
|
- Added report media uploads at `POST /api/v1/reports/{id}/media`.
|
||||||
- Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads.
|
- Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads.
|
||||||
- Removed the old `/photos` upload path; report attachments now use `/media`.
|
- Report attachments now use the `/media` upload path.
|
||||||
- Added generated OpenAPI JSON from Drogon controller route macros.
|
- Added generated OpenAPI JSON from Drogon controller route macros and adjacent
|
||||||
|
metadata blocks.
|
||||||
- Exposed `/api/v1/openapi.json` and `/api/v1/docs`.
|
- Exposed `/api/v1/openapi.json` and `/api/v1/docs`.
|
||||||
- Added a small Streamlit API lab for visual endpoint testing.
|
- Added a small Streamlit API lab for visual endpoint testing.
|
||||||
|
|
||||||
## To do next
|
## To do next
|
||||||
- Add richer OpenAPI schema metadata for request and response bodies.
|
- Add richer OpenAPI schema metadata for request and response bodies.
|
||||||
- Consider extracting `generate_openapi.py` into an open-source standalone
|
- Consider extracting `scripts/generate_openapi.py` into an open-source standalone
|
||||||
project once the conventions stabilize.
|
project once the conventions stabilize.
|
||||||
- Add minimal media upload integration tests after endpoint contracts settle.
|
- Add minimal media upload integration tests after endpoint contracts settle.
|
||||||
- Keep API and docs on `media` terminology only.
|
- Keep API and docs on `media` terminology only.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate a compact OpenAPI spec from Drogon HttpController route macros."""
|
"""Generate a compact OpenAPI spec from Drogon route macros and metadata blocks."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,21 +12,12 @@ from pathlib import Path
|
|||||||
ROUTE_RE = re.compile(
|
ROUTE_RE = re.compile(
|
||||||
r"ADD_METHOD_TO\(\s*([A-Za-z_]\w*)::([A-Za-z_]\w*)\s*,\s*"
|
r"ADD_METHOD_TO\(\s*([A-Za-z_]\w*)::([A-Za-z_]\w*)\s*,\s*"
|
||||||
r'"([^"]+)"\s*,\s*drogon::([A-Za-z]+)\s*\)',
|
r'"([^"]+)"\s*,\s*drogon::([A-Za-z]+)\s*\)',
|
||||||
re.MULTILINE,
|
re.MULTILINE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
METHODS_WITH_JSON_BODY = {"post", "put", "patch"}
|
METHODS_WITH_JSON_BODY = {"post", "put", "patch"}
|
||||||
PUBLIC_OPERATIONS = {
|
REQUIRED_METADATA = {"tags", "summary", "description", "auth", "body", "response"}
|
||||||
"health",
|
|
||||||
"ready",
|
|
||||||
"openApi",
|
|
||||||
"docs",
|
|
||||||
"registerCitizen",
|
|
||||||
"requestOtp",
|
|
||||||
"verifyOtp",
|
|
||||||
"loginWithPassword",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def controller_tag(controller: str) -> str:
|
def controller_tag(controller: str) -> str:
|
||||||
@@ -51,10 +42,47 @@ def openapi_path(path: str) -> tuple[str, list[dict]]:
|
|||||||
return re.sub(r"\{(\d+)\}", replace, path), parameters
|
return re.sub(r"\{(\d+)\}", replace, path), parameters
|
||||||
|
|
||||||
|
|
||||||
def request_body(path: str, method: str) -> dict | None:
|
def parse_metadata(header: Path, text: str, route_start: int) -> dict[str, str]:
|
||||||
if method != "post" and method != "patch":
|
prefix = text[:route_start].rstrip()
|
||||||
|
if not prefix.endswith("*/"):
|
||||||
|
raise ValueError(f"{header}: route is missing an openapi metadata block")
|
||||||
|
|
||||||
|
comment_start = prefix.rfind("/*")
|
||||||
|
if comment_start == -1:
|
||||||
|
raise ValueError(f"{header}: route is missing an openapi metadata block")
|
||||||
|
|
||||||
|
block = prefix[comment_start + 2:-2]
|
||||||
|
metadata: dict[str, str] = {}
|
||||||
|
in_openapi_block = False
|
||||||
|
|
||||||
|
for raw_line in block.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line.startswith("*"):
|
||||||
|
line = line[1:].strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line == "openapi:":
|
||||||
|
in_openapi_block = True
|
||||||
|
continue
|
||||||
|
if not in_openapi_block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = re.match(r"([A-Za-z_]+):\s*(.+)", line)
|
||||||
|
if match:
|
||||||
|
metadata[match.group(1)] = match.group(2).strip()
|
||||||
|
|
||||||
|
missing = sorted(REQUIRED_METADATA - metadata.keys())
|
||||||
|
if missing:
|
||||||
|
names = ", ".join(missing)
|
||||||
|
raise ValueError(f"{header}: openapi metadata block is missing: {names}")
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def request_body(body_kind: str, method: str) -> dict | None:
|
||||||
|
if body_kind == "none":
|
||||||
return None
|
return None
|
||||||
if path.endswith("/media"):
|
if body_kind == "multipart":
|
||||||
return {
|
return {
|
||||||
"required": True,
|
"required": True,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -71,33 +99,56 @@ def request_body(path: str, method: str) -> dict | None:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if method in METHODS_WITH_JSON_BODY:
|
if body_kind == "json" and method in METHODS_WITH_JSON_BODY:
|
||||||
return {
|
return {
|
||||||
"required": True,
|
"required": True,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {"schema": {"type": "object"}}
|
"application/json": {"schema": {"type": "object"}}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return None
|
if body_kind == "json":
|
||||||
|
raise ValueError(f"JSON body is not supported for {method.upper()} routes")
|
||||||
|
raise ValueError(f"Unsupported request body kind: {body_kind}")
|
||||||
|
|
||||||
|
|
||||||
def operation(controller: str, handler: str, raw_path: str, method: str) -> dict:
|
def parse_tags(value: str) -> list[str]:
|
||||||
|
return [tag.strip() for tag in value.split(",") if tag.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_public(metadata: dict[str, str]) -> bool:
|
||||||
|
auth = metadata["auth"]
|
||||||
|
if auth == "public":
|
||||||
|
return True
|
||||||
|
if auth == "bearer":
|
||||||
|
return False
|
||||||
|
raise ValueError(f"Unsupported auth kind: {auth}")
|
||||||
|
|
||||||
|
|
||||||
|
def operation(
|
||||||
|
controller: str,
|
||||||
|
handler: str,
|
||||||
|
raw_path: str,
|
||||||
|
method: str,
|
||||||
|
metadata: dict[str, str],
|
||||||
|
) -> dict:
|
||||||
path, params = openapi_path(raw_path)
|
path, params = openapi_path(raw_path)
|
||||||
op = {
|
op = {
|
||||||
"tags": [controller_tag(controller)],
|
"tags": parse_tags(metadata["tags"]) or [controller_tag(controller)],
|
||||||
"operationId": f"{controller}.{handler}",
|
"operationId": f"{controller}.{handler}",
|
||||||
|
"summary": metadata["summary"],
|
||||||
|
"description": metadata["description"],
|
||||||
"parameters": params,
|
"parameters": params,
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "JSON response",
|
"description": metadata["response"],
|
||||||
"content": {"application/json": {"schema": {"type": "object"}}},
|
"content": {"application/json": {"schema": {"type": "object"}}},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
body = request_body(path, method)
|
body = request_body(metadata["body"], method)
|
||||||
if body:
|
if body:
|
||||||
op["requestBody"] = body
|
op["requestBody"] = body
|
||||||
if handler not in PUBLIC_OPERATIONS:
|
if not is_public(metadata):
|
||||||
op["security"] = [{"bearerAuth": []}]
|
op["security"] = [{"bearerAuth": []}]
|
||||||
if method == "post":
|
if method == "post":
|
||||||
op["responses"]["201"] = op["responses"]["200"]
|
op["responses"]["201"] = op["responses"]["200"]
|
||||||
@@ -122,9 +173,13 @@ def generate(controllers_dir: Path, title: str, version: str) -> dict:
|
|||||||
}
|
}
|
||||||
for header in sorted(controllers_dir.glob("*.h")):
|
for header in sorted(controllers_dir.glob("*.h")):
|
||||||
text = header.read_text(encoding="utf-8")
|
text = header.read_text(encoding="utf-8")
|
||||||
for controller, handler, raw_path, drogon_method in ROUTE_RE.findall(text):
|
for match in ROUTE_RE.finditer(text):
|
||||||
|
controller, handler, raw_path, drogon_method = match.groups()
|
||||||
|
metadata = parse_metadata(header, text, match.start())
|
||||||
method = drogon_method.lower()
|
method = drogon_method.lower()
|
||||||
path, op = operation(controller, handler, raw_path, method)
|
path, op = operation(controller, handler, raw_path, method, metadata)
|
||||||
|
if "/photos" in path:
|
||||||
|
raise ValueError(f"{header}: generated OpenAPI path still uses /photos")
|
||||||
spec["paths"].setdefault(path, {})[method] = op
|
spec["paths"].setdefault(path, {})[method] = op
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
Generated
Reference in New Issue
Block a user