Skip to main content

blazingly_openapi/
lib.rs

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