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:
2026-08-28 13:54:49 +00:00
parent ad975408e7
commit 42239a0af5
10 changed files with 2001 additions and 29 deletions
+85
View File
@@ -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(&registry);
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;
}
}