From ff6774d980f18046487000c920518137be0447cc Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Wed, 19 Aug 2026 00:14:32 -0600 Subject: [PATCH 1/2] feat: raw-request passthrough for HTTP webhook operations (COD-402) Opt-in `raw_request: true` operation flag: the generated HTTP handler receives exact raw body bytes + a headers map instead of typed extraction, so consumers can verify signatures (webhook HMAC) over the wire representation while keeping handlers generated. - hydra-core: field + validation (http-only surface, unary, no body params) - hydra-codegen: GeneratedRawOperationInput, HeaderMap/Bytes handlers, http_raw_dispatch_fn config knob, conditional imports - examples/notes: echo_raw dogfood op + live byte-exactness test - tests: pinned pre-feature fixture proves default output byte-identical; iris + rite round-trips verified byte-identical --- crates/hydra-codegen/src/lib.rs | 178 +++++++++++++++--- crates/hydra-codegen/tests/codegen.rs | 149 +++++++++++++++ .../tests/fixtures/notes-pre-raw-http.rs | 118 ++++++++++++ .../fixtures/notes-pre-raw-operations.yaml | 70 +++++++ crates/hydra-core/src/lib.rs | 16 ++ crates/hydra-core/src/validate.rs | 38 ++++ examples/notes/api/operations.yaml | 9 + examples/notes/generated/http.rs | 42 +++++ examples/notes/hydra.yaml | 2 + examples/notes/src/lib.rs | 33 ++++ examples/notes/tests/surfaces.rs | 59 ++++++ 11 files changed, 692 insertions(+), 22 deletions(-) create mode 100644 crates/hydra-codegen/tests/fixtures/notes-pre-raw-http.rs create mode 100644 crates/hydra-codegen/tests/fixtures/notes-pre-raw-operations.yaml diff --git a/crates/hydra-codegen/src/lib.rs b/crates/hydra-codegen/src/lib.rs index 445e004..f35366c 100644 --- a/crates/hydra-codegen/src/lib.rs +++ b/crates/hydra-codegen/src/lib.rs @@ -33,6 +33,12 @@ pub struct GenerateConfig { /// Where the SSE binding hooks live, e.g. `super::`. #[serde(default = "default_sse_binding_prefix")] pub sse_binding_prefix: String, + /// Where generated raw-request handlers send operation inputs, e.g. + /// `super::execute_generated_raw_operation`. Only used when the + /// definition contains `raw_request: true` operations. Emits + /// `(&state, "", GeneratedRawOperationInput { .. })`. + #[serde(default = "default_http_raw_dispatch_fn")] + pub http_raw_dispatch_fn: String, /// Header line identifying the generator in committed artifacts. #[serde(default = "default_generator_name")] pub generator_name: String, @@ -42,6 +48,10 @@ fn default_sse_binding_prefix() -> String { "super::".to_string() } +fn default_http_raw_dispatch_fn() -> String { + "super::execute_generated_raw_operation".to_string() +} + fn default_generator_name() -> String { "hydra".to_string() } @@ -52,6 +62,7 @@ impl Default for GenerateConfig { http_dispatch_fn: "super::execute_generated_operation".to_string(), http_state_type: "crate::app::AppState".to_string(), sse_binding_prefix: default_sse_binding_prefix(), + http_raw_dispatch_fn: default_http_raw_dispatch_fn(), generator_name: default_generator_name(), } } @@ -200,6 +211,55 @@ fn cli_operations(definition: &ApiDefinition) -> impl Iterator Self { + let any = |predicate: &dyn Fn(&&Operation) -> bool| unary_ops.iter().any(predicate); + Self { + path: any(&|o| { + o.parameters + .iter() + .any(|p| p.location == ParameterLocation::Path) + }), + query: any(&|o| { + o.parameters + .iter() + .any(|p| p.location == ParameterLocation::Query) + }), + // Raw-request handlers extract the body as bytes themselves, + // so only non-raw unary operations pull in the Json extractor. + body_json: any(&|o| { + !o.is_raw_request() + && o.parameters + .iter() + .any(|p| p.location == ParameterLocation::Body) + }), + raw: any(&|o| o.is_raw_request()), + get: any(&|o| o.method == HttpMethod::Get), + post: any(&|o| o.method == HttpMethod::Post), + } + } +} + fn generate_http(definition: &ApiDefinition, config: &GenerateConfig) -> String { let mut out = generated_header("HTTP route handlers generated from the API definition"); out.push_str("use std::collections::BTreeMap;\n\n"); @@ -207,35 +267,19 @@ fn generate_http(definition: &ApiDefinition, config: &GenerateConfig) -> String let unary_ops: Vec<&Operation> = http_operations(definition) .filter(|operation| !operation.is_sse()) .collect(); - let any_path = unary_ops.iter().any(|o| { - o.parameters - .iter() - .any(|p| p.location == ParameterLocation::Path) - }); - let any_query = unary_ops.iter().any(|o| { - o.parameters - .iter() - .any(|p| p.location == ParameterLocation::Query) - }); - let any_body = unary_ops.iter().any(|o| { - o.parameters - .iter() - .any(|p| p.location == ParameterLocation::Body) - }); - let any_get = unary_ops.iter().any(|o| o.method == HttpMethod::Get); - let any_post = unary_ops.iter().any(|o| o.method == HttpMethod::Post); + let plan = HttpImportPlan::analyze(&unary_ops); let mut extractors = vec!["State"]; - if any_path { + if plan.path { extractors.push("Path"); } - if any_query { + if plan.query { extractors.push("Query"); } let mut methods = Vec::new(); - if any_get { + if plan.get { methods.push("get"); } - if any_post { + if plan.post { methods.push("post"); } push_fmt!( @@ -244,9 +288,15 @@ fn generate_http(definition: &ApiDefinition, config: &GenerateConfig) -> String extractors.join(", "), methods.join(", ") ); - if any_body { + if plan.body_json { out.push_str("use axum::Json;\n"); } + if plan.raw { + // Raw handlers take HeaderMap + Bytes directly; Bytes must come + // last so the body is fully buffered before extraction. + out.push_str("use axum::body::Bytes;\n"); + out.push_str("use axum::http::HeaderMap;\n"); + } out.push_str("use serde_json::Value;\n\n"); out.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n"); out.push_str("pub struct GeneratedRoute {\n"); @@ -260,6 +310,9 @@ fn generate_http(definition: &ApiDefinition, config: &GenerateConfig) -> String out.push_str(" pub query: BTreeMap,\n"); out.push_str(" pub body: Value,\n"); out.push_str("}\n\n"); + if plan.raw { + push_raw_input_struct(&mut out); + } out.push_str("pub const GENERATED_ROUTES: &[GeneratedRoute] = &[\n"); for operation in http_operations(definition).filter(|o| !o.is_sse()) { out.push_str(" GeneratedRoute { name: "); @@ -315,6 +368,10 @@ fn push_unary_handler(out: &mut String, operation: &Operation, config: &Generate .parameters .iter() .any(|parameter| parameter.location == ParameterLocation::Query); + if operation.is_raw_request() { + push_raw_handler(out, operation, config, has_path, has_query); + return; + } let has_body = operation .parameters .iter() @@ -365,6 +422,68 @@ fn push_unary_handler(out: &mut String, operation: &Operation, config: &Generate out.push_str("}\n"); } +/// Emit one raw-request axum handler: the request body arrives as exact +/// bytes with a header map, so consumers can verify signatures over the +/// wire representation. Path/query params still extract normally. +fn push_raw_handler( + out: &mut String, + operation: &Operation, + config: &GenerateConfig, + has_path: bool, + has_query: bool, +) { + out.push_str("async fn "); + out.push_str(&operation.name); + out.push_str("(\n"); + push_fmt!( + out, + " State(state): State<{}>,\n", + config.http_state_type + ); + if has_path { + out.push_str(" Path(path): Path>,\n"); + } + if has_query { + out.push_str(" Query(query): Query>,\n"); + } + // HeaderMap before Bytes: extractors run in declaration order and the + // body must be buffered last. + out.push_str(" headers: HeaderMap,\n"); + out.push_str(" raw_body: Bytes,\n"); + out.push_str(") -> Response {\n"); + out.push_str(" let headers: BTreeMap = headers\n"); + out.push_str(" .iter()\n"); + out.push_str(" .filter_map(|(name, value)| {\n"); + out.push_str(" value\n"); + out.push_str(" .to_str()\n"); + out.push_str(" .ok()\n"); + out.push_str(" .map(|value| (name.as_str().to_owned(), value.to_owned()))\n"); + out.push_str(" })\n"); + out.push_str(" .collect();\n"); + push_fmt!(out, " {}(\n", config.http_raw_dispatch_fn); + out.push_str(" &state,\n"); + out.push_str(" "); + out.push_str(&rust_string_literal(&operation.name)); + out.push_str(",\n"); + out.push_str(" GeneratedRawOperationInput {\n"); + if has_path { + out.push_str(" path,\n"); + } else { + out.push_str(" path: BTreeMap::new(),\n"); + } + if has_query { + out.push_str(" query,\n"); + } else { + out.push_str(" query: BTreeMap::new(),\n"); + } + out.push_str(" headers,\n"); + out.push_str(" raw_body: raw_body.to_vec(),\n"); + out.push_str(" },\n"); + out.push_str(" )\n"); + out.push_str(" .await\n"); + out.push_str("}\n"); +} + /// Emit the SSE surface: route metadata plus a named runtime binding hook per /// streaming operation. The handwritten server binds the actual handler, so /// no duplicate axum route exists in generated code. @@ -418,6 +537,21 @@ fn generate_sse_surface(definition: &ApiDefinition, config: &GenerateConfig) -> out } +/// Emit the `GeneratedRawOperationInput` struct, present only when the +/// definition contains raw-request operations. +fn push_raw_input_struct(out: &mut String) { + out.push_str("/// Input for raw-request operations: the exact wire bytes and\n"); + out.push_str("/// headers, for consumers that verify signatures over the\n"); + out.push_str("/// request as received.\n"); + out.push_str("#[derive(Debug, Clone, Default, PartialEq, Eq)]\n"); + out.push_str("pub struct GeneratedRawOperationInput {\n"); + out.push_str(" pub path: BTreeMap,\n"); + out.push_str(" pub query: BTreeMap,\n"); + out.push_str(" pub headers: BTreeMap,\n"); + out.push_str(" pub raw_body: Vec,\n"); + out.push_str("}\n\n"); +} + fn http_operations(definition: &ApiDefinition) -> impl Iterator { definition.operations.iter().filter(|o| o.generates_http()) } diff --git a/crates/hydra-codegen/tests/codegen.rs b/crates/hydra-codegen/tests/codegen.rs index 04135d4..33e09b2 100644 --- a/crates/hydra-codegen/tests/codegen.rs +++ b/crates/hydra-codegen/tests/codegen.rs @@ -24,6 +24,7 @@ fn sample_definition() -> ApiDefinition { delivery: Delivery::Unary, surfaces: None, cli_command: None, + raw_request: false, }], } } @@ -69,6 +70,7 @@ fn adding_operation_changes_every_surface() { delivery: Delivery::Unary, surfaces: None, cli_command: None, + raw_request: false, }); let after = generate_all(&definition, &GenerateConfig::default()); assert_ne!(before.cli_rs, after.cli_rs); @@ -101,6 +103,7 @@ fn sse_operations_are_excluded_from_mcp_and_unary_routes() { delivery: Delivery::Sse, surfaces: Some(vec![hydra_core::Surface::Http, hydra_core::Surface::Cli]), cli_command: Some("watch".into()), + raw_request: false, }); let artifacts = generate_all(&definition, &GenerateConfig::default()); assert!(!artifacts.mcp_json.contains("subscribe_events")); @@ -170,6 +173,152 @@ fn rejects_non_kebab_cli_command() { assert!(hydra_core::validate::validate_definition(&definition).is_err()); } +#[test] +fn raw_request_operation_generates_raw_handler() { + let mut definition = sample_definition(); + definition.operations.push(Operation { + name: "ingest_webhook".into(), + description: "Receive a signed webhook.".into(), + method: HttpMethod::Post, + path: "/hooks/ingest".into(), + read: false, + output_type: "Value".into(), + parameters: vec![], + delivery: Delivery::Unary, + surfaces: Some(vec![hydra_core::Surface::Http]), + cli_command: None, + raw_request: true, + }); + let artifacts = generate_all(&definition, &GenerateConfig::default()); + // Raw input struct emitted + assert!( + artifacts + .http_rs + .contains("pub struct GeneratedRawOperationInput") + ); + // Raw handler shape: HeaderMap + Bytes extractors, raw dispatch fn + assert!(artifacts.http_rs.contains("headers: HeaderMap,")); + assert!(artifacts.http_rs.contains("raw_body: Bytes,")); + assert!( + artifacts + .http_rs + .contains("super::execute_generated_raw_operation(") + ); + assert!(artifacts.http_rs.contains("raw_body: raw_body.to_vec(),")); + // Route registered under the raw handler + assert!( + artifacts + .http_rs + .contains(".route(\"/hooks/ingest\", post(ingest_webhook))") + ); + // Absent from CLI and MCP + assert!(!artifacts.cli_rs.contains("ingest_webhook")); + assert!(!artifacts.mcp_json.contains("ingest_webhook")); + // Default dispatch lane untouched for the regular operation + assert!( + artifacts + .http_rs + .contains("super::execute_generated_operation(") + ); +} + +#[test] +fn raw_request_operation_with_path_parameter_extracts_typed_path() { + let mut definition = sample_definition(); + definition.operations.push(Operation { + name: "ingest_hook".into(), + description: "Receive a signed webhook for a source.".into(), + method: HttpMethod::Post, + path: "/hooks/{source}/ingest".into(), + read: false, + output_type: "Value".into(), + parameters: vec![Parameter { + name: "source".into(), + description: "Hook source identifier.".into(), + ty: hydra_core::ParameterType::String, + required: true, + location: ParameterLocation::Path, + }], + delivery: Delivery::Unary, + surfaces: Some(vec![hydra_core::Surface::Http]), + cli_command: None, + raw_request: true, + }); + let artifacts = generate_all(&definition, &GenerateConfig::default()); + assert!( + artifacts + .http_rs + .contains(".route(\"/hooks/{source}/ingest\", post(ingest_hook))") + ); + // Path extractor present in the raw handler, body extraction absent + assert!( + artifacts + .http_rs + .contains("Path(path): Path>,\n headers: HeaderMap,") + ); + assert!(!artifacts.http_rs.contains("Json(body)")); +} + +#[test] +fn raw_request_byte_identical_when_flag_absent() { + // The core determinism claim for this feature (COD-402 acceptance): + // a definition with no raw_request operations must produce + // byte-identical HTTP output to the pre-feature generator. The + // fixture pair is the notes example as of v0.1.0 (f6ef2e6), before + // echo_raw/raw_request existed. + let expected = include_str!("fixtures/notes-pre-raw-http.rs"); + let definition: ApiDefinition = + serde_yaml::from_str(include_str!("fixtures/notes-pre-raw-operations.yaml")).unwrap(); + // Config as of v0.1.0 (the fixture's provenance). http_raw_dispatch_fn + // is deliberately unset: the new knob must not leak into output for + // definitions that don't use it. + let config = GenerateConfig { + http_dispatch_fn: "crate::execute_operation_http".to_string(), + http_state_type: "crate::AppState".to_string(), + sse_binding_prefix: "super::".to_string(), + ..GenerateConfig::default() + }; + let artifacts = generate_all(&definition, &config); + assert!(!artifacts.http_rs.contains("GeneratedRawOperationInput")); + assert!(!artifacts.http_rs.contains("HeaderMap")); + assert_eq!(artifacts.http_rs, expected); +} + +#[test] +fn rejects_raw_request_with_non_http_surface() { + let mut definition = sample_definition(); + definition.operations[0].raw_request = true; + // surfaces: None means all surfaces — raw must be exactly [http] + assert!(hydra_core::validate::validate_definition(&definition).is_err()); + definition.operations[0].surfaces = + Some(vec![hydra_core::Surface::Http, hydra_core::Surface::Mcp]); + assert!(hydra_core::validate::validate_definition(&definition).is_err()); + definition.operations[0].surfaces = Some(vec![hydra_core::Surface::Http]); + assert!(hydra_core::validate::validate_definition(&definition).is_ok()); +} + +#[test] +fn rejects_raw_request_with_sse_or_body_params() { + let mut definition = sample_definition(); + definition.operations[0].raw_request = true; + definition.operations[0].surfaces = Some(vec![hydra_core::Surface::Http]); + // body-location parameter is rejected (raw bytes replace Json body) + definition.operations[0].parameters.push(Parameter { + name: "payload".into(), + description: "Body payload.".into(), + ty: hydra_core::ParameterType::String, + required: true, + location: ParameterLocation::Body, + }); + assert!(hydra_core::validate::validate_definition(&definition).is_err()); + definition.operations[0].parameters.pop(); + // delivery: sse is rejected (raw is unary-only) + definition.operations[0].delivery = Delivery::Sse; + assert!(hydra_core::validate::validate_definition(&definition).is_err()); + definition.operations[0].delivery = Delivery::Unary; + assert!(hydra_core::validate::validate_definition(&definition).is_ok()); +} + #[test] fn committed_example_artifacts_are_current() { // Guards against editing generated/ by hand or forgetting `hydra write`. diff --git a/crates/hydra-codegen/tests/fixtures/notes-pre-raw-http.rs b/crates/hydra-codegen/tests/fixtures/notes-pre-raw-http.rs new file mode 100644 index 0000000..6140851 --- /dev/null +++ b/crates/hydra-codegen/tests/fixtures/notes-pre-raw-http.rs @@ -0,0 +1,118 @@ +// Code generated by hydra. DO NOT EDIT. +// HTTP route handlers generated from the API definition + +use std::collections::BTreeMap; + +use axum::{extract::{State, Path, Query}, response::Response, routing::{get, post}, Router}; +use axum::Json; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GeneratedRoute { + pub name: &'static str, + pub method: &'static str, + pub path: &'static str, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GeneratedOperationInput { + pub path: BTreeMap, + pub query: BTreeMap, + pub body: Value, +} + +pub const GENERATED_ROUTES: &[GeneratedRoute] = &[ + GeneratedRoute { name: "list_notes", method: "GET", path: "/notes" }, + GeneratedRoute { name: "get_note", method: "GET", path: "/notes/{note_id}" }, + GeneratedRoute { name: "create_note", method: "POST", path: "/notes" }, + GeneratedRoute { name: "delete_note", method: "POST", path: "/notes/{note_id}/delete" }, + GeneratedRoute { name: "note_stats", method: "GET", path: "/stats" }, +]; + +pub fn generated_router() -> Router { + Router::new() + .route("/notes", get(list_notes)) + .route("/notes/{note_id}", get(get_note)) + .route("/notes", post(create_note)) + .route("/notes/{note_id}/delete", post(delete_note)) + .route("/stats", get(note_stats)) +} + +async fn list_notes( + State(state): State, + Query(query): Query>, +) -> Response { + crate::execute_operation_http( + &state, + "list_notes", + GeneratedOperationInput { + path: BTreeMap::new(), + query, + body: Value::Null, + }, + ) + .await +} + +async fn get_note( + State(state): State, + Path(path): Path>, +) -> Response { + crate::execute_operation_http( + &state, + "get_note", + GeneratedOperationInput { + path, + query: BTreeMap::new(), + body: Value::Null, + }, + ) + .await +} + +async fn create_note( + State(state): State, + Json(body): Json, +) -> Response { + crate::execute_operation_http( + &state, + "create_note", + GeneratedOperationInput { + path: BTreeMap::new(), + query: BTreeMap::new(), + body, + }, + ) + .await +} + +async fn delete_note( + State(state): State, + Path(path): Path>, +) -> Response { + crate::execute_operation_http( + &state, + "delete_note", + GeneratedOperationInput { + path, + query: BTreeMap::new(), + body: Value::Null, + }, + ) + .await +} + +async fn note_stats( + State(state): State, +) -> Response { + crate::execute_operation_http( + &state, + "note_stats", + GeneratedOperationInput { + path: BTreeMap::new(), + query: BTreeMap::new(), + body: Value::Null, + }, + ) + .await +} diff --git a/crates/hydra-codegen/tests/fixtures/notes-pre-raw-operations.yaml b/crates/hydra-codegen/tests/fixtures/notes-pre-raw-operations.yaml new file mode 100644 index 0000000..b3e1852 --- /dev/null +++ b/crates/hydra-codegen/tests/fixtures/notes-pre-raw-operations.yaml @@ -0,0 +1,70 @@ +operations: + - name: list_notes + description: List notes, newest first. + method: GET + path: /notes + read: true + output_type: Vec + parameters: + - name: limit + description: Maximum number of notes to return. + type: u32 + required: false + location: query + - name: get_note + description: Get a single note by ID. + method: GET + path: /notes/{note_id} + read: true + output_type: Note + parameters: + - name: note_id + description: Note ID to fetch. + type: string + required: true + location: path + - name: create_note + description: Create a note with a title and body. + method: POST + path: /notes + read: false + output_type: Note + parameters: + - name: title + description: Note title. + type: string + required: true + location: body + - name: body + description: Note body text. + type: string + required: true + location: body + - name: delete_note + description: Delete a note by ID. + method: POST + path: /notes/{note_id}/delete + read: false + output_type: "()" + parameters: + - name: note_id + description: Note ID to delete. + type: string + required: true + location: path + - name: note_stats + description: Return total note count and character volume. + method: GET + path: /stats + read: true + output_type: Stats + parameters: [] + surfaces: [http, mcp] + - name: compact_notes + description: Internal compaction job; exposed on CLI only for operators. + method: POST + path: /internal/compact + read: false + output_type: "()" + parameters: [] + surfaces: [cli] diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs index 2f362d0..a841bcb 100644 --- a/crates/hydra-core/src/lib.rs +++ b/crates/hydra-core/src/lib.rs @@ -92,6 +92,15 @@ pub struct Operation { /// command differently from the operation. #[serde(default)] pub cli_command: Option, + /// Opt in to raw-request access on the HTTP surface. The generated + /// handler receives the exact raw body bytes and a header map instead + /// of decoded/typed extractors, for consumers that verify signatures + /// (e.g. webhook HMAC) over the wire representation. Default behavior + /// (flag absent) is unchanged. Raw-request operations must list `http` + /// as their only surface, stay unary, and declare no body-location + /// parameters. + #[serde(default)] + pub raw_request: bool, } /// Response delivery kind for a generated operation. @@ -124,6 +133,13 @@ impl Operation { matches!(self.delivery, Delivery::Sse) } + /// Whether this operation opts into raw-request access on the HTTP + /// surface (exact body bytes + headers instead of typed extraction). + #[must_use] + pub const fn is_raw_request(&self) -> bool { + self.raw_request + } + /// Whether the HTTP surface is generated for this operation. #[must_use] pub fn generates_http(&self) -> bool { diff --git a/crates/hydra-core/src/validate.rs b/crates/hydra-core/src/validate.rs index 3be07b2..bce5448 100644 --- a/crates/hydra-core/src/validate.rs +++ b/crates/hydra-core/src/validate.rs @@ -52,6 +52,7 @@ pub fn validate_definition(definition: &ApiDefinition) -> Result<()> { ); validate_operation_parameters(operation)?; validate_operation_surfaces(operation)?; + validate_operation_raw_request(operation)?; } // CLI command overrides must not collide with any generated subcommand. @@ -123,6 +124,43 @@ fn validate_operation_parameters(operation: &Operation) -> Result<()> { Ok(()) } +/// Validate the raw-request escape hatch: it is an HTTP-surface-only +/// feature, so raw operations must not appear on CLI or MCP, must not +/// stream, and must not declare body-location parameters (the raw bytes +/// replace JSON body extraction). +fn validate_operation_raw_request(operation: &Operation) -> Result<()> { + if !operation.is_raw_request() { + return Ok(()); + } + // `surfaces: None` means "all surfaces"; raw request access is an + // HTTP-only escape hatch, so the allowlist must be exactly [http]. + anyhow::ensure!( + operation + .surfaces + .as_ref() + .is_some_and(|surfaces| surfaces.len() == 1 && surfaces.contains(&Surface::Http)), + "operation {} uses raw_request but does not list exactly the http surface; \ + raw request access is http-only", + operation.name + ); + anyhow::ensure!( + !operation.is_sse(), + "operation {} uses raw_request with delivery: sse; \ + raw request access is unary-only", + operation.name + ); + anyhow::ensure!( + operation + .parameters + .iter() + .all(|parameter| parameter.location != ParameterLocation::Body), + "operation {} uses raw_request but declares a body-location parameter; \ + the raw body bytes replace JSON body extraction", + operation.name + ); + Ok(()) +} + /// Validate surface allowlists, delivery-kind rules, and CLI command names. fn validate_operation_surfaces(operation: &Operation) -> Result<()> { if let Some(surfaces) = &operation.surfaces { diff --git a/examples/notes/api/operations.yaml b/examples/notes/api/operations.yaml index b3e1852..5bb45e7 100644 --- a/examples/notes/api/operations.yaml +++ b/examples/notes/api/operations.yaml @@ -68,3 +68,12 @@ operations: output_type: "()" parameters: [] surfaces: [cli] + - name: echo_raw + description: Echo the exact raw request bytes and headers; webhook-style ingress reference. + method: POST + path: /hooks/echo + read: false + output_type: Value + parameters: [] + surfaces: [http] + raw_request: true diff --git a/examples/notes/generated/http.rs b/examples/notes/generated/http.rs index 6140851..049c9e5 100644 --- a/examples/notes/generated/http.rs +++ b/examples/notes/generated/http.rs @@ -5,6 +5,8 @@ use std::collections::BTreeMap; use axum::{extract::{State, Path, Query}, response::Response, routing::{get, post}, Router}; use axum::Json; +use axum::body::Bytes; +use axum::http::HeaderMap; use serde_json::Value; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -21,12 +23,24 @@ pub struct GeneratedOperationInput { pub body: Value, } +/// Input for raw-request operations: the exact wire bytes and +/// headers, for consumers that verify signatures over the +/// request as received. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GeneratedRawOperationInput { + pub path: BTreeMap, + pub query: BTreeMap, + pub headers: BTreeMap, + pub raw_body: Vec, +} + pub const GENERATED_ROUTES: &[GeneratedRoute] = &[ GeneratedRoute { name: "list_notes", method: "GET", path: "/notes" }, GeneratedRoute { name: "get_note", method: "GET", path: "/notes/{note_id}" }, GeneratedRoute { name: "create_note", method: "POST", path: "/notes" }, GeneratedRoute { name: "delete_note", method: "POST", path: "/notes/{note_id}/delete" }, GeneratedRoute { name: "note_stats", method: "GET", path: "/stats" }, + GeneratedRoute { name: "echo_raw", method: "POST", path: "/hooks/echo" }, ]; pub fn generated_router() -> Router { @@ -36,6 +50,7 @@ pub fn generated_router() -> Router { .route("/notes", post(create_note)) .route("/notes/{note_id}/delete", post(delete_note)) .route("/stats", get(note_stats)) + .route("/hooks/echo", post(echo_raw)) } async fn list_notes( @@ -116,3 +131,30 @@ async fn note_stats( ) .await } + +async fn echo_raw( + State(state): State, + headers: HeaderMap, + raw_body: Bytes, +) -> Response { + let headers: BTreeMap = headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect(); + crate::execute_operation_raw_http( + &state, + "echo_raw", + GeneratedRawOperationInput { + path: BTreeMap::new(), + query: BTreeMap::new(), + headers, + raw_body: raw_body.to_vec(), + }, + ) + .await +} diff --git a/examples/notes/hydra.yaml b/examples/notes/hydra.yaml index ae32c52..c9b69cc 100644 --- a/examples/notes/hydra.yaml +++ b/examples/notes/hydra.yaml @@ -3,4 +3,6 @@ # crate-local paths. http_dispatch_fn: "crate::execute_operation_http" http_state_type: "crate::AppState" +# Raw-request operations (raw_request: true) dispatch here instead. +http_raw_dispatch_fn: "crate::execute_operation_raw_http" sse_binding_prefix: "super::" diff --git a/examples/notes/src/lib.rs b/examples/notes/src/lib.rs index 7f82add..7b8209a 100644 --- a/examples/notes/src/lib.rs +++ b/examples/notes/src/lib.rs @@ -230,6 +230,39 @@ pub async fn execute_operation_http( } } +/// HTTP adapter for raw-request operations: the wire bytes and headers +/// arrive untouched, so signature verification over the exact received +/// representation is possible. +/// +/// This example echoes them back as JSON. +#[allow(clippy::unused_async)] +pub async fn execute_operation_raw_http( + state: &AppState, + operation: &str, + input: generated::GeneratedRawOperationInput, +) -> axum::response::Response { + match operation { + "echo_raw" => axum::Json(serde_json::json!({ + "bytes": input.raw_body, + "bytes_len": input.raw_body.len(), + "headers": input.headers, + })) + .into_response(), + _ => { + execute_operation_http( + state, + operation, + generated::GeneratedOperationInput { + path: input.path, + query: input.query, + body: Value::Null, + }, + ) + .await + } + } +} + // ── Surface wiring ──────────────────────────────────────────────────────── /// Build the full HTTP router for the notes service. diff --git a/examples/notes/tests/surfaces.rs b/examples/notes/tests/surfaces.rs index c656173..8042dcd 100644 --- a/examples/notes/tests/surfaces.rs +++ b/examples/notes/tests/surfaces.rs @@ -155,3 +155,62 @@ async fn stats_available_on_http_but_not_cli() { let body_value = body_json(res).await; assert_eq!(body_value["count"], json!(2)); } + +#[tokio::test] +async fn raw_request_delivers_exact_wire_bytes_and_headers() { + // The COD-402 acceptance criterion, proven live: a raw-request + // operation receives the exact bytes and headers as sent — including + // a non-UTF8-safe payload that typed Json extraction would mangle or + // reject, and header casing preserved as received. + let state = AppState::with_fixtures(); + let payload: &[u8] = &[ + 0x7b, 0x22, 0x61, 0x22, 0x3a, 0x31, 0x2c, 0x22, 0x62, 0x22, 0x3a, 0x32, + 0x7d, // {"a":1,"b":2} + 0xff, 0xfe, 0x00, // trailing bytes that are NOT valid UTF-8 + ]; + let res = http( + &state, + Request::post("/hooks/echo") + .header("content-type", "application/json") + .header("x-webhook-signature", "sha256=deadbeef") + .header("x-multi", "one") + .header("x-multi", "two") + .body(Body::from(payload.to_vec())) + .unwrap(), + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + let body = body_json(res).await; + + // Byte-exactness: the echoed bytes equal the wire bytes verbatim. + assert_eq!(body["bytes_len"], json!(payload.len())); + let echoed: Vec = body["bytes"] + .as_array() + .unwrap() + .iter() + .map(|v| u8::try_from(v.as_u64().unwrap_or(u64::MAX)).unwrap_or(u8::MAX)) + .collect(); + assert_eq!(echoed, payload.to_vec()); + + // Headers arrive with values intact (single-valued header). + assert_eq!( + body["headers"]["x-webhook-signature"], + json!("sha256=deadbeef") + ); + assert_eq!(body["headers"]["content-type"], json!("application/json")); + // Multi-valued headers collapse to one entry in the BTreeMap; the last + // value seen wins. The contract is "a header map", not multi-map. + assert_eq!(body["headers"]["x-multi"], json!("two")); +} + +#[tokio::test] +async fn raw_request_route_absent_from_cli_and_mcp() { + // echo_raw lists surfaces: [http] only — the generated CLI enum and MCP + // schema must not mention it. + let state = AppState::with_fixtures(); + let _ = state; + let cli_rs = include_str!("../generated/cli.rs"); + let mcp_json = include_str!("../generated/mcp.json"); + assert!(!cli_rs.contains("echo_raw") && !cli_rs.contains("EchoRaw")); + assert!(!mcp_json.contains("echo_raw")); +} From e4c3ed17ea3d145bf960e95cf02792e6d9559381 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Wed, 19 Aug 2026 00:23:57 -0600 Subject: [PATCH 2/2] docs: address review panel findings for raw-request (COD-402) - Document header-map contract (lowercased names, non-UTF-8 values dropped, repeated headers last-wins) on the field, the generated struct, and README - Fix misleading header-casing comment in surfaces test - Add raw-request section + http_raw_dispatch_fn knob to README - Drop unused AppState/tokio from the structural absence test --- README.md | 28 +++++++++++++++++++++++++++ crates/hydra-codegen/src/lib.rs | 13 ++++++++++--- crates/hydra-codegen/tests/codegen.rs | 6 +++--- crates/hydra-core/src/lib.rs | 3 ++- examples/notes/generated/http.rs | 10 +++++++--- examples/notes/tests/surfaces.rs | 8 +++----- 6 files changed, 53 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3e28f78..38f7a07 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ operations: ```yaml http_dispatch_fn: "crate::execute_operation_http" http_state_type: "crate::AppState" +# Only needed if the definition has raw_request operations: +# http_raw_dispatch_fn: "crate::execute_operation_raw_http" ``` 3. Generate and commit: @@ -75,6 +77,32 @@ cargo run -p hydra-codegen -- check # CI guard: fails if artifacts are stale 4. `include!` the generated files, implement one dispatch function, and wire your binaries. See `examples/notes/src/lib.rs`. +## Raw-request (webhook) operations + +Operations that must see the exact wire representation — signature-verified +webhooks, for example — opt in with `raw_request: true`. The generated HTTP +handler receives the raw body bytes and a header map instead of typed +extraction, and dispatches to `http_raw_dispatch_fn`: + +```yaml +- name: receive_webhook + description: Receive a signed webhook payload. + method: POST + path: /hooks/github + read: false + output_type: Value + parameters: [] + surfaces: [http] + raw_request: true +``` + +Rules: `http` must be the only listed surface, the operation is unary (no +SSE), and body-location parameters are rejected — the raw bytes replace +JSON body extraction. The header map lowercases names, drops non-UTF-8 +values, and collapses repeated headers to the last value. Definitions +without raw operations generate byte-identical output to before the flag +existed. + ## Design rules - **No inference.** Method, path, parameter locations, and surface diff --git a/crates/hydra-codegen/src/lib.rs b/crates/hydra-codegen/src/lib.rs index f35366c..cd3aa42 100644 --- a/crates/hydra-codegen/src/lib.rs +++ b/crates/hydra-codegen/src/lib.rs @@ -539,10 +539,17 @@ fn generate_sse_surface(definition: &ApiDefinition, config: &GenerateConfig) -> /// Emit the `GeneratedRawOperationInput` struct, present only when the /// definition contains raw-request operations. +/// +/// The emitted doc notes the header-map contract: names lowercased, +/// non-UTF-8 values dropped, repeated headers last-wins. fn push_raw_input_struct(out: &mut String) { - out.push_str("/// Input for raw-request operations: the exact wire bytes and\n"); - out.push_str("/// headers, for consumers that verify signatures over the\n"); - out.push_str("/// request as received.\n"); + out.push_str("/// Input for raw-request operations: the exact raw body bytes\n"); + out.push_str("/// and a header map, for consumers that verify signatures over\n"); + out.push_str("/// the request as received.\n"); + out.push_str("///\n"); + out.push_str("/// Header contract: names are lowercase (HTTP canonical form),\n"); + out.push_str("/// values must be UTF-8 (non-UTF-8 values are dropped), and\n"); + out.push_str("/// repeated headers collapse to the last value.\n"); out.push_str("#[derive(Debug, Clone, Default, PartialEq, Eq)]\n"); out.push_str("pub struct GeneratedRawOperationInput {\n"); out.push_str(" pub path: BTreeMap,\n"); diff --git a/crates/hydra-codegen/tests/codegen.rs b/crates/hydra-codegen/tests/codegen.rs index 33e09b2..d9a2c6f 100644 --- a/crates/hydra-codegen/tests/codegen.rs +++ b/crates/hydra-codegen/tests/codegen.rs @@ -269,9 +269,9 @@ fn raw_request_byte_identical_when_flag_absent() { let expected = include_str!("fixtures/notes-pre-raw-http.rs"); let definition: ApiDefinition = serde_yaml::from_str(include_str!("fixtures/notes-pre-raw-operations.yaml")).unwrap(); - // Config as of v0.1.0 (the fixture's provenance). http_raw_dispatch_fn - // is deliberately unset: the new knob must not leak into output for - // definitions that don't use it. + // Config as of v0.1.0 (the fixture's provenance). The raw dispatch + // knob is left at its default: it must not leak into output for + // definitions that don't use raw operations. let config = GenerateConfig { http_dispatch_fn: "crate::execute_operation_http".to_string(), http_state_type: "crate::AppState".to_string(), diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs index a841bcb..9aaddbb 100644 --- a/crates/hydra-core/src/lib.rs +++ b/crates/hydra-core/src/lib.rs @@ -98,7 +98,8 @@ pub struct Operation { /// (e.g. webhook HMAC) over the wire representation. Default behavior /// (flag absent) is unchanged. Raw-request operations must list `http` /// as their only surface, stay unary, and declare no body-location - /// parameters. + /// parameters. The header map lowercases names, drops non-UTF-8 + /// values, and collapses repeated headers to the last value. #[serde(default)] pub raw_request: bool, } diff --git a/examples/notes/generated/http.rs b/examples/notes/generated/http.rs index 049c9e5..a2ba99d 100644 --- a/examples/notes/generated/http.rs +++ b/examples/notes/generated/http.rs @@ -23,9 +23,13 @@ pub struct GeneratedOperationInput { pub body: Value, } -/// Input for raw-request operations: the exact wire bytes and -/// headers, for consumers that verify signatures over the -/// request as received. +/// Input for raw-request operations: the exact raw body bytes +/// and a header map, for consumers that verify signatures over +/// the request as received. +/// +/// Header contract: names are lowercase (HTTP canonical form), +/// values must be UTF-8 (non-UTF-8 values are dropped), and +/// repeated headers collapse to the last value. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct GeneratedRawOperationInput { pub path: BTreeMap, diff --git a/examples/notes/tests/surfaces.rs b/examples/notes/tests/surfaces.rs index 8042dcd..cb5fb80 100644 --- a/examples/notes/tests/surfaces.rs +++ b/examples/notes/tests/surfaces.rs @@ -161,7 +161,7 @@ async fn raw_request_delivers_exact_wire_bytes_and_headers() { // The COD-402 acceptance criterion, proven live: a raw-request // operation receives the exact bytes and headers as sent — including // a non-UTF8-safe payload that typed Json extraction would mangle or - // reject, and header casing preserved as received. + // reject. Header names arrive lowercased (HTTP canonical form). let state = AppState::with_fixtures(); let payload: &[u8] = &[ 0x7b, 0x22, 0x61, 0x22, 0x3a, 0x31, 0x2c, 0x22, 0x62, 0x22, 0x3a, 0x32, @@ -203,12 +203,10 @@ async fn raw_request_delivers_exact_wire_bytes_and_headers() { assert_eq!(body["headers"]["x-multi"], json!("two")); } -#[tokio::test] -async fn raw_request_route_absent_from_cli_and_mcp() { +#[test] +fn raw_request_route_absent_from_cli_and_mcp() { // echo_raw lists surfaces: [http] only — the generated CLI enum and MCP // schema must not mention it. - let state = AppState::with_fixtures(); - let _ = state; let cli_rs = include_str!("../generated/cli.rs"); let mcp_json = include_str!("../generated/mcp.json"); assert!(!cli_rs.contains("echo_raw") && !cli_rs.contains("EchoRaw"));