Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/aisix-admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
294 changes: 153 additions & 141 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
//! 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.
//! The proxy `/v1/chat/completions` surface is OpenAI-compatible and
//! 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
//! `<workspace-root>/schemas/resources/<name>.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",
Expand Down Expand Up @@ -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"],
Expand All @@ -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"],
Expand Down Expand Up @@ -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"],
Expand All @@ -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"],
Expand Down Expand Up @@ -471,8 +359,132 @@ const SCALAR_HTML: &str = r#"<!DOCTYPE html>
</body>
</html>"#;

/// Resource schemas embedded at compile time from
/// `<workspace-root>/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<String> = 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> {
Expand All @@ -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",
Expand All @@ -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.
Expand All @@ -540,7 +552,7 @@ mod tests {
] {
assert!(
parsed["components"]["schemas"][schema].is_object(),
"OPENAPI_JSON missing schema {schema}"
"merged OpenAPI missing schema {schema}"
);
}
}
Expand All @@ -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",
Expand All @@ -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"];

Expand Down
Loading