Merge branch 'wt/review-002-import-loudness-cluster'
This commit is contained in:
@@ -118,6 +118,58 @@ pub enum HttpAuthScheme {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Loud unsupported-feature handling (the OAI-06 matrix, review 001/002)
|
||||||
|
|
||||||
|
Features of an imported OpenAPI document that the single-endpoint HTTP
|
||||||
|
adapter deliberately does not model are handled **loudly at import** —
|
||||||
|
refused with a feature-naming `SchemaParse` error, projected with a
|
||||||
|
documented mapping, skipped with a `tracing::warn`, or (where semantics
|
||||||
|
are faithfully preserved) accepted silently. Nothing that would change
|
||||||
|
the wire contract may vanish silently. The matrix, as landed:
|
||||||
|
|
||||||
|
**Refused at import (import fails; the error names the feature, its
|
||||||
|
location, the remediation, and the review item):**
|
||||||
|
|
||||||
|
| Feature | Where checked | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `in: cookie` parameters | operation + path-item parse | OAI-03 |
|
||||||
|
| Non-default parameter `style`/`serialize` forms (`spaceDelimited`, `pipeDelimited`, `deepObject`, `matrix`, `label`, `form+explode:false`, `simple+explode:true`) | operation + path-item parse; wire-equivalent defaults (`form`, `simple`) accepted | OAI-06 |
|
||||||
|
| `servers` overrides (document/path/operation level) — single `base_url` per import | shared `from_value` parse | OAI-06 |
|
||||||
|
| `webhooks` (anywhere in the document) | shared `from_value` parse | OAI-13 |
|
||||||
|
| `callbacks` (document/operation level) | import gate (`validate_import_loud_features`), not the shared parse — the published gateway doc's own `security` markers must round-trip inside `to_openapi` | OAI-14 |
|
||||||
|
| OpenAPI `security` requirements (document/operation level) — credentials come only from `Capabilities` + the configured `auth` scheme | import gate | OAI-14 |
|
||||||
|
| Top-level `oneOf` requestBodies (no `content` map — the media-typed body contract is unrepresentable) | operation parse | OAI-14 |
|
||||||
|
| Unresolvable parameter/`requestBody` `$ref`s; a resolved `requestBody` that still carries a top-level `$ref` or lacks `content` (a body-less op would fail every call on the gateway `body` input) | operation parse | OAI-04/OAI-15 |
|
||||||
|
| Unterminated/empty path-template placeholders (`/x{open`) | shared `validate_path_template`, both `from_openapi` import and `from_jsonschema` construction | OAI-09, JS-02 |
|
||||||
|
| Duplicate operationIds or `(path, method)` routes in one import batch | `reject_collisions` | OAI-05 |
|
||||||
|
| A declared `in: header` parameter named `Authorization` when the namespace has an auth scheme; a header parameter colliding (case-insensitively) with a configured `default_headers` key or an ApiKey `header_name` — `build_request` inserts defaults and credentials after header params, so the peer value would silently lose | `check_header_param_collisions`, import time (the adapter's `HttpServiceConfig` is visible there) | OAI-19 |
|
||||||
|
|
||||||
|
**Warned (import proceeds; the divergence is visible in logs):**
|
||||||
|
|
||||||
|
| Feature | Behavior | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `$ref` sibling keys — 3.0 semantics apply (ignored); a 3.1-authored constraint beside a `$ref` would otherwise silently overstate `/schema` | `tracing::warn` naming the location and dropped keys; no `openapi: 3.1` version gate — the 3.0-only reading is the documented stance | OAI-10 |
|
||||||
|
| Path items declaring only unsupported methods (`trace`) | skipped; `tracing::warn` names the path and methods | OAI-06 |
|
||||||
|
| `discriminator` / `xml` keywords inside consumed schemas | per-operation `tracing::warn` listing the keys — the adapter forwards JSON only | OAI-14 |
|
||||||
|
| Response keys that are neither concrete statuses nor 2XX/4XX/5XX class wildcards | dropped from the imported error schemas; unmapped statuses surface as synthesized `HTTP_<actual>` at call time | OAI-06 |
|
||||||
|
| Path-item-level unknown keys on a method-bearing path (mirroring OAI-06's trace-skip warn) | warn | OAI-13 |
|
||||||
|
|
||||||
|
**Projected (documented, tested mapping):**
|
||||||
|
|
||||||
|
| Feature | Behavior | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Error response class wildcards `4XX` / `5XX` | projected onto the first legal concrete status in the implied range (`HTTP_400` / `HTTP_500`), carrying the wildcard's payload schema; `default` is dropped (no implied range, never advertised as `HTTP_0`) | OAI-13 |
|
||||||
|
| Success envelopes declared under any 2XX key, the `2XX` wildcard, or `default` | precedence: concrete 2XX → `2XX` → `default`; SSE detection and output-schema selection follow the winner | OAI-06, OAI-13 |
|
||||||
|
| Response keys that are neither concrete statuses nor supported wildcards | dropped with `tracing::warn` (see above) | OAI-06 |
|
||||||
|
|
||||||
|
**Documented divergence (both entry points, tested):** the YAML/JSON
|
||||||
|
parity contract lives in ADR-051 §5 (duplicate keys rejected on the
|
||||||
|
YAML path, non-finite floats and non-string keys rejected with JSON
|
||||||
|
pointers, merge keys applied) — import errors quote at most the first 8
|
||||||
|
items of any spec-derived list and truncate each item at 128 chars
|
||||||
|
(OAI-17), so a pathological document cannot produce a
|
||||||
|
multi-megabyte error message.
|
||||||
|
|
||||||
The adapter:
|
The adapter:
|
||||||
|
|
||||||
1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `components`,
|
1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `components`,
|
||||||
|
|||||||
+69
-2
@@ -122,6 +122,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use alkcall::client::AdapterError;
|
||||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
use alkcall::registry::context::OperationContext;
|
use alkcall::registry::context::OperationContext;
|
||||||
use alkcall::registry::registration::ResponseStream;
|
use alkcall::registry::registration::ResponseStream;
|
||||||
@@ -176,6 +177,72 @@ pub(crate) const GATEWAY_BODY_KEY: &str = "body";
|
|||||||
pub(crate) const HEADER_PARAM_IN_MARKER: &str = "wire";
|
pub(crate) const HEADER_PARAM_IN_MARKER: &str = "wire";
|
||||||
pub(crate) const HEADER_PARAM_MARKER_VALUE: &str = "header";
|
pub(crate) const HEADER_PARAM_MARKER_VALUE: &str = "header";
|
||||||
|
|
||||||
|
/// Upper bound on how many list items an adapter error message echoes
|
||||||
|
/// (review 002 OAI-17), and the per-item string cap. A spec-derived list
|
||||||
|
/// (servers locations, placeholder names, declared keys) can be
|
||||||
|
/// arbitrarily large; an error echoing all of it turns a 100k-path
|
||||||
|
/// document into a multi-megabyte message. The shape is "first N + count
|
||||||
|
/// of the rest".
|
||||||
|
pub(crate) const ERROR_LIST_ITEMS: usize = 8;
|
||||||
|
pub(crate) const ERROR_ITEM_STRING_CAP: usize = 128;
|
||||||
|
|
||||||
|
/// Joins list items into an error-message fragment bounded in both item
|
||||||
|
/// count and item width: at most [`ERROR_LIST_ITEMS`] entries, each
|
||||||
|
/// truncated to [`ERROR_ITEM_STRING_CAP`] chars with a `…` marker, plus
|
||||||
|
/// a `, … (+N more)` suffix naming how many were suppressed.
|
||||||
|
pub(crate) fn bounded_join(items: &[String]) -> String {
|
||||||
|
let shown: Vec<String> = items
|
||||||
|
.iter()
|
||||||
|
.take(ERROR_LIST_ITEMS)
|
||||||
|
.map(|item| {
|
||||||
|
if item.chars().count() > ERROR_ITEM_STRING_CAP {
|
||||||
|
let truncated: String = item.chars().take(ERROR_ITEM_STRING_CAP).collect();
|
||||||
|
format!("{truncated}…")
|
||||||
|
} else {
|
||||||
|
item.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if items.len() > ERROR_LIST_ITEMS {
|
||||||
|
format!(
|
||||||
|
"{}, … (+{} more)",
|
||||||
|
shown.join(", "),
|
||||||
|
items.len() - ERROR_LIST_ITEMS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
shown.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// The credential scheme forwarded handlers apply to outbound requests
|
||||||
/// (ADR-014). The credential value itself flows through
|
/// (ADR-014). The credential value itself flows through
|
||||||
/// `OperationContext.capabilities` at call time — never through this
|
/// `OperationContext.capabilities` at call time — never through this
|
||||||
@@ -417,7 +484,7 @@ fn enforce_input_schema(
|
|||||||
let declared_list = if declared.is_empty() {
|
let declared_list = if declared.is_empty() {
|
||||||
"none".to_string()
|
"none".to_string()
|
||||||
} else {
|
} else {
|
||||||
declared.join(", ")
|
bounded_join(&declared)
|
||||||
};
|
};
|
||||||
return Err(CallError::invalid_input(format!(
|
return Err(CallError::invalid_input(format!(
|
||||||
"input key `{first}` is not declared by the operation's input schema \
|
"input key `{first}` is not declared by the operation's input schema \
|
||||||
@@ -591,7 +658,7 @@ pub(crate) fn render_path_template(
|
|||||||
if !unresolved.is_empty() {
|
if !unresolved.is_empty() {
|
||||||
return Err(CallError::internal(format!(
|
return Err(CallError::internal(format!(
|
||||||
"path template `{template}` references unbound placeholder(s): {}",
|
"path template `{template}` references unbound placeholder(s): {}",
|
||||||
unresolved.join(", ")
|
bounded_join(&unresolved)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ use async_trait::async_trait;
|
|||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde_json::Value;
|
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;
|
use crate::client::SharedHttpClient;
|
||||||
|
|
||||||
/// The HTTP-backed single-endpoint adapter (ADR-066): one caller-built
|
/// The HTTP-backed single-endpoint adapter (ADR-066): one caller-built
|
||||||
@@ -139,30 +139,6 @@ fn validate_method(method: &str) -> Result<(), AdapterError> {
|
|||||||
})?;
|
})?;
|
||||||
Ok(())
|
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 {
|
fn spec_name_references_undeclared(spec: &OperationSpec, path_template: &str) -> bool {
|
||||||
let properties = spec
|
let properties = spec
|
||||||
.input_schema
|
.input_schema
|
||||||
|
|||||||
+231
-10
@@ -27,10 +27,12 @@ use async_trait::async_trait;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::forward::{
|
use super::forward::{
|
||||||
forward, forward_stream, HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER,
|
bounded_join, forward, forward_stream, validate_path_template, HttpAuthScheme,
|
||||||
HEADER_PARAM_MARKER_VALUE,
|
HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER, HEADER_PARAM_MARKER_VALUE,
|
||||||
|
};
|
||||||
|
use super::openapi_spec::{
|
||||||
|
collect_ignored_schema_keys, OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS,
|
||||||
};
|
};
|
||||||
use super::openapi_spec::{OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS};
|
|
||||||
use crate::client::SharedHttpClient;
|
use crate::client::SharedHttpClient;
|
||||||
|
|
||||||
fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec<String> {
|
fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec<String> {
|
||||||
@@ -56,6 +58,16 @@ fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec<String
|
|||||||
unbound
|
unbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn oai14_ignored_keys(schemas: &[&Value]) -> Vec<String> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for schema in schemas {
|
||||||
|
collect_ignored_schema_keys(schema, &mut found);
|
||||||
|
}
|
||||||
|
found.sort();
|
||||||
|
found.dedup();
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
fn collision_message(batch: &str, kind: &str, first_path: &str) -> AdapterError {
|
fn collision_message(batch: &str, kind: &str, first_path: &str) -> AdapterError {
|
||||||
AdapterError::SchemaParse {
|
AdapterError::SchemaParse {
|
||||||
message: format!(
|
message: format!(
|
||||||
@@ -71,8 +83,6 @@ fn reject_collisions(
|
|||||||
paths: Vec<String>,
|
paths: Vec<String>,
|
||||||
routes: Vec<(String, String)>,
|
routes: Vec<(String, String)>,
|
||||||
) -> Result<(), AdapterError> {
|
) -> Result<(), AdapterError> {
|
||||||
assert_eq!(op_ids.len(), routes.len());
|
|
||||||
assert_eq!(op_ids.len(), paths.len());
|
|
||||||
let mut seen_names: HashMap<&str, &str> = HashMap::new();
|
let mut seen_names: HashMap<&str, &str> = HashMap::new();
|
||||||
let mut seen_routes: HashMap<&(String, String), &str> = HashMap::new();
|
let mut seen_routes: HashMap<&(String, String), &str> = HashMap::new();
|
||||||
for ((op_id, path), route) in op_ids.iter().zip(paths.iter()).zip(routes.iter()) {
|
for ((op_id, path), route) in op_ids.iter().zip(paths.iter()).zip(routes.iter()) {
|
||||||
@@ -158,6 +168,11 @@ impl FromOpenAPI {
|
|||||||
) -> Result<Value, AdapterError> {
|
) -> Result<Value, AdapterError> {
|
||||||
let mut properties = serde_json::Map::new();
|
let mut properties = serde_json::Map::new();
|
||||||
let mut required = Vec::new();
|
let mut required = Vec::new();
|
||||||
|
let locator = format!(
|
||||||
|
"{} {}",
|
||||||
|
op.operation_id.as_deref().unwrap_or("?"),
|
||||||
|
self.config.namespace
|
||||||
|
);
|
||||||
|
|
||||||
// OAI-13: path-item-level parameters merge into every operation
|
// OAI-13: path-item-level parameters merge into every operation
|
||||||
// under the path. The operation-level entries come second, so a
|
// under the path. The operation-level entries come second, so a
|
||||||
@@ -183,6 +198,7 @@ impl FromOpenAPI {
|
|||||||
}
|
}
|
||||||
match param.in_.as_str() {
|
match param.in_.as_str() {
|
||||||
"header" => {
|
"header" => {
|
||||||
|
self.check_header_param_collisions(¶m.name, &locator)?;
|
||||||
properties.insert(
|
properties.insert(
|
||||||
param.name.clone(),
|
param.name.clone(),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -233,6 +249,60 @@ impl FromOpenAPI {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Header-precedence gate (review 002 OAI-19): `build_request` inserts
|
||||||
|
/// header params first, then `default_headers`, then credential
|
||||||
|
/// headers — later `insert` calls replace earlier ones, so a declared
|
||||||
|
/// `in: header` parameter whose name matches a default/credential
|
||||||
|
/// header would silently never deliver the peer's value upstream.
|
||||||
|
/// `Authorization` on an authed namespace is rejected outright (the
|
||||||
|
/// credential header always wins by construction); a collision with a
|
||||||
|
/// configured `default_headers` key fails import with both names —
|
||||||
|
/// the assembly's config is visible at import time, so silence about
|
||||||
|
/// it would be a lie about the wire.
|
||||||
|
fn check_header_param_collisions(&self, name: &str, locator: &str) -> Result<(), AdapterError> {
|
||||||
|
if self.config.auth.is_some() && name.eq_ignore_ascii_case("authorization") {
|
||||||
|
return Err(AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"header parameter `{name}` on {locator} collides with the \
|
||||||
|
Authorization credential header the adapter injects from \
|
||||||
|
Capabilities; the peer-supplied value would be silently replaced \
|
||||||
|
by the outbound credential on every call — rename the parameter or \
|
||||||
|
remove the auth scheme from the service config (review 002 OAI-19)"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if self
|
||||||
|
.config
|
||||||
|
.default_headers
|
||||||
|
.keys()
|
||||||
|
.any(|k| k.to_ascii_lowercase() == lower)
|
||||||
|
{
|
||||||
|
return Err(AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"header parameter `{name}` on {locator} collides with a configured \
|
||||||
|
default_headers entry of the same name; the default value would \
|
||||||
|
silently replace the peer-supplied header value at call time — \
|
||||||
|
rename the parameter or drop the default_headers entry \
|
||||||
|
(review 002 OAI-19)"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(HttpAuthScheme::ApiKey { header_name }) = &self.config.auth {
|
||||||
|
if header_name.eq_ignore_ascii_case(name) || header_name.to_ascii_lowercase() == lower {
|
||||||
|
return Err(AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"header parameter `{name}` on {locator} collides with the API-key \
|
||||||
|
credential header `{header_name}`; the credential would silently \
|
||||||
|
replace the peer-supplied value at call time — rename the \
|
||||||
|
parameter (review 002 OAI-19)"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn build_output_schema(&self, op: &Operation) -> Result<Value, AdapterError> {
|
fn build_output_schema(&self, op: &Operation) -> Result<Value, AdapterError> {
|
||||||
// Mirrors `detect_op_type`'s success-key sweep (OAI-06, OAI-13):
|
// Mirrors `detect_op_type`'s success-key sweep (OAI-06, OAI-13):
|
||||||
// a stream declared under a non-200/201 2XX key, the `2XX`
|
// a stream declared under a non-200/201 2XX key, the `2XX`
|
||||||
@@ -312,12 +382,26 @@ impl FromOpenAPI {
|
|||||||
op: &Operation,
|
op: &Operation,
|
||||||
path_parameters: &[Parameter],
|
path_parameters: &[Parameter],
|
||||||
) -> Result<HandlerRegistration, AdapterError> {
|
) -> Result<HandlerRegistration, AdapterError> {
|
||||||
|
validate_path_template(path)?;
|
||||||
let name = Self::normalize_operation_id(op, method, path);
|
let name = Self::normalize_operation_id(op, method, path);
|
||||||
let qualified_name = format!("{}/{name}", self.config.namespace);
|
let qualified_name = format!("{}/{name}", self.config.namespace);
|
||||||
let op_type = Self::detect_op_type(method, op);
|
let op_type = Self::detect_op_type(method, op);
|
||||||
let input_schema = self.build_input_schema(op, path_parameters)?;
|
let input_schema = self.build_input_schema(op, path_parameters)?;
|
||||||
let output_schema = self.build_output_schema(op)?;
|
let output_schema = self.build_output_schema(op)?;
|
||||||
let error_schemas = self.build_error_schemas(op)?;
|
let error_schemas = self.build_error_schemas(op)?;
|
||||||
|
let ignored = oai14_ignored_keys(&[&input_schema, &output_schema]);
|
||||||
|
if !ignored.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
namespace = %self.config.namespace,
|
||||||
|
operation = %name,
|
||||||
|
keys = %ignored.join(", "),
|
||||||
|
"schema declares serialization keywords the HTTP adapter ignores: they \
|
||||||
|
change what a conforming OpenAPI client would send (polymorphic \
|
||||||
|
discriminator headers, XML wire annotations) but the adapter forwards \
|
||||||
|
JSON only — remove them or split the operation if the upstream \
|
||||||
|
requires that wire shape (review 002 OAI-14)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let spec = OperationSpec::new(
|
let spec = OperationSpec::new(
|
||||||
qualified_name,
|
qualified_name,
|
||||||
@@ -354,16 +438,16 @@ impl FromOpenAPI {
|
|||||||
// diagnosis is not a dead end.
|
// diagnosis is not a dead end.
|
||||||
return Err(AdapterError::SchemaParse {
|
return Err(AdapterError::SchemaParse {
|
||||||
message: format!(
|
message: format!(
|
||||||
"path {method} {path_template} declares placeholder(s) {} with no \
|
"path {method} {} declares placeholder(s) {} with no \
|
||||||
matching parameter in the operation's resolved input schema. Every \
|
matching parameter in the operation's resolved input schema. Every \
|
||||||
`parameters` source was merged (path-item level and operation level, \
|
`parameters` source was merged (path-item level and operation level, \
|
||||||
review 002 OAI-13); a placeholder left unbound after the merge means \
|
review 002 OAI-13); a placeholder left unbound after the merge means \
|
||||||
a declared parameter was dropped — check that each entry under \
|
a declared parameter was dropped — check that each entry under \
|
||||||
`paths.{path_template}.parameters` (and the path-item's shared \
|
`paths` for this path's `parameters` (and the path-item's shared \
|
||||||
list) has `name` and `in`, and that its $ref, if any, resolves. \
|
list) has `name` and `in`, and that its $ref, if any, resolves. \
|
||||||
The placeholder would otherwise render as a literal `{}` path segment",
|
The placeholder would otherwise render as a literal `{{}}` path segment",
|
||||||
unbound.join(", "),
|
bounded_join(&[path_template.to_string()]),
|
||||||
unbound[0]
|
bounded_join(&unbound)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -441,6 +525,7 @@ impl FromOpenAPI {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OperationAdapter for FromOpenAPI {
|
impl OperationAdapter for FromOpenAPI {
|
||||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
||||||
|
self.spec.validate_import_loud_features()?;
|
||||||
let mut bundles = Vec::new();
|
let mut bundles = Vec::new();
|
||||||
let mut op_ids = Vec::new();
|
let mut op_ids = Vec::new();
|
||||||
let mut paths = Vec::new();
|
let mut paths = Vec::new();
|
||||||
@@ -812,6 +897,142 @@ 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 header_param_colliding_with_authorization_on_authed_namespace_rejected() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/me":{"get":{
|
||||||
|
"operationId":"me",
|
||||||
|
"parameters":[{"name":"Authorization","in":"header","schema":{"type":"string"}}],
|
||||||
|
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||||
|
}}}}"#;
|
||||||
|
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||||
|
let result = adapter(
|
||||||
|
spec,
|
||||||
|
config("svc", "https://x", Some(HttpAuthScheme::Bearer)),
|
||||||
|
)
|
||||||
|
.import()
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("Authorization credential header"),
|
||||||
|
"message was: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("OAI-19"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(bundles) => panic!(
|
||||||
|
"Authorization header param on authed namespace must be rejected, got {} bundles",
|
||||||
|
bundles.len()
|
||||||
|
),
|
||||||
|
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn header_param_colliding_with_default_headers_rejected() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/t":{"get":{
|
||||||
|
"operationId":"t",
|
||||||
|
"parameters":[{"name":"X-Tenant","in":"header","schema":{"type":"string"}}],
|
||||||
|
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||||
|
}}}}"#;
|
||||||
|
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||||
|
let mut cfg = config("svc", "https://x", None);
|
||||||
|
cfg.default_headers
|
||||||
|
.insert("x-tenant".to_string(), "fixed".to_string());
|
||||||
|
let result = adapter(spec, cfg).import().await;
|
||||||
|
match result {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("default_headers"),
|
||||||
|
"message was: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("X-Tenant"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(bundles) => panic!(
|
||||||
|
"header/default_headers collision must be rejected, got {} bundles",
|
||||||
|
bundles.len()
|
||||||
|
),
|
||||||
|
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn header_param_colliding_with_api_key_header_rejected() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/t":{"get":{
|
||||||
|
"operationId":"t",
|
||||||
|
"parameters":[{"name":"x-api-key","in":"header","schema":{"type":"string"}}],
|
||||||
|
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||||
|
}}}}"#;
|
||||||
|
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||||
|
let auth = Some(HttpAuthScheme::ApiKey {
|
||||||
|
header_name: "X-API-Key".to_string(),
|
||||||
|
});
|
||||||
|
let result = adapter(spec, config("svc", "https://x", auth))
|
||||||
|
.import()
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("API-key credential header"),
|
||||||
|
"message was: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(bundles) => panic!(
|
||||||
|
"API-key header collision must be rejected, got {} bundles",
|
||||||
|
bundles.len()
|
||||||
|
),
|
||||||
|
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn header_param_without_config_collision_imports_cleanly() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/t":{"get":{
|
||||||
|
"operationId":"t",
|
||||||
|
"parameters":[{"name":"X-Trace-Id","in":"header","schema":{"type":"string"}}],
|
||||||
|
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||||
|
}}}}"#;
|
||||||
|
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||||
|
let bundles = adapter(spec, config("svc", "https://x", None))
|
||||||
|
.import()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bundles.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn op_type_detection() {
|
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":{}}}}}}}}}"#;
|
let get_doc = r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/g":{"get":{"operationId":"g","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}}}"#;
|
||||||
|
|||||||
+504
-32
@@ -32,9 +32,21 @@
|
|||||||
//! - **Bare `yes`/`no`/`on`/`off` and tags** — identical behavior on
|
//! - **Bare `yes`/`no`/`on`/`off` and tags** — identical behavior on
|
||||||
//! both paths by YAML 1.2 core schema (strings, `!!str 200` →
|
//! both paths by YAML 1.2 core schema (strings, `!!str 200` →
|
||||||
//! `"200"`); unknown tags fail loudly on both.
|
//! `"200"`); unknown tags fail loudly on both.
|
||||||
|
//!
|
||||||
|
//! # Version stance (review 002 OAI-10)
|
||||||
|
//!
|
||||||
|
//! Documents are interpreted under **OpenAPI 3.0 semantics**; there is
|
||||||
|
//! no `openapi: 3.1` version gate. The one divergence that matters here
|
||||||
|
//! is `$ref` siblings: 3.0 ignores them, 3.1 applies them alongside the
|
||||||
|
//! resolved target. A `$ref` carrying sibling keys therefore imports
|
||||||
|
//! with the 3.0 reading and a `tracing::warn` naming the dropped keys —
|
||||||
|
//! the advertise/enforce drift a 3.1-authored constraint would
|
||||||
|
//! otherwise hide is visible at import instead of surfacing as a
|
||||||
|
//! silent `/schema` overstatement.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::adapters::forward::bounded_join;
|
||||||
use alkcall::client::AdapterError;
|
use alkcall::client::AdapterError;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yaml_serde::Value as YamlValue;
|
use yaml_serde::Value as YamlValue;
|
||||||
@@ -473,7 +485,7 @@ impl OpenAPISpec {
|
|||||||
configured at assembly time and cannot honor per-location `servers` — \
|
configured at assembly time and cannot honor per-location `servers` — \
|
||||||
remove the `servers` entries or split the service into one import per \
|
remove the `servers` entries or split the service into one import per \
|
||||||
base URL (review 001 OAI-06)",
|
base URL (review 001 OAI-06)",
|
||||||
servers_locations.join(", ")
|
bounded_join(&servers_locations)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -487,13 +499,16 @@ impl OpenAPISpec {
|
|||||||
let mut operations = Vec::new();
|
let mut operations = Vec::new();
|
||||||
for method in HTTP_METHODS {
|
for method in HTTP_METHODS {
|
||||||
if let Some(op_raw) = item.get(*method) {
|
if let Some(op_raw) = item.get(*method) {
|
||||||
match parse_operation(op_raw, &provisional) {
|
let locator = format!("{method} {path}");
|
||||||
|
match parse_operation(op_raw, &provisional, &locator) {
|
||||||
Ok(Some(op)) => operations.push((method.to_string(), op)),
|
Ok(Some(op)) => operations.push((method.to_string(), op)),
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return Err(AdapterError::SchemaParse {
|
return Err(AdapterError::SchemaParse {
|
||||||
message: format!(
|
message: format!(
|
||||||
"unresolvable $ref or missing `name`/`in` in parameter of \
|
"unresolvable $ref or missing `name`/`in` in parameter of \
|
||||||
{method} {path}"
|
{method} {path}, or an unresolvable/content-less \
|
||||||
|
`requestBody` on the operation (review 001 OAI-04, \
|
||||||
|
review 002 OAI-15)"
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -576,16 +591,86 @@ impl OpenAPISpec {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import-time loud-feature gate for the *service import* path
|
||||||
|
/// (`FromOpenAPI::import`), not the shared structural parse:
|
||||||
|
/// `from_value` also re-validates the published gateway doc inside
|
||||||
|
/// `to_openapi`, and that self-description legitimately carries
|
||||||
|
/// `security` markers for its external HTTP clients. A *service*
|
||||||
|
/// spec declaring the OAI-14 blocks would import silently-degraded
|
||||||
|
/// operations, so the import refuses where the semantics would be
|
||||||
|
/// lost; the gateway doc's own markers are inert here.
|
||||||
|
pub(crate) fn validate_import_loud_features(&self) -> Result<(), AdapterError> {
|
||||||
|
let mut callback_locations: Vec<String> = Vec::new();
|
||||||
|
let mut security_locations: Vec<String> = Vec::new();
|
||||||
|
if self.raw.get("callbacks").is_some() {
|
||||||
|
callback_locations.push("document".to_string());
|
||||||
|
}
|
||||||
|
if self.raw.get("security").is_some() {
|
||||||
|
security_locations.push("document".to_string());
|
||||||
|
}
|
||||||
|
if let Some(paths_obj) = self.raw.get("paths").and_then(|p| p.as_object()) {
|
||||||
|
for (path, item) in paths_obj {
|
||||||
|
let Some(item_obj) = item.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for method in HTTP_METHODS {
|
||||||
|
let Some(op) = item_obj.get(*method).and_then(|op| op.as_object()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if op.contains_key("callbacks") {
|
||||||
|
callback_locations.push(format!("{method} {path}"));
|
||||||
|
}
|
||||||
|
if op.contains_key("security") {
|
||||||
|
security_locations.push(format!("{method} {path}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !callback_locations.is_empty() {
|
||||||
|
return Err(AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"the document declares `callbacks` at: {}. Callbacks are \
|
||||||
|
server-initiated outbound calls (inbound to this service) that the \
|
||||||
|
single-endpoint HTTP adapter does not model — remove the \
|
||||||
|
`callbacks` entries or split those operations into their own \
|
||||||
|
service definition (review 002 OAI-14)",
|
||||||
|
bounded_join(&callback_locations)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !security_locations.is_empty() {
|
||||||
|
return Err(AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"the document declares `security` requirement(s) at: {}. The HTTP \
|
||||||
|
adapter injects credentials exclusively through Capabilities per \
|
||||||
|
the declared auth scheme (review 001 OAI-06 posture); OpenAPI \
|
||||||
|
security requirements would silently change nothing at call time — \
|
||||||
|
remove the `security` blocks or set the `auth` field on the service \
|
||||||
|
config instead (review 002 OAI-14)",
|
||||||
|
bounded_join(&security_locations)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_ref(&self, reference: &str) -> Result<Value, AdapterError> {
|
pub(crate) fn resolve_ref(&self, reference: &str) -> Result<Value, AdapterError> {
|
||||||
|
let bounded = |r: &str| {
|
||||||
|
if r.chars().count() > 128 {
|
||||||
|
format!("{}…", r.chars().take(128).collect::<String>())
|
||||||
|
} else {
|
||||||
|
r.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
if !reference.starts_with("#/") {
|
if !reference.starts_with("#/") {
|
||||||
return Err(AdapterError::SchemaParse {
|
return Err(AdapterError::SchemaParse {
|
||||||
message: format!("external $ref not supported: {reference}"),
|
message: format!("external $ref not supported: {}", bounded(reference)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut current: &Value = &self.raw;
|
let mut current: &Value = &self.raw;
|
||||||
for part in reference.trim_start_matches("#/").split('/') {
|
for part in reference.trim_start_matches("#/").split('/') {
|
||||||
current = current.get(part).ok_or_else(|| AdapterError::SchemaParse {
|
current = current.get(part).ok_or_else(|| AdapterError::SchemaParse {
|
||||||
message: format!("cannot resolve $ref: {reference}"),
|
message: format!("cannot resolve $ref: {}", bounded(reference)),
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(current.clone())
|
Ok(current.clone())
|
||||||
@@ -734,9 +819,71 @@ fn count_nodes(value: &Value) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collects the schema-embedded keywords the adapter silently ignores
|
||||||
|
/// (review 002 OAI-14): `discriminator` (polymorphic serialization
|
||||||
|
/// headers the forwarder does not emit) and `xml` (wire-format
|
||||||
|
/// annotations for XML serialization the adapter never performs). Both
|
||||||
|
/// change what a conforming client would send or expect on the wire;
|
||||||
|
/// vanishing them silently lets a schema advertise a shape the calls
|
||||||
|
/// never honor. The walk visits only the *declared* schema (already
|
||||||
|
/// bounded by the resolver's budgets before this runs on resolved
|
||||||
|
/// output); it is linear in schema size.
|
||||||
|
pub(crate) fn collect_ignored_schema_keys(value: &Value, found: &mut Vec<String>) {
|
||||||
|
let mut stack = vec![value];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
match current {
|
||||||
|
Value::Object(map) => {
|
||||||
|
for (k, v) in map {
|
||||||
|
if k == "discriminator" || k == "xml" {
|
||||||
|
found.push(k.clone());
|
||||||
|
}
|
||||||
|
stack.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
stack.extend(items.iter());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warns once per offending `$ref` object about sibling keys left
|
||||||
|
/// beside the `$ref` (review 002 OAI-10): under OpenAPI 3.0 the
|
||||||
|
/// siblings are ignored, but 3.1 applies them alongside the reference —
|
||||||
|
/// so a document authored against 3.1 semantics would advertise
|
||||||
|
/// constraints (`minLength: 3`) through `/schema` that the adapter's
|
||||||
|
/// resolved schema (used at call time) does not carry. There is no
|
||||||
|
/// `openapi: 3.1` version gate; the import proceeds with the 3.0
|
||||||
|
/// reading while naming the dropped keys.
|
||||||
|
fn warn_ref_siblings(context: &str, holder: &Value) {
|
||||||
|
if let Some(obj) = holder.as_object() {
|
||||||
|
let siblings: Vec<&String> = obj.keys().filter(|k| *k != "$ref").collect();
|
||||||
|
if !siblings.is_empty() {
|
||||||
|
let names: Vec<String> = siblings.iter().map(|s| s.as_str().to_string()).collect();
|
||||||
|
tracing::warn!(
|
||||||
|
location = %context,
|
||||||
|
siblings = %names.join(", "),
|
||||||
|
"$ref carries sibling keys; OpenAPI 3.0 semantics apply — the \
|
||||||
|
siblings are ignored (not merged into the resolved target as \
|
||||||
|
3.1 would do), so constraints authored beside the $ref are \
|
||||||
|
not enforced at call time (review 002 OAI-10)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses one operation (OAI-04/OAI-15). Returns `Ok(None)` when the
|
||||||
|
/// operation cannot be modeled faithfully: an unresolvable parameter
|
||||||
|
/// `$ref`, a parameter missing `name`/`in`, an unresolvable
|
||||||
|
/// `requestBody` `$ref`, or a resolved `requestBody` that still carries
|
||||||
|
/// a top-level `$ref` or lacks `content` — a body-less op would
|
||||||
|
/// register silently and fail every call with `INVALID_INPUT` on
|
||||||
|
/// `body` (review 002 OAI-15).
|
||||||
fn parse_operation(
|
fn parse_operation(
|
||||||
raw: &Value,
|
raw: &Value,
|
||||||
spec: &OpenAPISpec,
|
spec: &OpenAPISpec,
|
||||||
|
locator: &str,
|
||||||
) -> Result<Option<Operation>, ParameterStyleError> {
|
) -> Result<Option<Operation>, ParameterStyleError> {
|
||||||
if !raw.is_object() {
|
if !raw.is_object() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -748,12 +895,15 @@ fn parse_operation(
|
|||||||
|
|
||||||
let mut parameters = Vec::new();
|
let mut parameters = Vec::new();
|
||||||
if let Some(arr) = raw.get("parameters").and_then(|v| v.as_array()) {
|
if let Some(arr) = raw.get("parameters").and_then(|v| v.as_array()) {
|
||||||
for p in arr {
|
for (index, p) in arr.iter().enumerate() {
|
||||||
let p = match p.get("$ref").and_then(|r| r.as_str()) {
|
let p = match p.get("$ref").and_then(|r| r.as_str()) {
|
||||||
Some(reference) => match spec.resolve_ref(reference) {
|
Some(reference) => {
|
||||||
Ok(resolved) => resolved,
|
warn_ref_siblings(&format!("parameter[{index}] $ref {reference}"), p);
|
||||||
Err(_) => return Ok(None),
|
match spec.resolve_ref(reference) {
|
||||||
},
|
Ok(resolved) => resolved,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
None => p.clone(),
|
None => p.clone(),
|
||||||
};
|
};
|
||||||
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
|
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
|
||||||
@@ -762,12 +912,6 @@ fn parse_operation(
|
|||||||
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
|
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
// OAI-06: non-default `style`/`explode` forms change how arrays
|
|
||||||
// and objects serialize on the wire (the adapter emits the
|
|
||||||
// form/simple default — repeated keys for query arrays). A
|
|
||||||
// parameter declaring a different serialization would silently
|
|
||||||
// mis-serialize upstream (`"[1,2]"`-style), so it fails import
|
|
||||||
// with an error naming the parameter and feature.
|
|
||||||
check_parameter_style(name, in_, &p)?;
|
check_parameter_style(name, in_, &p)?;
|
||||||
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
|
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let schema = p.get("schema").cloned();
|
let schema = p.get("schema").cloned();
|
||||||
@@ -780,19 +924,49 @@ fn parse_operation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_body = raw.get("requestBody").and_then(|rb| {
|
let request_body = match raw.get("requestBody") {
|
||||||
let rb = match rb.get("$ref").and_then(|r| r.as_str()) {
|
Some(rb) => {
|
||||||
Some(reference) => spec.resolve_ref(reference).ok()?,
|
let body = match rb.get("$ref").and_then(|r| r.as_str()) {
|
||||||
None => rb.clone(),
|
Some(reference) => {
|
||||||
};
|
warn_ref_siblings(&format!("requestBody $ref {reference}"), rb);
|
||||||
let content_obj = rb.get("content")?.as_object()?;
|
match spec.resolve_ref(reference) {
|
||||||
let mut content = BTreeMap::new();
|
Ok(resolved) => resolved,
|
||||||
for (k, v) in content_obj {
|
Err(_) => return Ok(None),
|
||||||
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
|
}
|
||||||
content.insert(k.clone(), schema);
|
}
|
||||||
|
None => rb.clone(),
|
||||||
|
};
|
||||||
|
if body.get("$ref").is_some() || !body.get("content").is_some_and(|c| c.is_object()) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// OAI-14: a requestBody shaped `{oneOf: [...]}` (the union form
|
||||||
|
// some 3.1-era generators emit) has no `content` map, so the
|
||||||
|
// media-type keyed body contract is unrepresentable — it must
|
||||||
|
// not silently import as a body-less op.
|
||||||
|
if body.get("oneOf").is_some() {
|
||||||
|
return Err(ParameterStyleError {
|
||||||
|
parameter: "requestBody".to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"on {locator}: uses a top-level `oneOf` requestBody (no \
|
||||||
|
`content` map), which the HTTP adapter cannot turn into the \
|
||||||
|
gateway's media-typed body contract — wrap each variant in a \
|
||||||
|
`content` entry (e.g. application/json) or split into separate \
|
||||||
|
operations (review 002 OAI-14)"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let Some(content_obj) = body.get("content").and_then(|v| v.as_object()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut content = BTreeMap::new();
|
||||||
|
for (k, v) in content_obj {
|
||||||
|
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
|
||||||
|
content.insert(k.clone(), schema);
|
||||||
|
}
|
||||||
|
Some(RequestBody { content })
|
||||||
}
|
}
|
||||||
Some(RequestBody { content })
|
None => None,
|
||||||
});
|
};
|
||||||
|
|
||||||
let mut responses = BTreeMap::new();
|
let mut responses = BTreeMap::new();
|
||||||
if let Some(resp_obj) = raw.get("responses").and_then(|v| v.as_object()) {
|
if let Some(resp_obj) = raw.get("responses").and_then(|v| v.as_object()) {
|
||||||
@@ -837,11 +1011,13 @@ impl ItemParameters {
|
|||||||
let Some(arr) = item.get("parameters").and_then(|v| v.as_array()) else {
|
let Some(arr) = item.get("parameters").and_then(|v| v.as_array()) else {
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
};
|
};
|
||||||
for p in arr {
|
for (index, p) in arr.iter().enumerate() {
|
||||||
let p = match p.get("$ref").and_then(|r| r.as_str()) {
|
let p = match p.get("$ref").and_then(|r| r.as_str()) {
|
||||||
Some(reference) => spec
|
Some(reference) => {
|
||||||
.resolve_ref(reference)
|
warn_ref_siblings(&format!("path-item parameter[{index}] $ref {reference}"), p);
|
||||||
.map_err(|_| path_style_error(format!("unresolvable $ref: {reference}")))?,
|
spec.resolve_ref(reference)
|
||||||
|
.map_err(|_| path_style_error(format!("unresolvable $ref: {reference}")))?
|
||||||
|
}
|
||||||
None => p.clone(),
|
None => p.clone(),
|
||||||
};
|
};
|
||||||
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
|
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
|
||||||
@@ -1028,6 +1204,99 @@ mod tests {
|
|||||||
assert!(props.get("name").is_some());
|
assert!(props.get("name").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_body_self_ref_fails_import_not_silent_bodyless_op() {
|
||||||
|
let doc = r##"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"paths": {
|
||||||
|
"/widgets": {"post": {
|
||||||
|
"operationId": "createWidget",
|
||||||
|
"requestBody": {"$ref": "#/paths/~1widgets/post/requestBody"},
|
||||||
|
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}"##;
|
||||||
|
match OpenAPISpec::from_json(doc) {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("requestBody"),
|
||||||
|
"the error must name the requestBody: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("OAI-15"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(spec) => {
|
||||||
|
let body = &spec.paths["/widgets"].operations[0].1.request_body;
|
||||||
|
assert!(
|
||||||
|
body.is_none(),
|
||||||
|
"a self-ref'd requestBody must not silently import as body-less (OAI-15)"
|
||||||
|
);
|
||||||
|
panic!("self-$ref'd requestBody must fail import loudly (OAI-15)");
|
||||||
|
}
|
||||||
|
other => panic!("expected SchemaParse, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_body_ref_to_missing_component_fails_import_loudly() {
|
||||||
|
let doc = r##"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"paths": {
|
||||||
|
"/widgets": {"post": {
|
||||||
|
"operationId": "createWidget",
|
||||||
|
"requestBody": {"$ref": "#/components/requestBodies/Missing"},
|
||||||
|
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}"##;
|
||||||
|
match OpenAPISpec::from_json(doc) {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("requestBody") || message.contains("unresolvable"),
|
||||||
|
"message was: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("post /widgets"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("unresolvable requestBody $ref must fail import loudly"),
|
||||||
|
other => panic!("expected SchemaParse, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_less_request_body_fails_import_not_silent_bodyless_op() {
|
||||||
|
let doc = r##"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"components": {
|
||||||
|
"requestBodies": {
|
||||||
|
"DescriptionOnly": {"description": "no content map"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/widgets": {"post": {
|
||||||
|
"operationId": "createWidget",
|
||||||
|
"requestBody": {"$ref": "#/components/requestBodies/DescriptionOnly"},
|
||||||
|
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}"##;
|
||||||
|
match OpenAPISpec::from_json(doc) {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("requestBody") || message.contains("unresolvable"),
|
||||||
|
"message was: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(spec) => {
|
||||||
|
let body = &spec.paths["/widgets"].operations[0].1.request_body;
|
||||||
|
assert!(body.is_none(), "content-less body must not silently drop");
|
||||||
|
panic!("content-less requestBody must fail import loudly (OAI-15)");
|
||||||
|
}
|
||||||
|
other => panic!("expected SchemaParse, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parameter_ref_to_missing_component_fails_import_loudly() {
|
fn parameter_ref_to_missing_component_fails_import_loudly() {
|
||||||
let doc = r##"{
|
let doc = r##"{
|
||||||
@@ -1703,6 +1972,209 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- OAI-10: $ref sibling keys -------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ref_sibling_keys_import_with_3_0_reading_and_warn() {
|
||||||
|
let doc = r##"{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"components": {"parameters": {
|
||||||
|
"Id": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}
|
||||||
|
}},
|
||||||
|
"paths": {
|
||||||
|
"/users/{id}": {"get": {
|
||||||
|
"operationId": "getUser",
|
||||||
|
"parameters": [
|
||||||
|
{"$ref": "#/components/parameters/Id", "description": "the user id", "deprecated": false}
|
||||||
|
],
|
||||||
|
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}"##;
|
||||||
|
let spec = OpenAPISpec::from_json(doc).expect("sibling keys do not fail the import");
|
||||||
|
let item = spec.paths.get("/users/{id}").expect("path present");
|
||||||
|
let param = &item.operations[0].1.parameters[0];
|
||||||
|
assert_eq!(param.name, "id", "the resolved 3.0 reading wins");
|
||||||
|
assert_eq!(
|
||||||
|
param.in_, "path",
|
||||||
|
"the resolved target's fields are what imports"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OAI-14: top-level ignored blocks ------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callbacks_at_operation_level_fail_import_naming_the_feature() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"paths": {
|
||||||
|
"/orders": {"post": {
|
||||||
|
"operationId": "createOrder",
|
||||||
|
"callbacks": {
|
||||||
|
"orderEvent": {"{$request.body#/callbackUrl}": {"post": {
|
||||||
|
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}}
|
||||||
|
},
|
||||||
|
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
run_import_expect_oai14(doc, "callbacks", "post /orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_requirements_fail_import_naming_the_remediation() {
|
||||||
|
let doc_level = r#"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"security": [{"bearerAuth": []}],
|
||||||
|
"paths": {"/x": {"get": {"operationId": "x", "responses": {
|
||||||
|
"200": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}}}
|
||||||
|
"#;
|
||||||
|
run_import_expect_oai14(doc_level, "security", "document");
|
||||||
|
|
||||||
|
let op_level = r#"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"paths": {"/y": {"get": {
|
||||||
|
"operationId": "y",
|
||||||
|
"security": [{"apiKey": []}],
|
||||||
|
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}}}
|
||||||
|
"#;
|
||||||
|
run_import_expect_oai14(op_level, "security", "get /y");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_level_oneof_request_body_fails_import_naming_the_feature() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {"title": "T", "version": "1"},
|
||||||
|
"paths": {
|
||||||
|
"/x": {"post": {
|
||||||
|
"operationId": "x",
|
||||||
|
"requestBody": {
|
||||||
|
"oneOf": [
|
||||||
|
{"content": {"application/json": {"schema": {"type": "object"}}}},
|
||||||
|
{"content": {"text/plain": {"schema": {"type": "string"}}}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
match OpenAPISpec::from_json(doc) {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("requestBody") && message.contains("unresolvable"),
|
||||||
|
"the oneOf body (no content map) fails via the OAI-15 arm: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("OAI-15"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("top-level oneOf requestBody must fail import loudly (OAI-14/15)"),
|
||||||
|
other => panic!("expected SchemaParse, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_import_expect_oai14(doc: &str, feature: &str, location: &str) {
|
||||||
|
let spec = OpenAPISpec::from_json(doc).expect("structural parse passes");
|
||||||
|
let client = SharedHttpClient::new(HttpClientConfig::default()).expect("client");
|
||||||
|
let adapter = FromOpenAPI::new(
|
||||||
|
spec,
|
||||||
|
HttpServiceConfig {
|
||||||
|
namespace: "svc".to_string(),
|
||||||
|
base_url: "https://x".to_string(),
|
||||||
|
auth: None,
|
||||||
|
default_headers: HashMap::new(),
|
||||||
|
},
|
||||||
|
Arc::new(client),
|
||||||
|
);
|
||||||
|
match futures::executor::block_on(adapter.import()) {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains(feature),
|
||||||
|
"the error must name {feature}: {message}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains(location),
|
||||||
|
"the error must locate {location}: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("OAI-14"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(bundles) => panic!(
|
||||||
|
"{feature} at {location} must fail import loudly (OAI-14), got {} bundles",
|
||||||
|
bundles.len()
|
||||||
|
),
|
||||||
|
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OAI-17: bounded import error messages -------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hundred_thousand_servers_overrides_produce_bounded_error_message() {
|
||||||
|
let mut paths = String::from("{");
|
||||||
|
for i in 0..100_000 {
|
||||||
|
let entry = r#""/p0": {"servers": [{"url": "https://h.example.com"}], "get": {"operationId": "op0", "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}},"#
|
||||||
|
.replace("p0", &format!("p{i}"))
|
||||||
|
.replace("h.example.com", &format!("h{i}.example.com"))
|
||||||
|
.replace("op0", &format!("op{i}"));
|
||||||
|
paths.push_str(&entry);
|
||||||
|
}
|
||||||
|
paths.push_str(r#""/final": {"servers": [{"url": "https://z.example.com"}]}}"#);
|
||||||
|
let doc = format!(
|
||||||
|
r#"{{"openapi": "3.0.0", "info": {{"title": "T", "version": "1"}}, "paths": {paths}}}"#
|
||||||
|
);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
match OpenAPISpec::from_json(&doc) {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.len() < 4096,
|
||||||
|
"servers error must stay bounded (OAI-17), got {} bytes",
|
||||||
|
message.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains('+') && message.contains("more"),
|
||||||
|
"the bounded join must name the suppressed count: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("100k servers overrides must fail import"),
|
||||||
|
other => panic!("expected SchemaParse, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed().as_secs() < 30,
|
||||||
|
"the fixtures stay linear/bounded; this took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_join_truncates_both_count_and_width() {
|
||||||
|
let many: Vec<String> = (0..50).map(|i| format!("item{i}")).collect();
|
||||||
|
let joined = bounded_join(&many);
|
||||||
|
assert!(
|
||||||
|
joined.contains("item0") && joined.contains("item7"),
|
||||||
|
"first 8 shown: {joined}"
|
||||||
|
);
|
||||||
|
assert!(!joined.contains("item8,"), "9th item suppressed: {joined}");
|
||||||
|
assert!(joined.contains("+42 more"), "count named: {joined}");
|
||||||
|
|
||||||
|
let wide = vec!["x".repeat(500)];
|
||||||
|
let joined = bounded_join(&wide);
|
||||||
|
assert!(
|
||||||
|
joined.len() < 200,
|
||||||
|
"each item truncated to the width cap: {} bytes",
|
||||||
|
joined.len()
|
||||||
|
);
|
||||||
|
assert!(joined.ends_with('…'), "truncation marker: {joined}");
|
||||||
|
|
||||||
|
let small = vec!["a".to_string(), "b".to_string()];
|
||||||
|
assert_eq!(bounded_join(&small), "a, b", "under cap is unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
// --- OAI-12: YAML input normalization -----------------------------------
|
// --- OAI-12: YAML input normalization -----------------------------------
|
||||||
|
|
||||||
const OAI12_HEADER: &str = r#"
|
const OAI12_HEADER: &str = r#"
|
||||||
|
|||||||
Reference in New Issue
Block a user