Skip to main content

blazingly_openapi/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use blazingly_core::{
5    AppDefinition, InputDescriptor, InputSource, ModelDescriptor, OperationDescriptor, SchemaKind,
6    SecurityLocation, SecuritySchemeDescriptor, SecuritySchemeKind, TypeDescriptor, ValidationRule,
7};
8use blazingly_json::{Map, Value, json};
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Recursion budget for a schema-derived example payload.
12///
13/// A model reached through more than this many `$ref` or property hops
14/// contributes `null` instead of another nesting level, so a self-referential
15/// schema cannot make document generation diverge.
16const MAX_EXAMPLE_DEPTH: usize = 8;
17
18/// Browser UI rendered by [`OpenApiService`].
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum OpenApiUi {
21    Scalar,
22    Swagger,
23    Disabled,
24}
25
26/// `OpenAPI` document metadata and well-known HTTP paths.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct OpenApiConfig {
29    pub title: String,
30    pub version: String,
31    pub document_path: String,
32    pub ui_path: String,
33    pub ui: OpenApiUi,
34}
35
36impl OpenApiConfig {
37    #[must_use]
38    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
39        Self {
40            title: title.into(),
41            version: version.into(),
42            document_path: "/openapi.json".to_owned(),
43            ui_path: "/docs".to_owned(),
44            ui: OpenApiUi::Scalar,
45        }
46    }
47
48    #[must_use]
49    pub fn with_document_path(mut self, path: impl Into<String>) -> Self {
50        self.document_path = path.into();
51        self
52    }
53
54    #[must_use]
55    pub fn with_ui_path(mut self, path: impl Into<String>) -> Self {
56        self.ui_path = path.into();
57        self
58    }
59
60    #[must_use]
61    pub const fn with_ui(mut self, ui: OpenApiUi) -> Self {
62        self.ui = ui;
63        self
64    }
65}
66
67impl Default for OpenApiConfig {
68    fn default() -> Self {
69        Self::new("Blazingly application", env!("CARGO_PKG_VERSION"))
70    }
71}
72
73/// One runtime-neutral `OpenAPI` HTTP asset.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct OpenApiAssetResponse {
76    pub status: u16,
77    pub headers: BTreeMap<String, String>,
78    pub body: Vec<u8>,
79}
80
81/// Precompiled `/openapi.json` and Scalar/Swagger UI assets.
82///
83/// Document generation and HTML assembly happen once during application
84/// construction, never on the request hot path.
85#[derive(Clone, Debug)]
86pub struct OpenApiService {
87    config: OpenApiConfig,
88    document: Vec<u8>,
89    ui: Option<Vec<u8>>,
90}
91
92impl OpenApiService {
93    #[must_use]
94    pub fn new(app: &AppDefinition, config: OpenApiConfig) -> Self {
95        let document = to_value_with_config(app, &config).to_string().into_bytes();
96        let ui = match config.ui {
97            OpenApiUi::Scalar => Some(scalar_html(&config).into_bytes()),
98            OpenApiUi::Swagger => Some(swagger_html(&config).into_bytes()),
99            OpenApiUi::Disabled => None,
100        };
101        Self {
102            config,
103            document,
104            ui,
105        }
106    }
107
108    /// Returns a precompiled response when `path` belongs to this service.
109    #[must_use]
110    pub fn handle(
111        &self,
112        method: blazingly_core::HttpMethod,
113        path: &str,
114    ) -> Option<OpenApiAssetResponse> {
115        let (body, content_type) = if path == self.config.document_path {
116            (&self.document, "application/json")
117        } else if path == self.config.ui_path {
118            (self.ui.as_ref()?, "text/html; charset=utf-8")
119        } else {
120            return None;
121        };
122        if !matches!(
123            method,
124            blazingly_core::HttpMethod::Get | blazingly_core::HttpMethod::Head
125        ) {
126            return Some(OpenApiAssetResponse {
127                status: 405,
128                headers: BTreeMap::from([
129                    ("allow".to_owned(), "GET, HEAD".to_owned()),
130                    (
131                        "content-type".to_owned(),
132                        "text/plain; charset=utf-8".to_owned(),
133                    ),
134                ]),
135                body: b"OpenAPI assets only support GET and HEAD".to_vec(),
136            });
137        }
138        Some(OpenApiAssetResponse {
139            status: 200,
140            headers: BTreeMap::from([
141                ("content-type".to_owned(), content_type.to_owned()),
142                (
143                    "cache-control".to_owned(),
144                    "no-cache, no-store, must-revalidate".to_owned(),
145                ),
146            ]),
147            body: body.clone(),
148        })
149    }
150
151    #[must_use]
152    pub const fn config(&self) -> &OpenApiConfig {
153        &self.config
154    }
155}
156
157/// Generates a deterministic `OpenAPI` 3.1 document from the application model.
158#[must_use]
159pub fn to_value(app: &AppDefinition) -> Value {
160    to_value_with_config(app, &OpenApiConfig::default())
161}
162
163/// Generates a deterministic `OpenAPI` document with explicit application info.
164#[must_use]
165pub fn to_value_with_config(app: &AppDefinition, config: &OpenApiConfig) -> Value {
166    let mut schemas = Map::new();
167    for operation in app.operations() {
168        for input in &operation.contract.inputs {
169            collect_model(&input.ty, &mut schemas);
170        }
171        for response in &operation.contract.responses {
172            if let Some(body) = &response.body {
173                collect_model(body, &mut schemas);
174            }
175        }
176    }
177
178    // Examples resolve `$ref` against the component schemas, so every model is
179    // collected before the first operation is projected.
180    let mut paths = Map::new();
181    for operation in app.operations() {
182        let path = paths
183            .entry(operation.http.path.clone())
184            .or_insert_with(|| Value::Object(Map::new()));
185        let Value::Object(path_item) = path else {
186            unreachable!("path entries are always OpenAPI path objects");
187        };
188        path_item.insert(
189            operation.http.method.as_openapi_key().to_owned(),
190            operation_value(operation, &schemas),
191        );
192    }
193
194    let mut document = json!({
195        "openapi": "3.1.0",
196        "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
197        "info": {
198            "title": config.title,
199            "version": config.version
200        },
201        "paths": paths
202    });
203    let tags = app
204        .operations()
205        .iter()
206        .filter_map(operation_tag)
207        .collect::<BTreeSet<_>>();
208    if !tags.is_empty() {
209        document["tags"] = Value::Array(
210            tags.into_iter()
211                .map(|name| json!({ "name": name }))
212                .collect(),
213        );
214    }
215    let security_schemes = app
216        .security_schemes()
217        .iter()
218        .map(|scheme| (scheme.name.clone(), security_scheme_value(scheme)))
219        .collect::<Map<_, _>>();
220    if !schemas.is_empty() || !security_schemes.is_empty() {
221        let mut components = Map::new();
222        if !schemas.is_empty() {
223            components.insert("schemas".to_owned(), Value::Object(schemas));
224        }
225        if !security_schemes.is_empty() {
226            components.insert(
227                "securitySchemes".to_owned(),
228                Value::Object(security_schemes),
229            );
230        }
231        document["components"] = Value::Object(components);
232    }
233    document
234}
235
236#[allow(clippy::too_many_lines)]
237fn operation_value(operation: &OperationDescriptor, components: &Map<String, Value>) -> Value {
238    let mut responses = operation
239        .contract
240        .responses
241        .iter()
242        .map(|response| {
243            let mut value = json!({
244                "description": response.error_message.as_deref().unwrap_or("Successful response")
245            });
246
247            if response.error_code.is_some() {
248                value["content"] = json!({
249                    "application/json": {
250                        "schema": error_schema(response),
251                        "example": error_example(response, components)
252                    }
253                });
254            } else if let Some(body) = &response.body {
255                value["content"] = json!({
256                    (response_media_type(body)): media_type_value(schema_value(body), components)
257                });
258            }
259            if let Some(code) = &response.error_code {
260                value["x-blazingly-error-code"] = Value::String(code.clone());
261            }
262            if !response.headers.is_empty() {
263                value["headers"] = Value::Object(
264                    response
265                        .headers
266                        .iter()
267                        .map(|header| {
268                            (
269                                header.name.clone(),
270                                json!({
271                                    "schema": { "type": "string" },
272                                    "example": header.value
273                                }),
274                            )
275                        })
276                        .collect(),
277                );
278            }
279
280            (response.status.to_string(), value)
281        })
282        .collect::<Map<_, _>>();
283
284    // Derived, never declared: an input that is decoded at all can be
285    // rejected before the handler runs — a malformed JSON body needs no rule
286    // to fail — and an operation that declares its own 422 keeps the one it
287    // declared.
288    if let Some(codes) = rejection_codes(operation)
289        && !responses.contains_key(REJECTION_STATUS)
290    {
291        responses.insert(
292            REJECTION_STATUS.to_owned(),
293            rejection_response(&codes, components),
294        );
295    }
296
297    let mut value = json!({
298        "operationId": operation.contract.id.as_str(),
299        "summary": operation.contract.summary,
300        "responses": responses,
301        "x-blazingly-agent": operation.contract.agent
302    });
303    if let Some(tag) = operation_tag(operation) {
304        value["tags"] = json!([tag]);
305    }
306    if let Some(description) = operation_description(operation) {
307        value["description"] = Value::String(description.to_owned());
308    }
309    if !operation.contract.dependencies.is_empty() {
310        value["x-blazingly-dependencies"] = Value::Array(
311            operation
312                .contract
313                .dependencies
314                .iter()
315                .map(|dependency| Value::String(dependency.rust_name.clone()))
316                .collect(),
317        );
318    }
319    if !operation.contract.security.is_empty() {
320        let requirements = operation
321            .contract
322            .security
323            .iter()
324            .map(|requirement| (requirement.scheme.clone(), json!(requirement.scopes)))
325            .collect::<Map<_, _>>();
326        value["security"] = Value::Array(vec![Value::Object(requirements)]);
327    }
328
329    let parameters = operation
330        .contract
331        .inputs
332        .iter()
333        .filter(|input| {
334            matches!(
335                input.source,
336                InputSource::Path | InputSource::Query | InputSource::Header | InputSource::Cookie
337            )
338        })
339        .flat_map(|input| parameter_values(input, components))
340        .collect::<Vec<_>>();
341    if !parameters.is_empty() {
342        value["parameters"] = Value::Array(parameters);
343    }
344
345    if let Some(input) = operation.contract.inputs.iter().find(|input| {
346        matches!(
347            input.source,
348            InputSource::Json
349                | InputSource::Form
350                | InputSource::Multipart
351                | InputSource::File
352                | InputSource::Stream
353        )
354    }) {
355        value["requestBody"] = json!({
356            "required": input.required,
357            "content": {
358                (request_media_type(input.source)):
359                    media_type_value(schema_value(&input.ty), components)
360            }
361        });
362    }
363
364    if let Some(tool) = &operation.contract.mcp {
365        value["x-blazingly-mcp"] = json!({
366            "name": tool.name,
367            "description": tool.description,
368            "risk": operation.contract.agent.risk,
369            "confirmation": operation.contract.agent.confirmation,
370            "idempotent": operation.contract.agent.idempotent,
371            "outputExposure": tool.expose_output
372        });
373    }
374
375    value
376}
377
378/// The section a browser UI groups this operation under.
379///
380/// The operation model has no tag field, so the group is the namespace of the
381/// stable operation identity: `users.create` and `users.list` both belong to
382/// `users`, and `billing.invoices.void` belongs to `billing.invoices`. An
383/// identity without a namespace stays untagged rather than becoming a section
384/// of its own.
385fn operation_tag(operation: &OperationDescriptor) -> Option<&str> {
386    operation
387        .contract
388        .id
389        .as_str()
390        .rsplit_once('.')
391        .map(|(namespace, _)| namespace)
392        .filter(|namespace| !namespace.is_empty())
393}
394
395/// Prose shown below the summary in a browser UI.
396///
397/// The contract carries one long-form description, the one an operation
398/// declares for agents; it defaults to the summary, so it is only projected
399/// when it says something the summary does not.
400fn operation_description(operation: &OperationDescriptor) -> Option<&str> {
401    let description = operation.contract.mcp.as_ref()?.description.as_str();
402    (!description.is_empty() && description != operation.contract.summary).then_some(description)
403}
404
405/// A media type entry carrying a schema and, when derivable, a sample payload.
406fn media_type_value(schema: Value, components: &Map<String, Value>) -> Value {
407    let example = example_for_schema(&schema, components, MAX_EXAMPLE_DEPTH);
408    let mut value = Map::new();
409    if !example.is_null() {
410        value.insert("example".to_owned(), example);
411    }
412    value.insert("schema".to_owned(), schema);
413    Value::Object(value)
414}
415
416/// The error envelope a failing operation actually returns.
417fn error_example(
418    response: &blazingly_core::ResponseDescriptor,
419    components: &Map<String, Value>,
420) -> Value {
421    let mut error = json!({
422        "code": response.error_code.as_deref().unwrap_or_default(),
423        "message": response.error_message.as_deref().unwrap_or_default()
424    });
425    if let Some(details) = &response.body {
426        error["details"] =
427            example_for_schema(&schema_value(details), components, MAX_EXAMPLE_DEPTH);
428    }
429    json!({ "error": error })
430}
431
432/// Builds a sample payload from a generated schema node.
433///
434/// Deriving the example from the schema rather than from the descriptor means
435/// every keyword the schema already carries — `format`, `minLength`, `const`,
436/// `minimum`, and anything a later projection adds — constrains the sample
437/// without a second traversal of the operation model.
438fn example_for_schema(schema: &Value, components: &Map<String, Value>, depth: usize) -> Value {
439    let (Some(object), 1..) = (schema.as_object(), depth) else {
440        return Value::Null;
441    };
442    for keyword in ["example", "default", "const"] {
443        if let Some(value) = object.get(keyword) {
444            return value.clone();
445        }
446    }
447    if let Some(first) = object
448        .get("enum")
449        .and_then(Value::as_array)
450        .and_then(|values| values.first())
451    {
452        return first.clone();
453    }
454    if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
455        let name = reference
456            .rsplit_once('/')
457            .map_or(reference, |(_, name)| name);
458        return components.get(name).map_or(Value::Null, |target| {
459            example_for_schema(target, components, depth - 1)
460        });
461    }
462
463    match schema_type(object) {
464        Some("object") => example_object(object, components, depth),
465        Some("array") => example_array(object, components, depth),
466        Some("string") => Value::String(example_string(object)),
467        Some("integer") => json!(example_integer(object)),
468        Some("number") => json!(example_number(object)),
469        Some("boolean") => Value::Bool(true),
470        _ => Value::Null,
471    }
472}
473
474/// The declared type, skipping the `"null"` member of a nullable union.
475fn schema_type(schema: &Map<String, Value>) -> Option<&str> {
476    match schema.get("type")? {
477        Value::String(name) => Some(name.as_str()),
478        Value::Array(names) => names
479            .iter()
480            .filter_map(Value::as_str)
481            .find(|name| *name != "null"),
482        _ => None,
483    }
484}
485
486fn example_object(
487    schema: &Map<String, Value>,
488    components: &Map<String, Value>,
489    depth: usize,
490) -> Value {
491    let Some(Value::Object(properties)) = schema.get("properties") else {
492        return Value::Object(Map::new());
493    };
494    Value::Object(
495        properties
496            .iter()
497            .map(|(name, property)| {
498                (
499                    name.clone(),
500                    example_for_schema(property, components, depth - 1),
501                )
502            })
503            .collect(),
504    )
505}
506
507fn example_array(
508    schema: &Map<String, Value>,
509    components: &Map<String, Value>,
510    depth: usize,
511) -> Value {
512    let item = schema.get("items").map_or(Value::Null, |items| {
513        example_for_schema(items, components, depth - 1)
514    });
515    let items = schema
516        .get("minItems")
517        .and_then(Value::as_u64)
518        .unwrap_or(1)
519        .clamp(1, 3);
520    Value::Array(vec![item; usize::try_from(items).unwrap_or(1)])
521}
522
523/// A sample string honouring the format, then the declared length window.
524///
525/// A formatted sample is returned verbatim: trimming an address to a
526/// `maxLength` would only produce a payload the same document rejects.
527fn example_string(schema: &Map<String, Value>) -> String {
528    let sample = match schema.get("format").and_then(Value::as_str) {
529        Some("email") => return "user@example.com".to_owned(),
530        Some("uuid") => return "00000000-0000-4000-8000-000000000000".to_owned(),
531        Some("uri") => return "https://example.com".to_owned(),
532        Some("ip") => return "192.0.2.1".to_owned(),
533        Some("date") => return "2024-01-01".to_owned(),
534        Some("date-time") => return "2024-01-01T00:00:00Z".to_owned(),
535        Some("decimal") => return "1.00".to_owned(),
536        Some("binary") => return "ZXhhbXBsZQ==".to_owned(),
537        _ => "example",
538    };
539
540    let minimum = schema
541        .get("minLength")
542        .and_then(Value::as_u64)
543        .unwrap_or(0)
544        .min(64);
545    let maximum = schema
546        .get("maxLength")
547        .and_then(Value::as_u64)
548        .unwrap_or(u64::MAX);
549    let mut value = sample.to_owned();
550    while u64::try_from(value.len()).unwrap_or(u64::MAX) < minimum {
551        value.push('x');
552    }
553    if u64::try_from(value.len()).unwrap_or(u64::MAX) > maximum {
554        value.truncate(usize::try_from(maximum).unwrap_or(usize::MAX));
555    }
556    value
557}
558
559fn example_integer(schema: &Map<String, Value>) -> i64 {
560    let mut value = 1_i64;
561    if let Some(minimum) = schema.get("minimum").and_then(Value::as_i64) {
562        value = value.max(minimum);
563    }
564    if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_i64) {
565        value = value.max(minimum.saturating_add(1));
566    }
567    if let Some(maximum) = schema.get("maximum").and_then(Value::as_i64) {
568        value = value.min(maximum);
569    }
570    if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_i64) {
571        value = value.min(maximum.saturating_sub(1));
572    }
573    value
574}
575
576fn example_number(schema: &Map<String, Value>) -> f64 {
577    let mut value = 1.0_f64;
578    if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) {
579        value = value.max(minimum);
580    }
581    if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) {
582        value = value.min(maximum);
583    }
584    value
585}
586
587fn response_media_type(descriptor: &TypeDescriptor) -> &'static str {
588    if matches!(descriptor.schema, SchemaKind::Binary) {
589        "application/octet-stream"
590    } else {
591        "application/json"
592    }
593}
594
595fn scalar_html(config: &OpenApiConfig) -> String {
596    let title = escape_html(&config.title);
597    let document_path = blazingly_json::to_string(&config.document_path)
598        .unwrap_or_else(|_| "\"/openapi.json\"".into());
599    format!(
600        concat!(
601            "<!doctype html><html><head><meta charset=\"utf-8\">",
602            "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
603            "<title>{title}</title></head><body>",
604            "<script id=\"api-reference\" data-url={document_path}></script>",
605            "<script src=\"https://cdn.jsdelivr.net/npm/@scalar/api-reference\"></script>",
606            "</body></html>"
607        ),
608        title = title,
609        document_path = document_path,
610    )
611}
612
613fn swagger_html(config: &OpenApiConfig) -> String {
614    let title = escape_html(&config.title);
615    let document_path = blazingly_json::to_string(&config.document_path)
616        .unwrap_or_else(|_| "\"/openapi.json\"".into());
617    format!(
618        concat!(
619            "<!doctype html><html><head><meta charset=\"utf-8\">",
620            "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
621            "<title>{title}</title>",
622            "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui.css\">",
623            "</head><body><div id=\"swagger-ui\"></div>",
624            "<script src=\"https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-bundle.js\"></script>",
625            "<script>SwaggerUIBundle({{url:{document_path},dom_id:'#swagger-ui'}});</script>",
626            "</body></html>"
627        ),
628        title = title,
629        document_path = document_path,
630    )
631}
632
633fn escape_html(value: &str) -> String {
634    value
635        .replace('&', "&amp;")
636        .replace('<', "&lt;")
637        .replace('>', "&gt;")
638        .replace('"', "&quot;")
639        .replace('\'', "&#39;")
640}
641
642/// The status every input rejection is reported under.
643const REJECTION_STATUS: &str = "422";
644
645/// Every code a rejection can carry, in the order a reader most wants them.
646///
647/// `validation_error` leads because it is the failure a well-formed request
648/// still meets, and the sample payload is derived from the first entry.
649const REJECTION_CODES: [&str; 6] = [
650    "validation_error",
651    "missing_input",
652    "invalid_json",
653    "invalid_input",
654    "invalid_multipart",
655    "invalid_file_count",
656];
657
658/// The codes an input from this source can be rejected with.
659///
660/// This mirrors the executor: a value is decoded, then validated, and each step
661/// answers with its own code. Bytes that reach the handler untouched have
662/// neither step, so a stream can produce none of them.
663fn source_rejection_codes(source: InputSource) -> &'static [&'static str] {
664    match source {
665        InputSource::Json => &["validation_error", "invalid_json"],
666        InputSource::Path
667        | InputSource::Query
668        | InputSource::Header
669        | InputSource::Cookie
670        | InputSource::Form => &["validation_error", "invalid_input"],
671        InputSource::Multipart => &["validation_error", "invalid_multipart", "invalid_input"],
672        // An upload is read out of the same multipart document, then counted.
673        // It is never validated against rules, and the decode that answers
674        // `invalid_input` is reachable only from structured arguments.
675        InputSource::File => &["invalid_multipart", "invalid_file_count"],
676        InputSource::Stream => &[],
677    }
678}
679
680/// The stable codes an operation's own inputs can be rejected with.
681///
682/// Returns `None` when nothing about the request is decoded, so an operation
683/// that only streams bytes is not given a failure it cannot produce. The set is
684/// closed and derived from the operation's own inputs: a body that is not JSON
685/// cannot fail as `invalid_json`, and an operation with nothing required cannot
686/// report `missing_input`.
687fn rejection_codes(operation: &OperationDescriptor) -> Option<Vec<&'static str>> {
688    let mut reachable = BTreeSet::new();
689    for input in &operation.contract.inputs {
690        let codes = source_rejection_codes(input.source);
691        if codes.is_empty() {
692            continue;
693        }
694        reachable.extend(codes);
695        // Only a value read out of the request one key at a time can be found
696        // missing. A JSON body is decoded whole and fails as `invalid_json`, a
697        // model is assembled from whichever of its fields arrived and fails on
698        // the field, and an upload reports its own absence by count.
699        if !matches!(input.source, InputSource::Json | InputSource::File)
700            && input.ty.model.is_none()
701            && (input.required || input.source == InputSource::Path)
702        {
703            reachable.insert("missing_input");
704        }
705    }
706    if reachable.is_empty() {
707        return None;
708    }
709    Some(
710        REJECTION_CODES
711            .into_iter()
712            .filter(|code| reachable.contains(code))
713            .collect(),
714    )
715}
716
717/// The rejection envelope the runtime returns before the handler is reached.
718///
719/// This response is projected from the framework's own input handling rather
720/// than declared by the operation, which `x-blazingly-automatic` records. The
721/// `violations` array is the shape a rule failure reports, one entry per broken
722/// rule, each naming the field path that broke it.
723fn rejection_response(codes: &[&str], components: &Map<String, Value>) -> Value {
724    let schema = json!({
725        "type": "object",
726        "properties": {
727            "error": {
728                "type": "object",
729                "properties": {
730                    "code": { "type": "string", "enum": codes },
731                    "message": { "type": "string" },
732                    "details": {
733                        "type": "object",
734                        "properties": {
735                            "violations": {
736                                "type": "array",
737                                "items": {
738                                    "type": "object",
739                                    "properties": {
740                                        "field": { "type": "string" },
741                                        "code": { "type": "string" },
742                                        "message": { "type": "string" }
743                                    },
744                                    "required": ["field", "code", "message"],
745                                    "additionalProperties": false
746                                }
747                            }
748                        }
749                    }
750                },
751                "required": ["code", "message"],
752                "additionalProperties": false
753            }
754        },
755        "required": ["error"],
756        "additionalProperties": false
757    });
758    json!({
759        "description": "The request was rejected before the handler ran: an input could not be decoded, or failed the rules the operation declares.",
760        "content": { "application/json": media_type_value(schema, components) },
761        "x-blazingly-automatic": true
762    })
763}
764
765fn error_schema(response: &blazingly_core::ResponseDescriptor) -> Value {
766    let mut error_properties = json!({
767        "code": {
768            "type": "string",
769            "const": response.error_code
770        },
771        "message": {
772            "type": "string"
773        }
774    });
775    let mut required = vec!["code", "message"];
776    if let Some(details) = &response.body {
777        error_properties["details"] = schema_value(details);
778        required.push("details");
779    }
780    json!({
781        "type": "object",
782        "properties": {
783            "error": {
784                "type": "object",
785                "properties": error_properties,
786                "required": required,
787                "additionalProperties": false
788            }
789        },
790        "required": ["error"],
791        "additionalProperties": false
792    })
793}
794
795fn parameter_values(input: &InputDescriptor, components: &Map<String, Value>) -> Vec<Value> {
796    let location = input_source_name(input.source);
797    if let Some(model) = &input.ty.model {
798        return model
799            .fields
800            .iter()
801            .map(|field| {
802                let mut schema = schema_value(&field.ty);
803                apply_validation(&mut schema, &field.validation);
804                parameter_value(
805                    parameter_name(input.source, &field.name),
806                    location,
807                    input.source == InputSource::Path || (input.required && field.required),
808                    schema,
809                    components,
810                )
811            })
812            .collect();
813    }
814
815    vec![parameter_value(
816        parameter_name(input.source, &input.name),
817        location,
818        input.source == InputSource::Path || input.required,
819        schema_value(&input.ty),
820        components,
821    )]
822}
823
824fn parameter_value(
825    name: String,
826    location: &'static str,
827    required: bool,
828    schema: Value,
829    components: &Map<String, Value>,
830) -> Value {
831    let example = example_for_schema(&schema, components, MAX_EXAMPLE_DEPTH);
832    let mut value = Map::new();
833    value.insert("name".to_owned(), Value::String(name));
834    value.insert("in".to_owned(), Value::String(location.to_owned()));
835    value.insert("required".to_owned(), Value::Bool(required));
836    value.insert("schema".to_owned(), schema);
837    if !example.is_null() {
838        value.insert("example".to_owned(), example);
839    }
840    Value::Object(value)
841}
842
843fn parameter_name(source: InputSource, name: &str) -> String {
844    if source == InputSource::Header {
845        name.replace('_', "-")
846    } else {
847        name.to_owned()
848    }
849}
850
851fn input_source_name(source: InputSource) -> &'static str {
852    match source {
853        InputSource::Path => "path",
854        InputSource::Query => "query",
855        InputSource::Header => "header",
856        InputSource::Cookie => "cookie",
857        InputSource::Json
858        | InputSource::Form
859        | InputSource::Multipart
860        | InputSource::File
861        | InputSource::Stream => {
862            unreachable!("body inputs are OpenAPI request bodies")
863        }
864    }
865}
866
867fn request_media_type(source: InputSource) -> &'static str {
868    match source {
869        InputSource::Json => "application/json",
870        InputSource::Form => "application/x-www-form-urlencoded",
871        InputSource::Multipart | InputSource::File => "multipart/form-data",
872        InputSource::Stream => "application/octet-stream",
873        InputSource::Path | InputSource::Query | InputSource::Header | InputSource::Cookie => {
874            unreachable!("parameter inputs do not have a request body media type")
875        }
876    }
877}
878
879/// The format decisions that make the shared projection an `OpenAPI` one.
880///
881/// A model appears as a `$ref` into `#/components/schemas` — the component
882/// itself is written once by [`collect_model`] — and raw bytes are spelled
883/// `format: "binary"`. Declarative constraints that predate a contract
884/// variant are decoded by the optional constraint reader.
885struct OpenApiDialect;
886
887impl blazingly_core::schema::SchemaDialect for OpenApiDialect {
888    fn model_node(&self, descriptor: &TypeDescriptor, model: &ModelDescriptor) -> Value {
889        json!({
890            "$ref": format!("#/components/schemas/{}", model.name),
891            "x-rust-type": descriptor.rust_name
892        })
893    }
894
895    fn binary_node(&self) -> Value {
896        json!({ "type": "string", "format": "binary" })
897    }
898
899    #[cfg(feature = "validation")]
900    fn project_custom_validator(&self, schema: &mut Value, validator: &str) -> bool {
901        let Some(constraint) = blazingly_validation::Constraint::parse(validator) else {
902            return false;
903        };
904        constraint.apply_json_schema(schema);
905        true
906    }
907}
908
909fn schema_value(descriptor: &TypeDescriptor) -> Value {
910    blazingly_core::schema::schema_value(&OpenApiDialect, descriptor)
911}
912
913fn collect_model(descriptor: &TypeDescriptor, schemas: &mut Map<String, Value>) {
914    if let Some(items) = &descriptor.items {
915        collect_model(items, schemas);
916    }
917    if let Some(model) = &descriptor.model {
918        if schemas.contains_key(&model.name) {
919            return;
920        }
921        schemas.insert(model.name.clone(), model_schema(model));
922        for field in &model.fields {
923            collect_model(&field.ty, schemas);
924        }
925    }
926}
927
928fn security_scheme_value(scheme: &SecuritySchemeDescriptor) -> Value {
929    let mut value = match &scheme.kind {
930        SecuritySchemeKind::ApiKey { location, name } => json!({
931            "type": "apiKey",
932            "in": match location {
933                SecurityLocation::Header => "header",
934                SecurityLocation::Query => "query",
935                SecurityLocation::Cookie => "cookie",
936            },
937            "name": name
938        }),
939        SecuritySchemeKind::Http {
940            scheme,
941            bearer_format,
942        } => {
943            let mut value = json!({ "type": "http", "scheme": scheme });
944            if let Some(bearer_format) = bearer_format {
945                value["bearerFormat"] = Value::String(bearer_format.clone());
946            }
947            value
948        }
949        SecuritySchemeKind::OAuth2 {
950            authorization_url,
951            token_url,
952            scopes,
953        } => {
954            let scopes = scopes
955                .iter()
956                .map(|scope| (scope.clone(), Value::String(String::new())))
957                .collect::<Map<_, _>>();
958            let mut flows = Map::new();
959            match (authorization_url, token_url) {
960                (Some(authorization_url), Some(token_url)) => {
961                    flows.insert(
962                        "authorizationCode".to_owned(),
963                        json!({
964                            "authorizationUrl": authorization_url,
965                            "tokenUrl": token_url,
966                            "scopes": scopes
967                        }),
968                    );
969                }
970                (Some(authorization_url), None) => {
971                    flows.insert(
972                        "implicit".to_owned(),
973                        json!({ "authorizationUrl": authorization_url, "scopes": scopes }),
974                    );
975                }
976                (None, Some(token_url)) => {
977                    flows.insert(
978                        "clientCredentials".to_owned(),
979                        json!({ "tokenUrl": token_url, "scopes": scopes }),
980                    );
981                }
982                (None, None) => {}
983            }
984            json!({ "type": "oauth2", "flows": flows })
985        }
986        SecuritySchemeKind::OpenIdConnect { discovery_url } => {
987            json!({ "type": "openIdConnect", "openIdConnectUrl": discovery_url })
988        }
989        SecuritySchemeKind::MutualTls => json!({ "type": "mutualTLS" }),
990    };
991    if let Some(description) = &scheme.description {
992        value["description"] = Value::String(description.clone());
993    }
994    value
995}
996
997fn model_schema(model: &ModelDescriptor) -> Value {
998    blazingly_core::schema::model_schema(&OpenApiDialect, model)
999}
1000
1001fn apply_validation(schema: &mut Value, validation: &[ValidationRule]) {
1002    blazingly_core::schema::apply_validation(&OpenApiDialect, schema, validation);
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use blazingly_core::{
1008        AgentPolicy, App, FieldDescriptor, HttpMethod, InputDescriptor, InputSource,
1009        McpToolDescriptor, ModelDescriptor, OperationDescriptor, ResponseDescriptor, SchemaKind,
1010        SecurityRequirement, SecuritySchemeDescriptor, SecuritySchemeKind, TypeDescriptor,
1011        ValidationRule,
1012    };
1013
1014    fn create_user_model() -> ModelDescriptor {
1015        ModelDescriptor::new(
1016            "CreateUser",
1017            vec![
1018                FieldDescriptor::new(
1019                    "name",
1020                    true,
1021                    TypeDescriptor::scalar("String", SchemaKind::String),
1022                    vec![ValidationRule::MinLength(12)],
1023                ),
1024                FieldDescriptor::new(
1025                    "email",
1026                    true,
1027                    TypeDescriptor::scalar("String", SchemaKind::String),
1028                    vec![ValidationRule::Email],
1029                ),
1030                FieldDescriptor::new(
1031                    "age",
1032                    false,
1033                    TypeDescriptor::scalar("u8", SchemaKind::Integer),
1034                    Vec::new(),
1035                ),
1036            ],
1037        )
1038    }
1039
1040    #[test]
1041    fn openapi_is_projected_from_the_operation_model() {
1042        let operation = OperationDescriptor::new(
1043            HttpMethod::Post,
1044            "/users",
1045            "users.create",
1046            "Create a user",
1047            Some(TypeDescriptor::new("CreateUser")),
1048            vec![ResponseDescriptor::success(
1049                201,
1050                Some(TypeDescriptor::new("UserView")),
1051            )],
1052        )
1053        .expect("operation should be valid");
1054        let app = App::new()
1055            .route(operation)
1056            .build()
1057            .expect("application should be valid");
1058
1059        let document = super::to_value(&app);
1060
1061        assert_eq!(
1062            document["paths"]["/users"]["post"]["operationId"],
1063            "users.create"
1064        );
1065        assert_eq!(
1066            document["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"]
1067                ["x-rust-type"],
1068            "CreateUser"
1069        );
1070        assert_eq!(
1071            document["paths"]["/users"]["post"]["responses"]["201"]["content"]["application/json"]
1072                ["schema"]["x-rust-type"],
1073            "UserView"
1074        );
1075    }
1076
1077    #[test]
1078    fn openapi_projects_registered_security_and_operation_scopes() {
1079        let operation = OperationDescriptor::new(
1080            HttpMethod::Get,
1081            "/users",
1082            "users.list",
1083            "List users",
1084            None,
1085            vec![ResponseDescriptor::success(200, None)],
1086        )
1087        .unwrap()
1088        .with_security(vec![
1089            SecurityRequirement::new("oauth").with_scopes(vec!["users:read".to_owned()]),
1090        ]);
1091        let app = App::new()
1092            .route(operation)
1093            .security_scheme(SecuritySchemeDescriptor::new(
1094                "oauth",
1095                SecuritySchemeKind::OAuth2 {
1096                    authorization_url: Some("https://auth.example/authorize".to_owned()),
1097                    token_url: Some("https://auth.example/token".to_owned()),
1098                    scopes: vec!["users:read".to_owned()],
1099                },
1100            ))
1101            .build()
1102            .unwrap();
1103
1104        let document = super::to_value(&app);
1105        assert_eq!(
1106            document["components"]["securitySchemes"]["oauth"]["flows"]["authorizationCode"]["tokenUrl"],
1107            "https://auth.example/token"
1108        );
1109        assert_eq!(
1110            document["paths"]["/users"]["get"]["security"][0]["oauth"][0],
1111            "users:read"
1112        );
1113    }
1114
1115    #[test]
1116    fn operations_are_grouped_by_the_namespace_of_their_identity() {
1117        let create = OperationDescriptor::new(
1118            HttpMethod::Post,
1119            "/users",
1120            "users.create",
1121            "Create a user",
1122            None,
1123            vec![ResponseDescriptor::success(201, None)],
1124        )
1125        .unwrap();
1126        let list = OperationDescriptor::new(
1127            HttpMethod::Get,
1128            "/users",
1129            "users.list",
1130            "List users",
1131            None,
1132            vec![ResponseDescriptor::success(200, None)],
1133        )
1134        .unwrap();
1135        let health = OperationDescriptor::new(
1136            HttpMethod::Get,
1137            "/health",
1138            "health",
1139            "Report health",
1140            None,
1141            vec![ResponseDescriptor::success(200, None)],
1142        )
1143        .unwrap();
1144        let app = App::new()
1145            .route(create)
1146            .route(list)
1147            .route(health)
1148            .build()
1149            .unwrap();
1150
1151        let document = super::to_value(&app);
1152
1153        assert_eq!(document["paths"]["/users"]["post"]["tags"][0], "users");
1154        assert_eq!(document["paths"]["/users"]["get"]["tags"][0], "users");
1155        assert_eq!(
1156            document["tags"].as_array().map(Vec::len),
1157            Some(1),
1158            "one section per namespace: {}",
1159            document["tags"]
1160        );
1161        assert_eq!(document["tags"][0]["name"], "users");
1162        assert!(
1163            document["paths"]["/health"]["get"]["tags"].is_null(),
1164            "an identity without a namespace stays untagged"
1165        );
1166    }
1167
1168    #[test]
1169    fn a_long_description_is_projected_only_when_it_adds_to_the_summary() {
1170        let described = OperationDescriptor::new(
1171            HttpMethod::Post,
1172            "/users",
1173            "users.create",
1174            "Create a user",
1175            None,
1176            vec![ResponseDescriptor::success(201, None)],
1177        )
1178        .unwrap()
1179        .with_mcp_tool(
1180            McpToolDescriptor::new("create_user", "Registers one user and returns its view."),
1181            AgentPolicy::default(),
1182        );
1183        let echoed = OperationDescriptor::new(
1184            HttpMethod::Get,
1185            "/users",
1186            "users.list",
1187            "List users",
1188            None,
1189            vec![ResponseDescriptor::success(200, None)],
1190        )
1191        .unwrap()
1192        .with_mcp_tool(
1193            McpToolDescriptor::new("list_users", "List users"),
1194            AgentPolicy::default(),
1195        );
1196        let app = App::new().route(described).route(echoed).build().unwrap();
1197
1198        let document = super::to_value(&app);
1199
1200        assert_eq!(
1201            document["paths"]["/users"]["post"]["description"],
1202            "Registers one user and returns its view."
1203        );
1204        assert!(document["paths"]["/users"]["get"]["description"].is_null());
1205    }
1206
1207    #[test]
1208    fn bodies_and_parameters_carry_examples_that_satisfy_their_own_schema() {
1209        let operation = OperationDescriptor::new(
1210            HttpMethod::Post,
1211            "/tenants/{tenant_id}/users",
1212            "users.create",
1213            "Create a user",
1214            None,
1215            vec![
1216                ResponseDescriptor::success(201, Some(TypeDescriptor::model(create_user_model()))),
1217                ResponseDescriptor::error(
1218                    409,
1219                    "email_already_exists",
1220                    "A user with this email already exists.",
1221                    None,
1222                ),
1223            ],
1224        )
1225        .unwrap()
1226        .with_inputs(vec![
1227            InputDescriptor::new(
1228                "tenant_id",
1229                InputSource::Path,
1230                true,
1231                TypeDescriptor::scalar("Uuid", SchemaKind::String),
1232            ),
1233            InputDescriptor::new(
1234                "body",
1235                InputSource::Json,
1236                true,
1237                TypeDescriptor::model(create_user_model()),
1238            ),
1239        ]);
1240        let app = App::new().route(operation).build().unwrap();
1241
1242        let document = super::to_value(&app);
1243        let operation = &document["paths"]["/tenants/{tenant_id}/users"]["post"];
1244
1245        let request = &operation["requestBody"]["content"]["application/json"]["example"];
1246        assert_eq!(request["email"], "user@example.com");
1247        assert_eq!(
1248            request["name"], "examplexxxxx",
1249            "a sample must reach its own minLength"
1250        );
1251        assert_eq!(request["age"], 1);
1252        assert_eq!(
1253            operation["responses"]["201"]["content"]["application/json"]["example"]["email"],
1254            "user@example.com"
1255        );
1256        assert_eq!(operation["parameters"][0]["name"], "tenant_id");
1257        assert_eq!(
1258            operation["parameters"][0]["example"],
1259            "00000000-0000-4000-8000-000000000000"
1260        );
1261
1262        let failure = &operation["responses"]["409"]["content"]["application/json"]["example"];
1263        assert_eq!(failure["error"]["code"], "email_already_exists");
1264        assert_eq!(
1265            failure["error"]["message"],
1266            "A user with this email already exists."
1267        );
1268    }
1269
1270    #[test]
1271    fn every_declared_tag_and_example_keeps_the_document_well_formed() {
1272        let operation = OperationDescriptor::new(
1273            HttpMethod::Post,
1274            "/users",
1275            "users.create",
1276            "Create a user",
1277            Some(TypeDescriptor::model(create_user_model())),
1278            vec![
1279                ResponseDescriptor::success(201, Some(TypeDescriptor::model(create_user_model()))),
1280                ResponseDescriptor::error(409, "conflict", "Already exists.", None),
1281            ],
1282        )
1283        .unwrap();
1284        let app = App::new().route(operation).build().unwrap();
1285
1286        let document = super::to_value(&app);
1287        let declared = document["tags"]
1288            .as_array()
1289            .expect("a grouped document declares its tags")
1290            .iter()
1291            .map(|tag| {
1292                tag["name"]
1293                    .as_str()
1294                    .expect("every tag object names a section")
1295                    .to_owned()
1296            })
1297            .collect::<Vec<_>>();
1298        assert_eq!(declared, ["users"]);
1299
1300        for (_, path_item) in document["paths"].as_object().expect("paths is an object") {
1301            for (_, operation) in path_item.as_object().expect("a path item is an object") {
1302                for tag in operation["tags"].as_array().into_iter().flatten() {
1303                    let tag = tag.as_str().expect("an operation tag is a string");
1304                    assert!(
1305                        declared.iter().any(|declared| declared == tag),
1306                        "operation tag {tag} is not declared at the document root"
1307                    );
1308                }
1309                for (_, response) in operation["responses"]
1310                    .as_object()
1311                    .expect("responses is an object")
1312                {
1313                    for (_, media) in response["content"].as_object().into_iter().flatten() {
1314                        assert!(
1315                            !media["schema"].is_null(),
1316                            "an example must accompany a schema, not replace it"
1317                        );
1318                    }
1319                }
1320            }
1321        }
1322    }
1323
1324    #[test]
1325    fn recorded_defaults_enumerations_and_nullability_use_openapi_31_spelling() {
1326        let model = ModelDescriptor::new(
1327            "Article",
1328            vec![
1329                FieldDescriptor::new(
1330                    "status",
1331                    false,
1332                    TypeDescriptor::scalar("String", SchemaKind::String),
1333                    vec![
1334                        ValidationRule::Custom("enum=draft|published".to_owned()),
1335                        ValidationRule::Custom("default=\"draft\"".to_owned()),
1336                    ],
1337                ),
1338                FieldDescriptor::new(
1339                    "subtitle",
1340                    false,
1341                    TypeDescriptor::scalar("String", SchemaKind::String),
1342                    vec![ValidationRule::Custom("nullable=true".to_owned())],
1343                ),
1344                FieldDescriptor::new(
1345                    "author",
1346                    false,
1347                    TypeDescriptor::model(create_user_model()),
1348                    vec![ValidationRule::Custom("nullable=true".to_owned())],
1349                ),
1350            ],
1351        );
1352        let operation = OperationDescriptor::new(
1353            HttpMethod::Post,
1354            "/articles",
1355            "articles.create",
1356            "Create an article",
1357            Some(TypeDescriptor::model(model)),
1358            vec![ResponseDescriptor::success(201, None)],
1359        )
1360        .unwrap();
1361        let app = App::new().route(operation).build().unwrap();
1362
1363        let document = super::to_value(&app);
1364        let properties = &document["components"]["schemas"]["Article"]["properties"];
1365
1366        assert_eq!(properties["status"]["default"], "draft");
1367        assert_eq!(properties["status"]["enum"][0], "draft");
1368        assert_eq!(properties["status"]["enum"][1], "published");
1369        assert_eq!(
1370            properties["subtitle"]["type"],
1371            blazingly_json::json!(["string", "null"]),
1372            "3.1 has no `nullable` keyword"
1373        );
1374        assert_eq!(
1375            properties["author"]["anyOf"][0]["$ref"], "#/components/schemas/CreateUser",
1376            "a nullable reference widens through anyOf"
1377        );
1378        assert_eq!(properties["author"]["anyOf"][1]["type"], "null");
1379        assert!(
1380            properties["status"]["x-blazingly-validators"].is_null(),
1381            "recovered metadata must not also appear as an opaque validator"
1382        );
1383        assert_eq!(
1384            document["paths"]["/articles"]["post"]["requestBody"]["content"]["application/json"]["example"]
1385                ["status"],
1386            "draft",
1387            "a declared default is the most useful sample value"
1388        );
1389    }
1390
1391    /// `#[api_model] #[min_length(1)] #[max_length(20)] struct Tag(String);`
1392    fn tag() -> TypeDescriptor {
1393        TypeDescriptor::scalar("Tag", SchemaKind::String).with_constraints(vec![
1394            ValidationRule::MinLength(1),
1395            ValidationRule::MaxLength(20),
1396        ])
1397    }
1398
1399    fn collection_of(item: TypeDescriptor) -> TypeDescriptor {
1400        TypeDescriptor {
1401            rust_name: format!("Vec<{}>", item.rust_name),
1402            schema: SchemaKind::Array(Box::new(item.schema.clone())),
1403            model: None,
1404            items: Some(Box::new(item)),
1405            constraints: Vec::new(),
1406        }
1407    }
1408
1409    fn create_note_model() -> ModelDescriptor {
1410        ModelDescriptor::new(
1411            "CreateNote",
1412            vec![
1413                FieldDescriptor::new(
1414                    "tags",
1415                    true,
1416                    collection_of(tag()),
1417                    vec![ValidationRule::Custom("max_items=5".to_owned())],
1418                ),
1419                FieldDescriptor::new("primary", true, tag(), Vec::new()),
1420                FieldDescriptor::new(
1421                    "groups",
1422                    true,
1423                    collection_of(collection_of(tag())),
1424                    Vec::new(),
1425                ),
1426            ],
1427        )
1428    }
1429
1430    fn note_operation() -> OperationDescriptor {
1431        OperationDescriptor::new(
1432            HttpMethod::Post,
1433            "/notes",
1434            "notes.create",
1435            "Create a note",
1436            Some(TypeDescriptor::model(create_note_model())),
1437            vec![ResponseDescriptor::success(201, None)],
1438        )
1439        .unwrap()
1440    }
1441
1442    #[test]
1443    fn a_value_types_bounds_reach_every_place_the_type_appears() {
1444        let app = App::new().route(note_operation()).build().unwrap();
1445
1446        let document = super::to_value(&app);
1447        let properties = &document["components"]["schemas"]["CreateNote"]["properties"];
1448
1449        let item = &properties["tags"]["items"];
1450        assert_eq!(item["x-rust-type"], "Tag");
1451        assert_eq!(item["minLength"], 1, "a collection item keeps its bounds");
1452        assert_eq!(item["maxLength"], 20);
1453        // `max_items` travels in the `Custom` channel, which only the
1454        // constraint reader turned on by `validation` can decode.
1455        #[cfg(feature = "validation")]
1456        assert_eq!(
1457            properties["tags"]["maxItems"], 5,
1458            "the field's own bound still describes the collection"
1459        );
1460
1461        assert_eq!(properties["primary"]["minLength"], 1);
1462        assert_eq!(properties["primary"]["maxLength"], 20);
1463
1464        let nested = &properties["groups"]["items"]["items"];
1465        assert_eq!(nested["x-rust-type"], "Tag");
1466        assert_eq!(nested["minLength"], 1, "nesting does not lose the bounds");
1467        assert_eq!(nested["maxLength"], 20);
1468    }
1469
1470    #[test]
1471    fn an_inherited_rule_is_not_listed_twice() {
1472        let validated = TypeDescriptor::scalar("Slug", SchemaKind::String)
1473            .with_constraints(vec![ValidationRule::Custom("check_slug".to_owned())]);
1474        let model = ModelDescriptor::new(
1475            "Page",
1476            vec![FieldDescriptor::new(
1477                "slug",
1478                true,
1479                validated,
1480                // What `#[api_model]` records on a field declared with the type.
1481                vec![ValidationRule::Custom("check_slug".to_owned())],
1482            )],
1483        );
1484        let operation = OperationDescriptor::new(
1485            HttpMethod::Post,
1486            "/pages",
1487            "pages.create",
1488            "Create a page",
1489            Some(TypeDescriptor::model(model)),
1490            vec![ResponseDescriptor::success(201, None)],
1491        )
1492        .unwrap();
1493        let app = App::new().route(operation).build().unwrap();
1494
1495        let document = super::to_value(&app);
1496        assert_eq!(
1497            document["components"]["schemas"]["Page"]["properties"]["slug"]["x-blazingly-validators"],
1498            blazingly_json::json!(["check_slug"])
1499        );
1500    }
1501
1502    /// An item's whole bundle projects onto the item, and only onto the item.
1503    #[test]
1504    fn an_items_bundle_stays_off_the_collection_that_holds_it() {
1505        let channel = TypeDescriptor::scalar("Channel", SchemaKind::String).with_constraints(vec![
1506            ValidationRule::MaxLength(16),
1507            ValidationRule::Custom("enum=news|sport".to_owned()),
1508            ValidationRule::Custom("pattern=^[a-z]+$".to_owned()),
1509        ]);
1510        let model = ModelDescriptor::new(
1511            "Subscribe",
1512            vec![FieldDescriptor::new(
1513                "channels",
1514                true,
1515                collection_of(channel),
1516                Vec::new(),
1517            )],
1518        );
1519        let operation = OperationDescriptor::new(
1520            HttpMethod::Post,
1521            "/subscriptions",
1522            "subscriptions.create",
1523            "Subscribe",
1524            Some(TypeDescriptor::model(model)),
1525            vec![ResponseDescriptor::success(201, None)],
1526        )
1527        .unwrap();
1528        let app = App::new().route(operation).build().unwrap();
1529
1530        let document = super::to_value(&app);
1531        let channels = &document["components"]["schemas"]["Subscribe"]["properties"]["channels"];
1532
1533        assert_eq!(channels["items"]["maxLength"], 16);
1534        assert_eq!(
1535            channels["items"]["enum"],
1536            blazingly_json::json!(["news", "sport"]),
1537            "a recovered enumeration reaches the item schema: {channels}"
1538        );
1539        #[cfg(feature = "validation")]
1540        assert_eq!(channels["items"]["pattern"], "^[a-z]+$");
1541        assert!(
1542            channels["maxLength"].is_null(),
1543            "an item bound must not be read as a bound on the list: {channels}"
1544        );
1545        assert!(
1546            channels["items"]["x-blazingly-validators"].is_null() || !cfg!(feature = "validation"),
1547            "a recovered item rule must not also appear as an opaque validator: {channels}"
1548        );
1549    }
1550
1551    #[test]
1552    fn an_operation_that_decodes_input_documents_the_rejection_it_can_return() {
1553        let undecoded = OperationDescriptor::new(
1554            HttpMethod::Get,
1555            "/notes",
1556            "notes.list",
1557            "List notes",
1558            None,
1559            vec![ResponseDescriptor::success(200, None)],
1560        )
1561        .unwrap();
1562        let streaming = OperationDescriptor::new(
1563            HttpMethod::Post,
1564            "/uploads",
1565            "uploads.create",
1566            "Upload bytes",
1567            None,
1568            vec![ResponseDescriptor::success(201, None)],
1569        )
1570        .unwrap()
1571        .with_inputs(vec![InputDescriptor::new(
1572            "body",
1573            InputSource::Stream,
1574            true,
1575            TypeDescriptor::scalar("StreamingBody", SchemaKind::Binary),
1576        )]);
1577        let app = App::new()
1578            .route(note_operation())
1579            .route(undecoded)
1580            .route(streaming)
1581            .build()
1582            .unwrap();
1583
1584        let document = super::to_value(&app);
1585        let failure = &document["paths"]["/notes"]["post"]["responses"]["422"];
1586
1587        assert_eq!(
1588            failure["x-blazingly-automatic"], true,
1589            "a projected rejection is marked as one the operation did not declare"
1590        );
1591        let schema = &failure["content"]["application/json"]["schema"];
1592        let codes = schema["properties"]["error"]["properties"]["code"]["enum"]
1593            .as_array()
1594            .expect("a rejection carries a closed set of codes");
1595        assert!(codes.contains(&blazingly_json::json!("validation_error")));
1596        assert!(
1597            codes.contains(&blazingly_json::json!("invalid_json")),
1598            "a JSON body can fail to decode before any rule runs: {codes:?}"
1599        );
1600        let violation = &schema["properties"]["error"]["properties"]["details"]["properties"]["violations"]
1601            ["items"];
1602        assert_eq!(violation["properties"]["field"]["type"], "string");
1603        assert_eq!(violation["properties"]["code"]["type"], "string");
1604        assert_eq!(violation["properties"]["message"]["type"], "string");
1605        assert_eq!(
1606            violation["required"],
1607            blazingly_json::json!(["field", "code", "message"])
1608        );
1609        assert!(
1610            !failure["content"]["application/json"]["example"]["error"]["details"]["violations"][0]
1611                .is_null(),
1612            "the envelope carries a sample violation"
1613        );
1614
1615        assert!(
1616            document["paths"]["/notes"]["get"]["responses"]["422"].is_null(),
1617            "an operation that decodes nothing does not claim a 422"
1618        );
1619        assert!(
1620            document["paths"]["/uploads"]["post"]["responses"]["422"].is_null(),
1621            "bytes that reach the handler untouched cannot be rejected by a rule"
1622        );
1623    }
1624
1625    /// Each source is documented with the codes that source actually produces.
1626    ///
1627    /// The runtime answers a failed input differently depending on how it read
1628    /// it, so a code that source cannot reach must not appear in the closed set
1629    /// the document publishes.
1630    #[test]
1631    fn a_rejection_names_the_codes_the_inputs_it_has_can_produce() {
1632        let codes = |source: InputSource, ty: TypeDescriptor, path: &str, id: &str| {
1633            let operation = OperationDescriptor::new(
1634                HttpMethod::Post,
1635                path,
1636                id,
1637                "Accept input",
1638                None,
1639                vec![ResponseDescriptor::success(201, None)],
1640            )
1641            .unwrap()
1642            .with_inputs(vec![InputDescriptor::new("body", source, true, ty)]);
1643            let app = App::new().route(operation).build().unwrap();
1644            super::to_value(&app)["paths"][path]["post"]["responses"]["422"]["content"]
1645                ["application/json"]["schema"]["properties"]["error"]["properties"]["code"]["enum"]
1646                .as_array()
1647                .map(|codes| {
1648                    codes
1649                        .iter()
1650                        .filter_map(|code| code.as_str().map(str::to_owned))
1651                        .collect::<Vec<_>>()
1652                })
1653        };
1654        let model = || TypeDescriptor::model(create_user_model());
1655        let scalar = || TypeDescriptor::scalar("String", SchemaKind::String);
1656
1657        assert_eq!(
1658            codes(InputSource::Json, model(), "/users", "users.create"),
1659            Some(vec![
1660                "validation_error".to_owned(),
1661                "invalid_json".to_owned(),
1662            ]),
1663            "a body decoded whole reports a missing body as invalid JSON"
1664        );
1665        assert_eq!(
1666            codes(InputSource::Multipart, model(), "/parts", "parts.create"),
1667            Some(vec![
1668                "validation_error".to_owned(),
1669                "invalid_input".to_owned(),
1670                "invalid_multipart".to_owned(),
1671            ]),
1672            "a model is assembled from the fields that arrived, never found missing"
1673        );
1674        assert_eq!(
1675            codes(InputSource::File, model(), "/files", "files.create"),
1676            Some(vec![
1677                "invalid_multipart".to_owned(),
1678                "invalid_file_count".to_owned(),
1679            ]),
1680            "an upload is read out of a multipart document, then counted"
1681        );
1682        assert_eq!(
1683            codes(InputSource::Query, scalar(), "/search", "search.run"),
1684            Some(vec![
1685                "validation_error".to_owned(),
1686                "missing_input".to_owned(),
1687                "invalid_input".to_owned(),
1688            ]),
1689            "a value read one key at a time is the one that can be found missing"
1690        );
1691    }
1692
1693    #[test]
1694    fn a_declared_422_is_not_replaced_by_the_derived_one() {
1695        let operation = OperationDescriptor::new(
1696            HttpMethod::Post,
1697            "/notes",
1698            "notes.create",
1699            "Create a note",
1700            Some(TypeDescriptor::model(create_note_model())),
1701            vec![
1702                ResponseDescriptor::success(201, None),
1703                ResponseDescriptor::error(
1704                    422,
1705                    "unprocessable_note",
1706                    "The note is not usable.",
1707                    None,
1708                ),
1709            ],
1710        )
1711        .unwrap();
1712        let app = App::new().route(operation).build().unwrap();
1713
1714        let document = super::to_value(&app);
1715        let declared = &document["paths"]["/notes"]["post"]["responses"]["422"];
1716        assert_eq!(declared["x-blazingly-error-code"], "unprocessable_note");
1717        assert!(
1718            declared["x-blazingly-automatic"].is_null(),
1719            "a declared response keeps its own description and schema"
1720        );
1721    }
1722
1723    #[test]
1724    fn a_body_without_a_documented_shape_carries_no_example() {
1725        let operation = OperationDescriptor::new(
1726            HttpMethod::Post,
1727            "/users",
1728            "users.create",
1729            "Create a user",
1730            Some(TypeDescriptor::new("CreateUser")),
1731            vec![ResponseDescriptor::success(201, None)],
1732        )
1733        .unwrap();
1734        let app = App::new().route(operation).build().unwrap();
1735
1736        let document = super::to_value(&app);
1737
1738        assert!(
1739            document["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]
1740                ["example"]
1741                .is_null(),
1742            "an unconstrained schema must not invent a payload"
1743        );
1744    }
1745}