diff --git a/src/adapters/forward.rs b/src/adapters/forward.rs index cb5a4b6..11a38c0 100644 --- a/src/adapters/forward.rs +++ b/src/adapters/forward.rs @@ -122,6 +122,7 @@ use std::collections::HashMap; use std::sync::Arc; +use alkcall::client::AdapterError; use alkcall::protocol::wire::{CallError, ResponseEnvelope}; use alkcall::registry::context::OperationContext; use alkcall::registry::registration::ResponseStream; @@ -176,6 +177,35 @@ pub(crate) const GATEWAY_BODY_KEY: &str = "body"; pub(crate) const HEADER_PARAM_IN_MARKER: &str = "wire"; pub(crate) const HEADER_PARAM_MARKER_VALUE: &str = "header"; +/// Import-time path-template validation shared by `from_jsonschema` +/// (review 001 OAI-09) and `from_openapi` (review 002 JS-02): every +/// `{placeholder}` must terminate with a `}` and carry a name. A +/// template that slips through (e.g. `/x{open`) otherwise surfaces as a +/// per-call `INTERNAL` error on first invoke — the eager-validation +/// promise both adapters make. +pub(crate) fn validate_path_template(path_template: &str) -> Result<(), AdapterError> { + let mut rest = path_template; + while let Some(start) = rest.find('{') { + let Some(end_rel) = rest[start..].find('}') else { + return Err(AdapterError::SchemaParse { + message: format!("path template `{path_template}` has an unterminated placeholder"), + }); + }; + if rest[start + 1..start + end_rel].is_empty() { + return Err(AdapterError::SchemaParse { + message: format!("path template `{path_template}` has an empty placeholder name"), + }); + } + rest = &rest[start + end_rel + 1..]; + } + if path_template.contains('}') && !path_template.contains('{') { + return Err(AdapterError::SchemaParse { + message: format!("path template `{path_template}` has `}}` without a matching `{{`"), + }); + } + Ok(()) +} + /// The credential scheme forwarded handlers apply to outbound requests /// (ADR-014). The credential value itself flows through /// `OperationContext.capabilities` at call time — never through this diff --git a/src/adapters/from_jsonschema.rs b/src/adapters/from_jsonschema.rs index dc8dd93..8870fff 100644 --- a/src/adapters/from_jsonschema.rs +++ b/src/adapters/from_jsonschema.rs @@ -32,7 +32,7 @@ use async_trait::async_trait; use reqwest::Method; use serde_json::Value; -use super::forward::{forward, forward_stream, HttpServiceConfig}; +use super::forward::{forward, forward_stream, validate_path_template, HttpServiceConfig}; use crate::client::SharedHttpClient; /// The HTTP-backed single-endpoint adapter (ADR-066): one caller-built @@ -139,30 +139,6 @@ fn validate_method(method: &str) -> Result<(), AdapterError> { })?; Ok(()) } - -fn validate_path_template(path_template: &str) -> Result<(), AdapterError> { - let mut rest = path_template; - while let Some(start) = rest.find('{') { - let Some(end_rel) = rest[start..].find('}') else { - return Err(AdapterError::SchemaParse { - message: format!("path template `{path_template}` has an unterminated placeholder"), - }); - }; - if rest[start + 1..start + end_rel].is_empty() { - return Err(AdapterError::SchemaParse { - message: format!("path template `{path_template}` has an empty placeholder name"), - }); - } - rest = &rest[start + end_rel + 1..]; - } - if path_template.contains('}') && !path_template.contains('{') { - return Err(AdapterError::SchemaParse { - message: format!("path template `{path_template}` has `}}` without a matching `{{`"), - }); - } - Ok(()) -} - fn spec_name_references_undeclared(spec: &OperationSpec, path_template: &str) -> bool { let properties = spec .input_schema diff --git a/src/adapters/from_openapi.rs b/src/adapters/from_openapi.rs index 4f9de5a..f3b638d 100644 --- a/src/adapters/from_openapi.rs +++ b/src/adapters/from_openapi.rs @@ -27,8 +27,8 @@ use async_trait::async_trait; use serde_json::Value; use super::forward::{ - forward, forward_stream, HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER, - HEADER_PARAM_MARKER_VALUE, + forward, forward_stream, validate_path_template, HttpServiceConfig, GATEWAY_BODY_KEY, + HEADER_PARAM_IN_MARKER, HEADER_PARAM_MARKER_VALUE, }; use super::openapi_spec::{OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS}; use crate::client::SharedHttpClient; @@ -310,6 +310,7 @@ impl FromOpenAPI { op: &Operation, path_parameters: &[Parameter], ) -> Result { + validate_path_template(path)?; let name = Self::normalize_operation_id(op, method, path); let qualified_name = format!("{}/{name}", self.config.namespace); let op_type = Self::detect_op_type(method, op); @@ -810,6 +811,32 @@ mod tests { } } + #[tokio::test] + async fn unterminated_path_template_fails_import_not_first_call() { + let doc = r#"{ + "openapi":"3.0.0","info":{"title":"T","version":"1"}, + "paths":{"/x{open":{"get":{"operationId":"x","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}} + }"#; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let result = adapter(spec, config("ns", "https://x", None)) + .import() + .await; + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("unterminated placeholder"), + "message was: {message}" + ); + assert!(message.contains("/x{open"), "message was: {message}"); + } + Ok(bundles) => panic!( + "unterminated path template must fail at import (JS-02), got {} bundles", + bundles.len() + ), + Err(e) => panic!("expected SchemaParse, got {e}"), + } + } + #[tokio::test] async fn op_type_detection() { let get_doc = r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/g":{"get":{"operationId":"g","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}}}"#;