feat(gateway,adapters): /publish endpoint (ADR-068) + to_openapi 6-endpoint projection
gateway-publish:
- GatewayDispatch::invoke_sink (internal:false, forwarded_for:None)
- POST /publish: NDJSON body, first line {operation, chunk} (OQ-02
resolved: first-line convention; terminal errors = plain HTTP status
+ JSON body, not NDJSON lines); 404 internal/unknown, 401/403 ACL,
400 INVALID_OPERATION_TYPE for non-Pub
- ADR-068 + open-questions.md updated with the OQ-02 resolution
adapter-to-openapi:
- src/adapters/openapi_spec.rs: OpenAPISpec model (JSON/YAML/from_str
JSON-first per ADR-051, $ref resolution) shared by from/to_openapi
- src/adapters/to_openapi.rs: 6-endpoint projection, info.version
1.0.0 -> 1.1.0 (minor: /publish addition per ADR-045), /publish
NDJSON doc with 400 oneOf (INVALID_INPUT + INVALID_OPERATION_TYPE),
ADR-023 error fidelity (protocol statuses, HTTP_<status> passthrough,
internal-op exclusion)
- GET /openapi.json wired into HttpAdapter's router (bearer-auth layer)
Verified: cargo test (136 lib), test --all-features (136+10 WS),
clippy -D warnings (both), fmt. Doc validates against openapiv3.
This commit is contained in:
@@ -134,6 +134,7 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
|
||||
let default: Router<RouterState> = Router::new()
|
||||
.merge(crate::gateway::routes::gateway_router())
|
||||
.route("/openapi.json", get(openapi_json_handler))
|
||||
.route("/healthz", get(healthz))
|
||||
.route_layer(from_fn_with_state(
|
||||
auth_state.clone(),
|
||||
@@ -203,6 +204,25 @@ fn stream_error_to_handler(e: StreamError) -> HandlerError {
|
||||
HandlerError::from(e)
|
||||
}
|
||||
|
||||
/// `GET /openapi.json` — the `to_openapi` projection of the local
|
||||
/// operation registry (ADR-042, ADR-045): the fixed 6-endpoint gateway
|
||||
/// doc. Served under the bearer-auth route layer like every other
|
||||
/// gateway endpoint.
|
||||
async fn openapi_json_handler(
|
||||
axum::extract::State(registry): axum::extract::State<Arc<OperationRegistry>>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
let spec = crate::adapters::to_openapi(®istry);
|
||||
match serde_json::to_vec(&spec.raw) {
|
||||
Ok(bytes) => ([(http::header::CONTENT_TYPE, "application/json")], bytes).into_response(),
|
||||
Err(e) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to serialize gateway spec: {e}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -357,6 +377,71 @@ mod tests {
|
||||
"decoy should look like nginx: {text}"
|
||||
);
|
||||
|
||||
let _ = server_task.await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn openapi_json_serves_the_gateway_projection() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut registry = OperationRegistry::new();
|
||||
let spec = alkcall::registry::spec::OperationSpec::new(
|
||||
"echo/run",
|
||||
alkcall::registry::spec::OperationType::Query,
|
||||
alkcall::registry::spec::Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
alkcall::registry::spec::AccessControl::default(),
|
||||
None,
|
||||
);
|
||||
registry
|
||||
.register(alkcall::registry::registration::HandlerRegistration::new(
|
||||
spec,
|
||||
alkcall::registry::registration::HandlerKind::Once(
|
||||
alkcall::registry::registration::make_handler(|input, ctx| async move {
|
||||
alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input)
|
||||
}),
|
||||
),
|
||||
alkcall::registry::registration::OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
alkcall::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let adapter = HttpAdapter::new(provider(), Arc::new(registry));
|
||||
let (client, server) = tokio::io::duplex(256 * 1024);
|
||||
let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None);
|
||||
let auth = AuthContext::anonymous(b"http/1.1");
|
||||
let server_task = tokio::spawn(async move {
|
||||
let _ = ProtocolHandler::handle(&adapter, conn, &auth).await;
|
||||
});
|
||||
|
||||
let mut client = client;
|
||||
client
|
||||
.write_all(
|
||||
b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.read_to_end(&mut response),
|
||||
)
|
||||
.await
|
||||
.expect("read timed out")
|
||||
.unwrap();
|
||||
|
||||
let text = String::from_utf8_lossy(&response);
|
||||
assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}");
|
||||
assert!(text.contains("application/json"), "got: {text}");
|
||||
// The 6-endpoint gateway doc with the version from the /publish addition.
|
||||
assert!(text.contains("\"/publish\""), "publish path in doc");
|
||||
assert!(text.contains("1.1.0"), "info.version 1.1.0 in doc");
|
||||
assert!(text.contains("gatewayPublish"), "publish operationId");
|
||||
|
||||
let _ = server_task.await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user