use utoipa::OpenApi; #[derive(OpenApi)] #[openapi(info(title = "gitdata api", version = "1.0"))] pub struct WebApiDoc; const CONVENTIONS: &str = r#" ## Conventions ### Response envelope Every JSON response uses the same envelope: | field | meaning | |---|---| | `code` | always equal to the HTTP status code | | `message` | human-readable result / error summary | | `data` | payload, `null` for errors and empty results | | `error` | field-level / technical detail (e.g. validation errors), may be `null` | | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` | | `request_id` | correlation id, also returned as the `X-Request-Id` header | Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`. - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`. ### Authentication - `session_cookie`: opaque session carried in the `session` cookie and stored in Redis. `GET /auth/login` redirects to the same-origin login page; `POST /auth/logout` revokes the session and clears the cookie. ### URL & parameter style - Paths are `kebab-case`, plural nouns, nesting expresses ownership. - Query/path/JSON field names are `snake_case`; enum values are `snake_case`. - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded by the `x-gitdata-service-key` header and is NOT documented here. - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen); idempotent whole-resource state uses `PUT` (default branch, pin, read-state); partial updates use `PATCH`. ### Pagination - Offset style: `?limit=&offset=` (default 50, max 100 unless stated), response `data` is `{ items, total, limit, offset }`. - Cursor style (chat message/sequence data): `?before_seq=&after_seq=&limit=`, response contains `has_more`. ### PATCH semantics (three-state fields) Update commands use `Option>` for clearable fields: - field **absent** → unchanged - field `null` → cleared - field value → set Example: `{"milestone_id": null}` unassigns the milestone; omitting `milestone_id` leaves it untouched. ### 2FA TOTP enrollment, recovery codes and sensitive-operation verification are handled by identity; GitData only proxies the account-management endpoints. ### Operations - `GET /healthz` — liveness probe (no auth, outside `/api/v1`). - `GET /readyz` — readiness probe: database + redis check, `503` when down. - Runtime metrics are pushed to the configured OTLP collector (30s interval); there is no scrape endpoint. - Cross-origin browser access is disabled by default; the API expects a same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist other origins. ### Abuse controls - Creation endpoints are subject to per-user rolling 24h quotas (`[quota]` config; 0 disables). Exceeding a quota returns `429` with `error_code: "rate_limited"`. ### Idempotency - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room; retrying with the same nonce returns the original message instead of a duplicate. ### Resource references - User references in responses embed a `user` object (`UserDto`) wherever a single person is shown; raw uuid fields are named `*_id` consistently. - Viewer-specific state is embedded, not fetched separately: `RepoDto.me` carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist. ### Identifiers - Issues are addressable by UUID **or** board-scoped number (e.g. `/issues/{space}/boards/{board}/issues/123`). - Binary uploads use raw request bodies with a content-type header (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON). "#; pub fn api_doc() -> utoipa::openapi::OpenApi { let mut doc = WebApiDoc::openapi(); let dbg = std::env::var("OPENAPI_DEBUG").is_ok(); macro_rules! m { ($name:literal, $expr:expr) => {{ if dbg { eprintln!("merge: {}", $name); } doc.merge($expr); if dbg { eprintln!("done: {}", $name); } }}; } m!("auth", auth::openapi::ApiDoc::openapi()); m!("auth-account", auth::openapi_account::AuthApiDoc::openapi()); m!("chat", chat::openapi::ChatApiDoc::openapi()); m!("issues", issues::openapi::IssuesApiDoc::openapi()); m!("space", space::openapi::SpaceApiDoc::openapi()); m!("user", user::openapi::UserApiDoc::openapi()); m!("git", git::openapi::GitApiDoc::openapi()); m!("pulls", pulls::openapi::PullsApiDoc::openapi()); m!("cleanup", crate::cleanup::CleanupApiDoc::openapi()); let paths = std::mem::take(&mut doc.paths.paths); doc.paths.paths = paths .into_iter() .map(|(path, item)| { let normalized = path .strip_prefix("/api/v1") .filter(|rest| rest.starts_with('/')) .map(str::to_string) .unwrap_or(path); (normalized, item) }) .collect(); doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]); doc.info.description = Some(CONVENTIONS.to_string()); doc.info.license = None; { use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; let components = doc .components .get_or_insert_with(utoipa::openapi::Components::default); components.add_security_scheme( "session_cookie", SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session"))), ); } doc.security = Some(vec![utoipa::openapi::security::SecurityRequirement::new( "session_cookie", Vec::::new(), )]); doc }