use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( info(title = "gitdata api", version = "1.0"), paths( crate::account_deletion::account_deletion_status, crate::account_deletion::request_account_deletion, crate::account_deletion::cancel_account_deletion, ), components(schemas(crate::account_deletion::AccountDeletionDto)) )] 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 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000), 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。 - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400 (`error_code: bad_request`),不会静默截断。 - 排序参数为 `?sort=&order=asc|desc`;`sort` 的取值按端点枚举校验, 未知值返回 400,未知参数同样返回 400。 - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。 - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。 ### 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 (primary) + redis live checks, rocketmq + s3 sampled every 10s, `503` when any is 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::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 }