From fe15abf5eb0681362ab7929458eaf30fae61fc00 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Sun, 17 May 2026 09:27:39 +0800 Subject: [PATCH 1/3] refactor(admin): merge resource JSON Schemas into served OpenAPI doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written OpenAPI 3.1 document in `crates/aisix-admin/src/openapi.rs` previously inlined its own copy of every resource schema (`Model`, `ApiKey`, `ProviderKey`, `Guardrail`, `CachePolicy`, `ObservabilityExporter`, `RateLimit`, `Routing`, plus the nested `ModelCost` / `BackgroundModelCheck`). That left three places to keep in sync whenever a resource field changed: the Rust struct, the inline OpenAPI schema, and the cp-api / dashboard side. This PR cuts the duplication. The Rust struct is now the single source of truth; `dump-schema` (PR #308) writes canonical draft-07 JSON Schemas into `schemas/resources/*.schema.json`; CI (PR #309) enforces those files match the structs. This commit: 1. Removes the ten inlined resource schemas from `OPENAPI_JSON_BASE` (the const formerly named `OPENAPI_JSON`). 2. Embeds the eight canonical schema files at compile time via `include_str!` into a new `RESOURCE_SCHEMAS` const. 3. Adds `merged_openapi()` — runs once on first request, parses the base spec, parses each embedded schema, hoists `definitions/*` into top-level `components.schemas`, rewrites `$ref: #/definitions/X` to `$ref: #/components/schemas/X` (JSON Schema draft-07 → OpenAPI 3.1), and caches the result in an `OnceLock`. 4. Changes `openapi_json()` to serve the merged doc instead of the raw `OPENAPI_JSON_BASE`. 5. Updates the three openapi unit tests to parse `merged_openapi()`. ## What this means for `/admin/openapi.json` The served document keeps the same wrapper schemas (`ModelEntry`, `ApiKeyEntry`, `ModelStatusView`, `ModelKind`, `RuntimeStatus`, `SystemTime`, `AdminError`) and gains 16 new top-level component schemas hoisted from the resource definitions (`Adapter`, `BedrockConfig`, `CacheBackend`, `CooldownConfig`, `GuardrailHookPoint`, `KeywordPattern`, `OnAllFilteredPolicy`, `ParamConstraints`, `Provider`, `RequestOverrides`, `ResponseOverrides`, `RoutingStrategy`, `RoutingTarget`, `StreamDoneMarker`, `TelemetryTags`, etc.). The resource schemas themselves are now precise reflections of the Rust types — e.g. `Guardrail` uses a proper `oneOf` discriminator on `kind` instead of the previous flat `additionalProperties: true` hand-wave; `Provider` lists its 6 variants from the actual enum; `Adapter` lists the 5 wire-shape kebab-case values from #302 Phase A. ## Verification - `cargo check -p aisix-admin` clean - `cargo clippy --workspace --all-targets -- -D warnings` clean - `cargo fmt --all -- --check` clean - `cargo test -p aisix-admin --lib` — all 7 openapi tests pass, including the regression test `openapi_apikey_schema_excludes_max_budget_usd` - External validation: parsed the merged doc, collected 43 `$ref` references across 32 distinct targets, all resolve inside `#/components/schemas/*` (0 unresolved) ## Why nested `if let` instead of let-chains Workspace is on `edition = "2021"`. The merge logic uses one level of nesting in two spots; not pretty, but `edition = "2024"` is a separate decision not in this PR's scope. ## Stack Builds on: - #307 (JsonSchema derives on resource structs) - #308 (dump-schema binary + initial schema files) - #309 (CI drift enforcement) Merge order: 307 → 308 → 309 → this PR. Base will switch to `main` once #308 merges. Refs api7/ai-gateway#304 (#1). --- crates/aisix-admin/src/openapi.rs | 289 +++++++++++++++--------------- 1 file changed, 148 insertions(+), 141 deletions(-) diff --git a/crates/aisix-admin/src/openapi.rs b/crates/aisix-admin/src/openapi.rs index 62811b6e..4172ba42 100644 --- a/crates/aisix-admin/src/openapi.rs +++ b/crates/aisix-admin/src/openapi.rs @@ -1,4 +1,5 @@ -//! Hand-written OpenAPI 3.1 document + Scalar mount. +//! Hand-written OpenAPI 3.1 paths + Scalar mount, with resource +//! schemas merged in from `schemas/resources/`. //! //! Covers every route mounted in `crate::build_router` so what you see //! at `/admin/openapi-scalar` matches what the binary actually serves. @@ -6,17 +7,33 @@ //! operators refer to OpenAI's published spec for that — duplicating //! it here adds drift risk without adding signal. //! +//! Resource shapes (`Model`, `ApiKey`, `ProviderKey`, `Guardrail`, +//! `CachePolicy`, `ObservabilityExporter`, `RateLimit`, `Routing`) are +//! NOT inlined in the constant below — they live in +//! `/schemas/resources/.schema.json`, generated +//! from the `schemars::JsonSchema` derives on the +//! `aisix-core::models::*` types (regenerated by the `dump-schema` +//! binary; CI enforces no drift). At first call to [`openapi_json`] +//! each file is parsed, its `definitions/*` are hoisted to top-level +//! `components.schemas`, `$ref` paths are rewritten from JSON Schema's +//! `#/definitions/X` to OpenAPI's `#/components/schemas/X`, and the +//! result is cached for the process lifetime. +//! //! The Scalar UI is a single static HTML page that loads the JSON spec //! over HTTP — no JS bundling required. +use std::sync::OnceLock; + use axum::http::header; use axum::response::{Html, IntoResponse, Response}; +use serde_json::Value; -/// Hand-written JSON spec. Small enough that maintaining it by hand is -/// less effort than wiring `utoipa` derive macros across every handler; -/// the surface is stable enough that drift is easy to spot in review. -/// Update this whenever a route is added/removed in `lib.rs`. -const OPENAPI_JSON: &str = r##"{ +/// Paths + OpenAPI-specific wrapper schemas (`ModelEntry`, +/// `ApiKeyEntry`, `ModelStatusView`, `AdminError`, etc.). Resource +/// schemas live in [`RESOURCE_SCHEMAS`] below and get merged in by +/// [`merged_openapi`]. Update this whenever a route is added or +/// removed in `lib.rs`. +const OPENAPI_JSON_BASE: &str = r##"{ "openapi": "3.1.0", "info": { "title": "aisix admin API", @@ -266,22 +283,6 @@ const OPENAPI_JSON: &str = r##"{ } }, "schemas": { - "Model": { - "type": "object", - "required": ["display_name"], - "properties": { - "display_name": {"type": "string", "example": "my-gpt4"}, - "provider": {"type": "string", "enum": ["openai","anthropic","google","deepseek","cohere","jina"]}, - "model_name": {"type": "string", "example": "gpt-4o"}, - "provider_key_id": {"type": "string", "example": "11111111-1111-1111-1111-111111111111"}, - "timeout": {"type": "integer", "minimum": 0, "description": "Request timeout in milliseconds. Absent or 0 = no timeout."}, - "rate_limit": {"$ref": "#/components/schemas/RateLimit"}, - "routing": {"$ref": "#/components/schemas/Routing"}, - "cost": {"$ref": "#/components/schemas/ModelCost"}, - "background_model_check": {"$ref": "#/components/schemas/BackgroundModelCheck"} - }, - "description": "A direct model ships `provider` + `model_name` + `provider_key_id`; a routing model ships `routing` and omits the upstream triple. `background_model_check` is direct-model-only and rejected on routing models." - }, "ModelEntry": { "type": "object", "required": ["id", "value", "revision"], @@ -291,24 +292,6 @@ const OPENAPI_JSON: &str = r##"{ "revision": {"type": "integer"} } }, - "BackgroundModelCheck": { - "type": "object", - "required": ["enabled", "interval_seconds", "timeout_seconds", "prompt", "max_tokens", "stale_after_seconds"], - "properties": { - "enabled": {"type": "boolean", "description": "Turns the periodic direct-model probe on or off."}, - "interval_seconds": {"type": "integer", "minimum": 1, "description": "Probe interval in seconds."}, - "timeout_seconds": {"type": "integer", "minimum": 1, "description": "Per-probe timeout in seconds."}, - "prompt": {"type": "string", "minLength": 1, "description": "Minimal prompt used by the background probe request."}, - "max_tokens": {"type": "integer", "minimum": 1, "description": "Max completion tokens used by the probe request."}, - "ignore_statuses": { - "type": "array", - "description": "Upstream HTTP statuses that should be recorded without marking the model unhealthy. Typical values are 408 and 429.", - "items": {"type": "integer", "minimum": 100, "maximum": 599} - }, - "stale_after_seconds": {"type": "integer", "minimum": 1, "description": "Age threshold after which an unhealthy background-check result is treated as stale and stops excluding the model."} - }, - "description": "Periodic direct-model health-check configuration. Rejected on routing models." - }, "ModelStatusView": { "type": "object", "required": ["id", "display_name", "kind", "status"], @@ -340,15 +323,6 @@ const OPENAPI_JSON: &str = r##"{ "nanos_since_epoch": {"type": "integer", "minimum": 0, "maximum": 999999999} } }, - "ApiKey": { - "type": "object", - "required": ["key_hash", "allowed_models"], - "properties": { - "key_hash": {"type": "string", "description": "SHA-256 hex of the plaintext bearer. Lowercase.", "example": "91ed2dbc407561556f3e7be98ba0bd2a57986d6a868c482d867d19c6d40d201c"}, - "allowed_models": {"type": "array", "items": {"type": "string"}, "description": "Allowed Model display_names. `[\"*\"]` for all; `[]` denies everything."}, - "rate_limit": {"$ref": "#/components/schemas/RateLimit"} - } - }, "ApiKeyEntry": { "type": "object", "required": ["id", "value", "revision"], @@ -358,92 +332,6 @@ const OPENAPI_JSON: &str = r##"{ "revision": {"type": "integer"} } }, - "ProviderKey": { - "type": "object", - "required": ["display_name", "secret"], - "properties": { - "display_name": {"type": "string", "example": "openai-prod"}, - "secret": {"type": "string", "description": "Upstream provider API key, plaintext.", "example": "sk-prod-xxxx"}, - "api_base": {"type": "string", "description": "Override for the upstream base URL. Empty/absent uses the provider default."} - } - }, - "RateLimit": { - "type": "object", - "properties": { - "tpm": {"type": "integer", "minimum": 0, "description": "Tokens per minute"}, - "tpd": {"type": "integer", "minimum": 0, "description": "Tokens per day"}, - "rpm": {"type": "integer", "minimum": 0, "description": "Requests per minute"}, - "rpd": {"type": "integer", "minimum": 0, "description": "Requests per day"}, - "concurrency": {"type": "integer", "minimum": 0, "description": "Max in-flight"} - } - }, - "Routing": { - "type": "object", - "required": ["targets"], - "properties": { - "strategy": {"type": "string", "enum": ["round_robin", "weighted", "failover"]}, - "targets": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["model"], - "properties": { - "model": {"type": "string", "description": "Target Model.display_name"}, - "weight": {"type": "integer", "minimum": 0} - } - } - }, - "retries": {"type": "integer", "minimum": 0}, - "max_fallbacks": {"type": "integer", "minimum": 0}, - "retry_on_429": {"type": "boolean"} - } - }, - "ModelCost": { - "type": "object", - "required": ["input_per_1k", "output_per_1k"], - "properties": { - "input_per_1k": {"type": "number", "minimum": 0, "description": "USD per 1,000 input (prompt) tokens"}, - "output_per_1k": {"type": "number", "minimum": 0, "description": "USD per 1,000 output (completion) tokens"} - } - }, - "Guardrail": { - "type": "object", - "required": ["name", "kind"], - "properties": { - "name": {"type": "string", "example": "block-pii"}, - "enabled": {"type": "boolean", "default": true}, - "hook_point": {"type": "string", "enum": ["input", "output", "both"], "description": "Where in the request lifecycle the guardrail fires."}, - "fail_open": {"type": "boolean", "description": "Only honoured for kind=bedrock. true → request through on remote-API failure (with telemetry annotation); false → 422."}, - "kind": {"type": "string", "enum": ["keyword", "bedrock"]} - }, - "description": "Discriminated by `kind`. `keyword` carries a `patterns` array of literal/regex blocklist entries. `bedrock` carries `guardrail_id`, `guardrail_version`, `region`, `aws_credentials`, `latency_mode`. See `aisix-core::Guardrail` for the per-kind shape.", - "additionalProperties": true - }, - "CachePolicy": { - "type": "object", - "required": ["name"], - "properties": { - "name": {"type": "string", "minLength": 1, "maxLength": 120, "example": "expensive-prompts"}, - "enabled": {"type": "boolean", "default": true, "description": "Soft kill switch. Disabled policies stay in the snapshot but the cache gate skips them."}, - "backend": {"type": "string", "enum": ["memory", "redis", "redis_semantic", "qdrant"], "default": "memory"}, - "ttl_seconds": {"type": "integer", "minimum": 1, "maximum": 604800, "default": 3600}, - "applies_to": {"type": "string", "minLength": 1, "maxLength": 255, "description": "Optional scope filter (e.g. `model:my-gpt4`, `api_key:k-1`). Absent = all chat completions."}, - "similarity_threshold": {"type": "number", "minimum": 0, "maximum": 1, "description": "redis_semantic / qdrant only."}, - "embedding_model": {"type": "string", "minLength": 1, "maxLength": 120, "description": "redis_semantic / qdrant only."} - } - }, - "ObservabilityExporter": { - "type": "object", - "required": ["name", "kind"], - "properties": { - "name": {"type": "string", "minLength": 1, "maxLength": 120, "example": "honeycomb"}, - "enabled": {"type": "boolean", "default": true}, - "kind": {"type": "string", "enum": ["otlp_http"]}, - "endpoint": {"type": "string", "description": "Full URL of the OTLP/HTTP traces endpoint, including the `/v1/traces` path. Required when kind=otlp_http."}, - "headers": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Static headers attached to every export. Plaintext at MVP — kine wire is mTLS-only."} - } - }, "AdminError": { "type": "object", "required": ["error_msg"], @@ -471,8 +359,127 @@ const SCALAR_HTML: &str = r#" "#; +/// Resource schemas embedded at compile time from +/// `/schemas/resources/*.schema.json`. The build will +/// fail if a file is missing — that is intentional, the canonical +/// schemas must exist before this crate compiles. +const RESOURCE_SCHEMAS: &[(&str, &str)] = &[ + ( + "Model", + include_str!("../../../schemas/resources/model.schema.json"), + ), + ( + "ApiKey", + include_str!("../../../schemas/resources/api_key.schema.json"), + ), + ( + "ProviderKey", + include_str!("../../../schemas/resources/provider_key.schema.json"), + ), + ( + "Guardrail", + include_str!("../../../schemas/resources/guardrail.schema.json"), + ), + ( + "CachePolicy", + include_str!("../../../schemas/resources/cache_policy.schema.json"), + ), + ( + "ObservabilityExporter", + include_str!("../../../schemas/resources/observability_exporter.schema.json"), + ), + ( + "RateLimit", + include_str!("../../../schemas/resources/rate_limit.schema.json"), + ), + ( + "Routing", + include_str!("../../../schemas/resources/routing.schema.json"), + ), +]; + +/// Build the merged OpenAPI document on first call and cache for the +/// process lifetime. The result is what `GET /admin/openapi.json` +/// serves; the constants above hold only the input fragments. +fn merged_openapi() -> &'static str { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + let mut doc: Value = + serde_json::from_str(OPENAPI_JSON_BASE).expect("OPENAPI_JSON_BASE must parse as JSON"); + + for (name, raw) in RESOURCE_SCHEMAS { + let mut schema: Value = serde_json::from_str(raw) + .unwrap_or_else(|e| panic!("schema {name} must parse: {e}")); + + // Hoist `definitions/*` to top-level `components.schemas/*`. + // The first definition wins — `Routing` appears both as a + // standalone resource and nested in `Model`; they are + // produced from the same Rust type so the content is + // identical, but we still guard against double-write. + if let Some(obj) = schema.as_object_mut() { + if let Some(Value::Object(defs)) = obj.remove("definitions") { + for (def_name, def_value) in defs { + let target = &mut doc["components"]["schemas"][&def_name]; + if target.is_null() { + *target = def_value; + } + } + } + } + + // Strip JSON Schema meta-fields that do not belong on an + // inline OpenAPI 3.1 component schema. + if let Some(obj) = schema.as_object_mut() { + obj.remove("$schema"); + obj.remove("title"); + } + + doc["components"]["schemas"][*name] = schema; + } + + // Rewrite `$ref: #/definitions/X` → `$ref: #/components/schemas/X` + // everywhere in the merged doc (covers both the top-level + // resource schemas and the already-hoisted nested ones). + rewrite_definitions_refs(&mut doc); + + serde_json::to_string(&doc).expect("merged OpenAPI must serialise") + }) +} + +/// Walk a JSON value, rewriting `{"$ref": "#/definitions/X"}` strings +/// to `{"$ref": "#/components/schemas/X"}`. `schemars` 0.8 emits the +/// JSON Schema draft-07 form; OpenAPI 3.1 requires the +/// `components/schemas` form for in-document references. +fn rewrite_definitions_refs(v: &mut Value) { + match v { + Value::Object(map) => { + for (k, val) in map.iter_mut() { + if k == "$ref" { + if let Value::String(s) = val { + if let Some(suffix) = s.strip_prefix("#/definitions/") { + *val = Value::String(format!("#/components/schemas/{suffix}")); + } + } + } else { + rewrite_definitions_refs(val); + } + } + } + Value::Array(items) => { + for item in items { + rewrite_definitions_refs(item); + } + } + _ => {} + } +} + pub async fn openapi_json() -> Response { - ([(header::CONTENT_TYPE, "application/json")], OPENAPI_JSON).into_response() + ( + [(header::CONTENT_TYPE, "application/json")], + merged_openapi(), + ) + .into_response() } pub async fn openapi_scalar() -> Html<&'static str> { @@ -489,7 +496,7 @@ mod tests { assert_eq!(resp.status(), 200); // Validate by parsing — guards against typos in the literal block. let parsed: serde_json::Value = - serde_json::from_str(OPENAPI_JSON).expect("OPENAPI_JSON must parse"); + serde_json::from_str(merged_openapi()).expect("merged_openapi must parse"); // Every route mounted in build_router should be documented. for path in [ "/livez", @@ -515,7 +522,7 @@ mod tests { ] { assert!( parsed["paths"][path].is_object(), - "OPENAPI_JSON missing path {path}" + "merged OpenAPI missing path {path}" ); } // Reusable schemas referenced from the path bodies. @@ -540,7 +547,7 @@ mod tests { ] { assert!( parsed["components"]["schemas"][schema].is_object(), - "OPENAPI_JSON missing schema {schema}" + "merged OpenAPI missing schema {schema}" ); } } @@ -553,7 +560,7 @@ mod tests { // Scalar's "Try it" doesn't prompt for an admin key on those // routes. let parsed: serde_json::Value = - serde_json::from_str(OPENAPI_JSON).expect("OPENAPI_JSON must parse"); + serde_json::from_str(merged_openapi()).expect("merged_openapi must parse"); for path in [ "/livez", "/metrics", @@ -571,7 +578,7 @@ mod tests { #[tokio::test] async fn openapi_livez_documents_plain_ok() { let parsed: serde_json::Value = - serde_json::from_str(OPENAPI_JSON).expect("OPENAPI_JSON must parse"); + serde_json::from_str(merged_openapi()).expect("merged_openapi must parse"); let schema = &parsed["paths"]["/livez"]["get"]["responses"]["200"]["content"]["text/plain"] ["schema"]; From a6505f77e9ce32c743a9ffcc26aab0bc82e53883 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Sun, 17 May 2026 09:37:46 +0800 Subject: [PATCH 2/3] refactor(admin): eagerly init merged OpenAPI doc in build_router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `merged_openapi` previously parsed and merged the embedded resource schemas on the first `/admin/openapi.json` request. That delayed any panic from a corrupt schema fragment until well after boot — a worse ops failure mode than crashing immediately on startup, especially since the panic is captured by axum's error handling and surfaces as a 500 to whoever happens to hit Scalar first. Move the init call up: `build_router` now calls `openapi::merged_openapi()` once at construction time, before any request can land. The result is cached in the same `OnceLock` so the handler still does a free lookup. Visibility on `merged_openapi` flips from private to `pub(crate)` to make the pre-warm callable from `lib.rs`; no other surface change. Surfaced by independent audit of #310. Refs api7/ai-gateway#304 (#1). --- crates/aisix-admin/src/lib.rs | 6 ++++++ crates/aisix-admin/src/openapi.rs | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/aisix-admin/src/lib.rs b/crates/aisix-admin/src/lib.rs index 40871446..4ff36f83 100644 --- a/crates/aisix-admin/src/lib.rs +++ b/crates/aisix-admin/src/lib.rs @@ -57,6 +57,12 @@ use axum::routing::{get, post}; use axum::{http::StatusCode, response::Response, Router}; pub fn build_router(state: AdminState) -> Router { + // Eagerly build the merged OpenAPI doc so any panic in schema + // parsing surfaces at boot, not at first `/admin/openapi.json` + // request. `merged_openapi` caches into an `OnceLock`; the + // subsequent handler call is a free lookup. + let _ = openapi::merged_openapi(); + Router::new() .route("/livez", get(livez)) .route("/metrics", get(metrics_handler)) diff --git a/crates/aisix-admin/src/openapi.rs b/crates/aisix-admin/src/openapi.rs index 4172ba42..e1a479af 100644 --- a/crates/aisix-admin/src/openapi.rs +++ b/crates/aisix-admin/src/openapi.rs @@ -401,7 +401,12 @@ const RESOURCE_SCHEMAS: &[(&str, &str)] = &[ /// Build the merged OpenAPI document on first call and cache for the /// process lifetime. The result is what `GET /admin/openapi.json` /// serves; the constants above hold only the input fragments. -fn merged_openapi() -> &'static str { +/// +/// `pub(crate)` so [`crate::build_router`] can pre-warm it at startup +/// — `OnceLock::get_or_init` panics here would otherwise surface only +/// on the first `/admin/openapi.json` request, well after boot, which +/// is a worse ops failure mode than crashing immediately. +pub(crate) fn merged_openapi() -> &'static str { static CELL: OnceLock = OnceLock::new(); CELL.get_or_init(|| { let mut doc: Value = From 13bcf9c1b629d9dc35f3e13838b37cdafaca9280 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Sun, 17 May 2026 10:02:38 +0800 Subject: [PATCH 3/3] build(docker): copy schemas/ into the build context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/aisix-admin/src/openapi.rs` uses `include_str!` to embed every `schemas/resources/*.schema.json` at compile time. The Docker release stage previously copied only `Cargo.{toml,lock}`, `rust-toolchain.toml`, `rustfmt.toml`, and `crates/` — `cargo build` inside the container therefore failed with eight "couldn't read .../schemas/resources/*.schema.json" errors. Adds a `COPY schemas ./schemas` line and an inline comment pinning the dependency between `include_str!` and the docker context. Surfaced by CI on PR #310 (build job). Refs api7/ai-gateway#304 (#1). --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 85375acd..f07831d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,10 @@ WORKDIR /src # the Dockerfile complexity. Source copy is a single layer. COPY Cargo.toml Cargo.lock rust-toolchain.toml rustfmt.toml ./ COPY crates ./crates +# `crates/aisix-admin/src/openapi.rs` uses `include_str!` to embed +# every `schemas/resources/*.schema.json` at compile time, so the +# Docker context must carry this directory or the release build fails. +COPY schemas ./schemas # `--locked` forces the build to use the exact versions in Cargo.lock — # fails fast if the lockfile is stale rather than silently resolving