Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 25 additions & 1 deletion crates/aisix-admin/src/etcd_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@

use aisix_core::resource::ResourceEntry;
use aisix_core::{
A2aAgent, ApiKey, CachePolicy, Guardrail, McpServer, Model, ObservabilityExporter, ProviderKey,
A2aAgent, ApiKey, CachePolicy, Guardrail, McpServer, Model, ObservabilityExporter,
PassthroughRoute, ProviderKey,
};
use etcd_client::{Client, GetOptions};
use serde::de::DeserializeOwned;
Expand All @@ -39,6 +40,7 @@ pub const CACHE_POLICIES_SUBKEY: &str = "cache_policies";
pub const OBSERVABILITY_EXPORTERS_SUBKEY: &str = "observability_exporters";
pub const MCP_SERVERS_SUBKEY: &str = "mcp_servers";
pub const A2A_AGENTS_SUBKEY: &str = "a2a_agents";
pub const PASSTHROUGH_ROUTES_SUBKEY: &str = "passthrough_routes";

pub struct EtcdConfigStore {
client: Mutex<Client>,
Expand Down Expand Up @@ -292,6 +294,28 @@ impl ConfigStore for EtcdConfigStore {
.map(|(id, v, rev)| ResourceEntry::new(id, v, rev))
.collect())
}

async fn get_passthrough_route(
&self,
id: &str,
) -> Result<Option<ResourceEntry<PassthroughRoute>>, StoreError> {
let key = self.key_for(PASSTHROUGH_ROUTES_SUBKEY, id);
Ok(self
.get_one::<PassthroughRoute>(&key)
.await?
.map(|(v, rev)| ResourceEntry::new(id, v, rev)))
}

async fn list_passthrough_routes(
&self,
) -> Result<Vec<ResourceEntry<PassthroughRoute>>, StoreError> {
Ok(self
.list_range::<PassthroughRoute>(PASSTHROUGH_ROUTES_SUBKEY)
.await?
.into_iter()
.map(|(id, v, rev)| ResourceEntry::new(id, v, rev))
.collect())
}
}

#[cfg(test)]
Expand Down
3 changes: 2 additions & 1 deletion crates/aisix-admin/src/file_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use aisix_core::resource::ResourceEntry;
use aisix_core::snapshot::{ResourceTable, SnapshotHandle};
use aisix_core::{
A2aAgent, AisixSnapshot, ApiKey, CachePolicy, Guardrail, McpServer, Model,
ObservabilityExporter, ProviderKey,
ObservabilityExporter, PassthroughRoute, ProviderKey,
};

use crate::store::{ConfigStore, StoreError};
Expand Down Expand Up @@ -78,6 +78,7 @@ impl_file_managed_store! {
{ ObservabilityExporter, observability_exporters, get_observability_exporter, list_observability_exporters }
{ McpServer, mcp_servers, get_mcp_server, list_mcp_servers }
{ A2aAgent, a2a_agents, get_a2a_agent, list_a2a_agents }
{ PassthroughRoute, passthrough_routes, get_passthrough_route, list_passthrough_routes }
}

#[cfg(test)]
Expand Down
17 changes: 15 additions & 2 deletions crates/aisix-admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
//! `GET /admin/v1/observability_exporters/:id`
//! - `GET /admin/v1/mcp_servers` and `GET /admin/v1/mcp_servers/:id`
//! - `GET /admin/v1/a2a_agents` and `GET /admin/v1/a2a_agents/:id`
//! - `GET /admin/v1/passthrough_routes` and
//! `GET /admin/v1/passthrough_routes/:id`
//! - `GET /admin/v1/models/status`, `GET /admin/v1/health`
//!
//! The resource write endpoints (POST/PUT/DELETE, including api-key
Expand Down Expand Up @@ -50,6 +52,7 @@ mod models_handlers;
mod models_status_handler;
mod observability_exporters_handlers;
mod openapi;
mod passthrough_routes_handlers;
mod playground_handler;
mod provider_keys_handlers;
mod state;
Expand Down Expand Up @@ -163,6 +166,14 @@ pub fn build_router(state: AdminState) -> Router {
"/admin/v1/a2a_agents/:id",
get(a2a_agents_handlers::get_a2a_agent),
)
.route(
"/admin/v1/passthrough_routes",
get(passthrough_routes_handlers::list_passthrough_routes),
)
.route(
"/admin/v1/passthrough_routes/:id",
get(passthrough_routes_handlers::get_passthrough_route),
)
.route(
"/admin/v1/guardrails",
get(guardrails_handlers::list_guardrails),
Expand Down Expand Up @@ -909,7 +920,7 @@ mod tests {
use aisix_core::resource::ResourceEntry;
use aisix_core::{
A2aAgent, ApiKey, CachePolicy, Guardrail, McpServer, Model, ObservabilityExporter,
ProviderKey,
PassthroughRoute, ProviderKey,
};

// A store whose every call fails with backend detail an anonymous
Expand Down Expand Up @@ -945,6 +956,7 @@ mod tests {
{ ObservabilityExporter, get_observability_exporter, list_observability_exporters }
{ McpServer, get_mcp_server, list_mcp_servers }
{ A2aAgent, get_a2a_agent, list_a2a_agents }
{ PassthroughRoute, get_passthrough_route, list_passthrough_routes }
}

let app = metrics_router(
Expand Down Expand Up @@ -983,7 +995,7 @@ mod tests {
use aisix_core::resource::ResourceEntry;
use aisix_core::{
A2aAgent, ApiKey, CachePolicy, Guardrail, McpServer, Model, ObservabilityExporter,
ProviderKey,
PassthroughRoute, ProviderKey,
};

// A store whose every call never resolves (mimics a blackholed
Expand Down Expand Up @@ -1019,6 +1031,7 @@ mod tests {
{ ObservabilityExporter, get_observability_exporter, list_observability_exporters }
{ McpServer, get_mcp_server, list_mcp_servers }
{ A2aAgent, get_a2a_agent, list_a2a_agents }
{ PassthroughRoute, get_passthrough_route, list_passthrough_routes }
}

let app = metrics_router(
Expand Down
163 changes: 163 additions & 0 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,113 @@ const OPENAPI_JSON_BASE: &str = r##"{
"description": "Get an upstream A2A agent resource by ID."
}
},
"/admin/v1/passthrough_routes": {
"get": {
"summary": "List Passthrough Routes",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PassthroughRouteEntry"
}
}
}
}
},
"401": {
"description": "Missing or invalid admin key",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminError"
}
}
}
},
"500": {
"description": "Configuration store operation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminError"
}
}
}
}
},
"tags": [
"Passthrough Routes"
],
Comment thread
jarvis9443 marked this conversation as resolved.
"description": "List explicit passthrough route resources."
}
},
"/admin/v1/passthrough_routes/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Passthrough route resource ID, as assigned by the active resource source (a UUIDv5 derived from the entry name in file mode; the etcd key's ID segment otherwise).",
"example": "1d95ac57-7f27-46a4-b5a3-55d3c3ad0a12"
}
],
"get": {
"summary": "Get Passthrough Route by ID",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PassthroughRouteEntry"
}
}
}
},
"401": {
"description": "Missing or invalid admin key",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminError"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminError"
}
}
}
},
"500": {
"description": "Configuration store operation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminError"
}
}
}
}
},
"tags": [
"Passthrough Routes"
],
"description": "Get an explicit passthrough route resource by ID."
}
},
"/admin/v1/guardrails": {
"get": {
"summary": "List Guardrails",
Expand Down Expand Up @@ -1552,6 +1659,31 @@ const OPENAPI_JSON_BASE: &str = r##"{
},
"description": "Stored Admin API resource entry."
},
"PassthroughRouteEntry": {
"type": "object",
"required": [
"id",
"value",
"revision"
],
"properties": {
"id": {
"type": "string",
"description": "Resource ID, as assigned by the active resource source.",
"example": "1d95ac57-7f27-46a4-b5a3-55d3c3ad0a12"
},
"value": {
"$ref": "#/components/schemas/PassthroughRoute",
"description": "Stored passthrough route configuration."
},
"revision": {
"type": "integer",
"description": "Monotonic resource revision: the etcd mod_revision of the entry, or the load generation in file mode.",
"example": 1845
}
},
"description": "Stored Admin API resource entry."
},
"A2aAgentEntry": {
"type": "object",
"required": [
Expand Down Expand Up @@ -1779,6 +1911,10 @@ const OPENAPI_JSON_BASE: &str = r##"{
"name": "A2A Agents",
"description": "Upstream A2A agents exposed through the gateway Agent Gateway endpoint."
},
{
"name": "Passthrough Routes",
"description": "Explicit passthrough routes that forward matching requests to one upstream target."
},
{
"name": "Guardrails",
"description": "Guardrail policies attached to proxy traffic."
Expand Down Expand Up @@ -1840,6 +1976,10 @@ const RESOURCE_SCHEMAS: &[(&str, &str)] = &[
"A2aAgent",
include_str!("../../../schemas/resources/a2a_agent.schema.json"),
),
(
"PassthroughRoute",
include_str!("../../../schemas/resources/passthrough_route.schema.json"),
),
(
"Guardrail",
include_str!("../../../schemas/resources/guardrail.schema.json"),
Expand Down Expand Up @@ -2440,6 +2580,8 @@ mod tests {
"/admin/v1/mcp_servers/{id}",
"/admin/v1/a2a_agents",
"/admin/v1/a2a_agents/{id}",
"/admin/v1/passthrough_routes",
"/admin/v1/passthrough_routes/{id}",
"/admin/v1/guardrails",
"/admin/v1/guardrails/{id}",
"/admin/v1/cache_policies",
Expand Down Expand Up @@ -2472,6 +2614,8 @@ mod tests {
"McpServerEntry",
"A2aAgent",
"A2aAgentEntry",
"PassthroughRoute",
"PassthroughRouteEntry",
"Guardrail",
"GuardrailEntry",
"CachePolicy",
Expand Down Expand Up @@ -2523,6 +2667,8 @@ mod tests {
"/admin/v1/mcp_servers/{id}",
"/admin/v1/a2a_agents",
"/admin/v1/a2a_agents/{id}",
"/admin/v1/passthrough_routes",
"/admin/v1/passthrough_routes/{id}",
"/admin/v1/guardrails",
"/admin/v1/guardrails/{id}",
"/admin/v1/cache_policies",
Expand Down Expand Up @@ -2687,6 +2833,15 @@ mod tests {
let paths = parsed["paths"]
.as_object()
.expect("paths must be an object");
// Every operation tag must be DECLARED in the top-level tags
// array — an undeclared tag renders as an unordered, undescribed
// group in the reference, and nothing else fails.
let declared_tags: std::collections::BTreeSet<&str> = parsed["tags"]
.as_array()
.expect("top-level tags must be an array")
.iter()
.filter_map(|t| t["name"].as_str())
.collect();

for (path, path_item) in paths {
let path_item = path_item.as_object().expect("path item must be an object");
Expand All @@ -2700,6 +2855,14 @@ mod tests {
.is_some_and(|tags| !tags.is_empty()),
"{method} {path} missing tags"
);
for tag in operation["tags"].as_array().into_iter().flatten() {
let tag = tag.as_str().unwrap_or_default();
assert!(
declared_tags.contains(tag),
"{method} {path} uses undeclared tag {tag:?} — add it to the \
top-level tags array"
);
}
assert!(
operation["description"]
.as_str()
Expand Down
34 changes: 34 additions & 0 deletions crates/aisix-admin/src/passthrough_routes_handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! Read handlers for `/admin/v1/passthrough_routes`: list and get-by-id,
//! same shape as [`crate::mcp_servers_handlers`]. Cross-field coupling
//! (match dimensions, target shape, per-mode required companions) lives in
//! the canonical schema, enforced on every declarative write path.

use aisix_core::resource::ResourceEntry;
use aisix_core::PassthroughRoute;
use axum::extract::{Path, State};
use axum::Json;

use crate::auth::AdminAuth;
use crate::error::AdminError;
use crate::state::AdminState;

pub async fn list_passthrough_routes(
_auth: AdminAuth,
State(state): State<AdminState>,
) -> Result<Json<Vec<ResourceEntry<PassthroughRoute>>>, AdminError> {
let entries = state.store.list_passthrough_routes().await?;
Ok(Json(entries))
}

pub async fn get_passthrough_route(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
) -> Result<Json<ResourceEntry<PassthroughRoute>>, AdminError> {
let entry = state
.store
.get_passthrough_route(&id)
.await?
.ok_or(AdminError::NotFound)?;
Ok(Json(entry))
}
Loading
Loading