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 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 62811b6e..e1a479af 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,132 @@ 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. +/// +/// `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 = + 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 +501,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 +527,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 +552,7 @@ mod tests { ] { assert!( parsed["components"]["schemas"][schema].is_object(), - "OPENAPI_JSON missing schema {schema}" + "merged OpenAPI missing schema {schema}" ); } } @@ -553,7 +565,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 +583,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"];