feat(http): complete to_openapi gateway projection with error fidelity and route wiring

Refine to_openapi to project operation-level errors (with http_status)
onto /call and /subscribe responses via oneOf merge with protocol-level
errors, preserving HTTP_<status> prefix codes without collision. Fix
BTreeMap→serde_json::Map for Value::Object compatibility. Wire GET
/openapi.json route test. Apply cargo fmt across the crate.
This commit is contained in:
glm-5.2 committed 2026-07-01 20:11:09 +00:00
1 parent 2695a19502
commit dd6aacc598
17 files changed
+1227 -683

No files matched your search

+18
View File
@@ -694,4 +694,22 @@ mod tests {
);
assert!(response.contains("location: https://example.com"));
}
#[tokio::test]
async fn openapi_json_route_serves_gateway_spec() {
let adapter = HttpAdapter::new(provider(), empty_registry());
let request = b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let response = serve_and_read(adapter, request).await;
assert!(
response.starts_with("HTTP/1.1 200"),
"expected 200 for /openapi.json, got: {response}"
);
assert!(response.contains("\"openapi\""));
assert!(response.contains("\"/search\""));
assert!(response.contains("\"/schema\""));
assert!(response.contains("\"/call\""));
assert!(response.contains("\"/batch\""));
assert!(response.contains("\"/subscribe\""));
assert!(response.contains("\"1.0.0\""));
}
}
+25 -27
View File
@@ -80,11 +80,12 @@ where
{
type Rejection = Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let identity = parts.extensions.get::<Option<Identity>>().cloned().flatten();
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let identity = parts
.extensions
.get::<Option<Identity>>()
.cloned()
.flatten();
Ok(ResolvedIdentity(identity))
}
}
@@ -174,15 +175,16 @@ mod tests {
assert!(identity.is_none());
}
async fn run_middleware(
idp: Arc<dyn IdentityProvider>,
request: Request,
) -> Response {
async fn run_middleware(idp: Arc<dyn IdentityProvider>, request: Request) -> Response {
let app: Router<()> = Router::new()
.route(
"/",
get(|req: Request| async move {
let identity = req.extensions().get::<Option<Identity>>().cloned().flatten();
let identity = req
.extensions()
.get::<Option<Identity>>()
.cloned()
.flatten();
if let Some(id) = identity {
(StatusCode::OK, id.id)
} else {
@@ -261,14 +263,12 @@ mod tests {
let app: Router<()> = Router::new()
.route(
"/",
get(
|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
},
),
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
}),
)
.layer(from_fn_with_state(idp, bearer_auth_middleware));
@@ -287,14 +287,12 @@ mod tests {
let app: Router<()> = Router::new()
.route(
"/",
get(
|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
},
),
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
}),
)
.layer(from_fn_with_state(idp, bearer_auth_middleware));
@@ -306,4 +304,4 @@ mod tests {
.unwrap();
assert_eq!(&bytes[..], b"none");
}
}
}
+9 -20
View File
@@ -33,10 +33,8 @@ pub fn fake_nginx_404() -> Response {
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
resp.headers_mut().insert(
header::SERVER,
HeaderValue::from_static("nginx"),
);
resp.headers_mut()
.insert(header::SERVER, HeaderValue::from_static("nginx"));
resp
}
@@ -61,10 +59,8 @@ pub async fn serve_static(root: &Path, request: Request) -> Response {
let content_type = mime_for_path(&resolved);
let mut resp = Response::new(Body::from(bytes));
*resp.status_mut() = StatusCode::OK;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type),
);
resp.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
resp
}
Err(_) => fake_nginx_404(),
@@ -173,10 +169,7 @@ mod tests {
async fn send(router: axum::Router, uri: &str) -> axum::response::Response {
tower::ServiceExt::<Request<Body>>::oneshot(
router,
Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
Request::builder().uri(uri).body(Body::empty()).unwrap(),
)
.await
.unwrap()
@@ -220,9 +213,7 @@ mod tests {
async fn unknown_path_with_static_site_decoy_serves_file() {
let dir = tempfile_dir();
let file = dir.join("index.html");
tokio::fs::write(&file, "<h1>hello</h1>")
.await
.unwrap();
tokio::fs::write(&file, "<h1>hello</h1>").await.unwrap();
let decoy = DecoyConfig::StaticSite { root: dir.clone() };
let resp = send(decoy_router(decoy), "/").await;
@@ -293,11 +284,9 @@ mod tests {
}
fn tempfile_dir() -> PathBuf {
let dir = PathBuf::from("/tmp").join(format!(
"alknet-http-decoy-test-{}",
uuid::Uuid::new_v4()
));
let dir =
PathBuf::from("/tmp").join(format!("alknet-http-decoy-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
}
}
+31 -11
View File
@@ -52,13 +52,19 @@ impl GatewayState {
}
fn dispatch(&self) -> GatewayDispatch {
GatewayDispatch::new(Arc::clone(&self.registry), Arc::clone(&self.identity_provider))
GatewayDispatch::new(
Arc::clone(&self.registry),
Arc::clone(&self.identity_provider),
)
}
}
impl FromRef<RouterState> for GatewayState {
fn from_ref(state: &RouterState) -> Self {
GatewayState::new(Arc::clone(&state.registry), Arc::clone(&state.identity_provider))
GatewayState::new(
Arc::clone(&state.registry),
Arc::clone(&state.identity_provider),
)
}
}
@@ -92,7 +98,9 @@ pub(crate) async fn call_handler(
return not_found_response(&request.operation);
}
let dispatch = state.dispatch();
let envelope = dispatch.invoke(identity.clone(), &request.operation, request.input).await;
let envelope = dispatch
.invoke(identity.clone(), &request.operation, request.input)
.await;
envelope_to_response(envelope, identity.as_ref())
}
@@ -101,7 +109,9 @@ pub(crate) async fn search_handler(
ResolvedIdentity(identity): ResolvedIdentity,
) -> Response {
let dispatch = state.dispatch();
let envelope = dispatch.invoke(identity.clone(), SERVICES_LIST, json!({})).await;
let envelope = dispatch
.invoke(identity.clone(), SERVICES_LIST, json!({}))
.await;
envelope_to_response(envelope, identity.as_ref())
}
@@ -115,7 +125,11 @@ pub(crate) async fn schema_handler(
}
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(identity.clone(), SERVICES_SCHEMA, json!({ "name": query.name }))
.invoke(
identity.clone(),
SERVICES_SCHEMA,
json!({ "name": query.name }),
)
.await;
envelope_to_response(envelope, identity.as_ref())
}
@@ -149,7 +163,9 @@ pub(crate) async fn subscribe_handler(
subscribe_stream_internal_error(request.operation)
} else {
let dispatch = state.dispatch();
let envelope = dispatch.invoke(identity, &request.operation, request.input).await;
let envelope = dispatch
.invoke(identity, &request.operation, request.input)
.await;
subscribe_stream_from_envelope(envelope)
};
Sse::new(stream)
@@ -221,8 +237,7 @@ fn not_found_response(operation: &str) -> Response {
fn forbidden_response(message: String, identity: Option<&Identity>) -> Response {
let error = CallError::forbidden(message);
let status_code = call_error_to_http_status_with_identity(&error, identity);
let status =
StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_value(&error).unwrap_or(Value::Null);
(status, Json(body)).into_response()
}
@@ -248,7 +263,9 @@ fn is_internal_op(registry: &OperationRegistry, operation: &str) -> bool {
}
}
fn envelope_to_sse_stream(envelope: ResponseEnvelope) -> impl Stream<Item = Result<Event, Infallible>> {
fn envelope_to_sse_stream(
envelope: ResponseEnvelope,
) -> impl Stream<Item = Result<Event, Infallible>> {
stream::once(async move {
match envelope.result {
Ok(output) => {
@@ -756,7 +773,10 @@ mod tests {
.get(axum::http::header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap().to_string());
assert!(
ctype.as_deref().unwrap_or("").starts_with("text/event-stream"),
ctype
.as_deref()
.unwrap_or("")
.starts_with("text/event-stream"),
"expected text/event-stream, got {ctype:?}"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
@@ -950,4 +970,4 @@ mod tests {
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
}
}
}
+1 -1
View File
@@ -59,4 +59,4 @@ mod tests {
let resp = call_healthz(req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
}
}