Skip to main content

blazingly_openapi/
lib.rs

1#![forbid(unsafe_code)]
2
3use blazingly_core::{
4    AppDefinition, FieldMetadata, InputDescriptor, InputSource, ModelDescriptor,
5    OperationDescriptor, SchemaKind, SecurityLocation, SecuritySchemeDescriptor,
6    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 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    let mut value = json!({
285        "operationId": operation.contract.id.as_str(),
286        "summary": operation.contract.summary,
287        "responses": responses,
288        "x-blazingly-agent": operation.contract.agent
289    });
290    if let Some(tag) = operation_tag(operation) {
291        value["tags"] = json!([tag]);
292    }
293    if let Some(description) = operation_description(operation) {
294        value["description"] = Value::String(description.to_owned());
295    }
296    if !operation.contract.dependencies.is_empty() {
297        value["x-blazingly-dependencies"] = Value::Array(
298            operation
299                .contract
300                .dependencies
301                .iter()
302                .map(|dependency| Value::String(dependency.rust_name.clone()))
303                .collect(),
304        );
305    }
306    if !operation.contract.security.is_empty() {
307        let requirements = operation
308            .contract
309            .security
310            .iter()
311            .map(|requirement| (requirement.scheme.clone(), json!(requirement.scopes)))
312            .collect::<Map<_, _>>();
313        value["security"] = Value::Array(vec![Value::Object(requirements)]);
314    }
315
316    let parameters = operation
317        .contract
318        .inputs
319        .iter()
320        .filter(|input| {
321            matches!(
322                input.source,
323                InputSource::Path | InputSource::Query | InputSource::Header | InputSource::Cookie
324            )
325        })
326        .flat_map(|input| parameter_values(input, components))
327        .collect::<Vec<_>>();
328    if !parameters.is_empty() {
329        value["parameters"] = Value::Array(parameters);
330    }
331
332    if let Some(input) = operation.contract.inputs.iter().find(|input| {
333        matches!(
334            input.source,
335            InputSource::Json
336                | InputSource::Form
337                | InputSource::Multipart
338                | InputSource::File
339                | InputSource::Stream
340        )
341    }) {
342        value["requestBody"] = json!({
343            "required": input.required,
344            "content": {
345                (request_media_type(input.source)):
346                    media_type_value(schema_value(&input.ty), components)
347            }
348        });
349    }
350
351    if let Some(tool) = &operation.contract.mcp {
352        value["x-blazingly-mcp"] = json!({
353            "name": tool.name,
354            "description": tool.description,
355            "risk": operation.contract.agent.risk,
356            "confirmation": operation.contract.agent.confirmation,
357            "idempotent": operation.contract.agent.idempotent,
358            "outputExposure": tool.expose_output
359        });
360    }
361
362    value
363}
364
365/// The section a browser UI groups this operation under.
366///
367/// The operation model has no tag field, so the group is the namespace of the
368/// stable operation identity: `users.create` and `users.list` both belong to
369/// `users`, and `billing.invoices.void` belongs to `billing.invoices`. An
370/// identity without a namespace stays untagged rather than becoming a section
371/// of its own.
372fn operation_tag(operation: &OperationDescriptor) -> Option<&str> {
373    operation
374        .contract
375        .id
376        .as_str()
377        .rsplit_once('.')
378        .map(|(namespace, _)| namespace)
379        .filter(|namespace| !namespace.is_empty())
380}
381
382/// Prose shown below the summary in a browser UI.
383///
384/// The contract carries one long-form description, the one an operation
385/// declares for agents; it defaults to the summary, so it is only projected
386/// when it says something the summary does not.
387fn operation_description(operation: &OperationDescriptor) -> Option<&str> {
388    let description = operation.contract.mcp.as_ref()?.description.as_str();
389    (!description.is_empty() && description != operation.contract.summary).then_some(description)
390}
391
392/// A media type entry carrying a schema and, when derivable, a sample payload.
393fn media_type_value(schema: Value, components: &Map<String, Value>) -> Value {
394    let example = example_for_schema(&schema, components, MAX_EXAMPLE_DEPTH);
395    let mut value = Map::new();
396    if !example.is_null() {
397        value.insert("example".to_owned(), example);
398    }
399    value.insert("schema".to_owned(), schema);
400    Value::Object(value)
401}
402
403/// The error envelope a failing operation actually returns.
404fn error_example(
405    response: &blazingly_core::ResponseDescriptor,
406    components: &Map<String, Value>,
407) -> Value {
408    let mut error = json!({
409        "code": response.error_code.as_deref().unwrap_or_default(),
410        "message": response.error_message.as_deref().unwrap_or_default()
411    });
412    if let Some(details) = &response.body {
413        error["details"] =
414            example_for_schema(&schema_value(details), components, MAX_EXAMPLE_DEPTH);
415    }
416    json!({ "error": error })
417}
418
419/// Builds a sample payload from a generated schema node.
420///
421/// Deriving the example from the schema rather than from the descriptor means
422/// every keyword the schema already carries — `format`, `minLength`, `const`,
423/// `minimum`, and anything a later projection adds — constrains the sample
424/// without a second traversal of the operation model.
425fn example_for_schema(schema: &Value, components: &Map<String, Value>, depth: usize) -> Value {
426    let (Some(object), 1..) = (schema.as_object(), depth) else {
427        return Value::Null;
428    };
429    for keyword in ["example", "default", "const"] {
430        if let Some(value) = object.get(keyword) {
431            return value.clone();
432        }
433    }
434    if let Some(first) = object
435        .get("enum")
436        .and_then(Value::as_array)
437        .and_then(|values| values.first())
438    {
439        return first.clone();
440    }
441    if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
442        let name = reference
443            .rsplit_once('/')
444            .map_or(reference, |(_, name)| name);
445        return components.get(name).map_or(Value::Null, |target| {
446            example_for_schema(target, components, depth - 1)
447        });
448    }
449
450    match schema_type(object) {
451        Some("object") => example_object(object, components, depth),
452        Some("array") => example_array(object, components, depth),
453        Some("string") => Value::String(example_string(object)),
454        Some("integer") => json!(example_integer(object)),
455        Some("number") => json!(example_number(object)),
456        Some("boolean") => Value::Bool(true),
457        _ => Value::Null,
458    }
459}
460
461/// The declared type, skipping the `"null"` member of a nullable union.
462fn schema_type(schema: &Map<String, Value>) -> Option<&str> {
463    match schema.get("type")? {
464        Value::String(name) => Some(name.as_str()),
465        Value::Array(names) => names
466            .iter()
467            .filter_map(Value::as_str)
468            .find(|name| *name != "null"),
469        _ => None,
470    }
471}
472
473fn example_object(
474    schema: &Map<String, Value>,
475    components: &Map<String, Value>,
476    depth: usize,
477) -> Value {
478    let Some(Value::Object(properties)) = schema.get("properties") else {
479        return Value::Object(Map::new());
480    };
481    Value::Object(
482        properties
483            .iter()
484            .map(|(name, property)| {
485                (
486                    name.clone(),
487                    example_for_schema(property, components, depth - 1),
488                )
489            })
490            .collect(),
491    )
492}
493
494fn example_array(
495    schema: &Map<String, Value>,
496    components: &Map<String, Value>,
497    depth: usize,
498) -> Value {
499    let item = schema.get("items").map_or(Value::Null, |items| {
500        example_for_schema(items, components, depth - 1)
501    });
502    let items = schema
503        .get("minItems")
504        .and_then(Value::as_u64)
505        .unwrap_or(1)
506        .clamp(1, 3);
507    Value::Array(vec![item; usize::try_from(items).unwrap_or(1)])
508}
509
510/// A sample string honouring the format, then the declared length window.
511///
512/// A formatted sample is returned verbatim: trimming an address to a
513/// `maxLength` would only produce a payload the same document rejects.
514fn example_string(schema: &Map<String, Value>) -> String {
515    let sample = match schema.get("format").and_then(Value::as_str) {
516        Some("email") => return "user@example.com".to_owned(),
517        Some("uuid") => return "00000000-0000-4000-8000-000000000000".to_owned(),
518        Some("uri") => return "https://example.com".to_owned(),
519        Some("ip") => return "192.0.2.1".to_owned(),
520        Some("date") => return "2024-01-01".to_owned(),
521        Some("date-time") => return "2024-01-01T00:00:00Z".to_owned(),
522        Some("decimal") => return "1.00".to_owned(),
523        Some("binary") => return "ZXhhbXBsZQ==".to_owned(),
524        _ => "example",
525    };
526
527    let minimum = schema
528        .get("minLength")
529        .and_then(Value::as_u64)
530        .unwrap_or(0)
531        .min(64);
532    let maximum = schema
533        .get("maxLength")
534        .and_then(Value::as_u64)
535        .unwrap_or(u64::MAX);
536    let mut value = sample.to_owned();
537    while u64::try_from(value.len()).unwrap_or(u64::MAX) < minimum {
538        value.push('x');
539    }
540    if u64::try_from(value.len()).unwrap_or(u64::MAX) > maximum {
541        value.truncate(usize::try_from(maximum).unwrap_or(usize::MAX));
542    }
543    value
544}
545
546fn example_integer(schema: &Map<String, Value>) -> i64 {
547    let mut value = 1_i64;
548    if let Some(minimum) = schema.get("minimum").and_then(Value::as_i64) {
549        value = value.max(minimum);
550    }
551    if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_i64) {
552        value = value.max(minimum.saturating_add(1));
553    }
554    if let Some(maximum) = schema.get("maximum").and_then(Value::as_i64) {
555        value = value.min(maximum);
556    }
557    if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_i64) {
558        value = value.min(maximum.saturating_sub(1));
559    }
560    value
561}
562
563fn example_number(schema: &Map<String, Value>) -> f64 {
564    let mut value = 1.0_f64;
565    if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) {
566        value = value.max(minimum);
567    }
568    if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) {
569        value = value.min(maximum);
570    }
571    value
572}
573
574fn response_media_type(descriptor: &TypeDescriptor) -> &'static str {
575    if matches!(descriptor.schema, SchemaKind::Binary) {
576        "application/octet-stream"
577    } else {
578        "application/json"
579    }
580}
581
582fn scalar_html(config: &OpenApiConfig) -> String {
583    let title = escape_html(&config.title);
584    let document_path = blazingly_json::to_string(&config.document_path)
585        .unwrap_or_else(|_| "\"/openapi.json\"".into());
586    format!(
587        concat!(
588            "<!doctype html><html><head><meta charset=\"utf-8\">",
589            "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
590            "<title>{title}</title></head><body>",
591            "<script id=\"api-reference\" data-url={document_path}></script>",
592            "<script src=\"https://cdn.jsdelivr.net/npm/@scalar/api-reference\"></script>",
593            "</body></html>"
594        ),
595        title = title,
596        document_path = document_path,
597    )
598}
599
600fn swagger_html(config: &OpenApiConfig) -> String {
601    let title = escape_html(&config.title);
602    let document_path = blazingly_json::to_string(&config.document_path)
603        .unwrap_or_else(|_| "\"/openapi.json\"".into());
604    format!(
605        concat!(
606            "<!doctype html><html><head><meta charset=\"utf-8\">",
607            "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
608            "<title>{title}</title>",
609            "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui.css\">",
610            "</head><body><div id=\"swagger-ui\"></div>",
611            "<script src=\"https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-bundle.js\"></script>",
612            "<script>SwaggerUIBundle({{url:{document_path},dom_id:'#swagger-ui'}});</script>",
613            "</body></html>"
614        ),
615        title = title,
616        document_path = document_path,
617    )
618}
619
620fn escape_html(value: &str) -> String {
621    value
622        .replace('&', "&amp;")
623        .replace('<', "&lt;")
624        .replace('>', "&gt;")
625        .replace('"', "&quot;")
626        .replace('\'', "&#39;")
627}
628
629fn error_schema(response: &blazingly_core::ResponseDescriptor) -> Value {
630    let mut error_properties = json!({
631        "code": {
632            "type": "string",
633            "const": response.error_code
634        },
635        "message": {
636            "type": "string"
637        }
638    });
639    let mut required = vec!["code", "message"];
640    if let Some(details) = &response.body {
641        error_properties["details"] = schema_value(details);
642        required.push("details");
643    }
644    json!({
645        "type": "object",
646        "properties": {
647            "error": {
648                "type": "object",
649                "properties": error_properties,
650                "required": required,
651                "additionalProperties": false
652            }
653        },
654        "required": ["error"],
655        "additionalProperties": false
656    })
657}
658
659fn parameter_values(input: &InputDescriptor, components: &Map<String, Value>) -> Vec<Value> {
660    let location = input_source_name(input.source);
661    if let Some(model) = &input.ty.model {
662        return model
663            .fields
664            .iter()
665            .map(|field| {
666                let mut schema = schema_value(&field.ty);
667                apply_validation(&mut schema, &field.validation);
668                parameter_value(
669                    parameter_name(input.source, &field.name),
670                    location,
671                    input.source == InputSource::Path || (input.required && field.required),
672                    schema,
673                    components,
674                )
675            })
676            .collect();
677    }
678
679    vec![parameter_value(
680        parameter_name(input.source, &input.name),
681        location,
682        input.source == InputSource::Path || input.required,
683        schema_value(&input.ty),
684        components,
685    )]
686}
687
688fn parameter_value(
689    name: String,
690    location: &'static str,
691    required: bool,
692    schema: Value,
693    components: &Map<String, Value>,
694) -> Value {
695    let example = example_for_schema(&schema, components, MAX_EXAMPLE_DEPTH);
696    let mut value = Map::new();
697    value.insert("name".to_owned(), Value::String(name));
698    value.insert("in".to_owned(), Value::String(location.to_owned()));
699    value.insert("required".to_owned(), Value::Bool(required));
700    value.insert("schema".to_owned(), schema);
701    if !example.is_null() {
702        value.insert("example".to_owned(), example);
703    }
704    Value::Object(value)
705}
706
707fn parameter_name(source: InputSource, name: &str) -> String {
708    if source == InputSource::Header {
709        name.replace('_', "-")
710    } else {
711        name.to_owned()
712    }
713}
714
715fn input_source_name(source: InputSource) -> &'static str {
716    match source {
717        InputSource::Path => "path",
718        InputSource::Query => "query",
719        InputSource::Header => "header",
720        InputSource::Cookie => "cookie",
721        InputSource::Json
722        | InputSource::Form
723        | InputSource::Multipart
724        | InputSource::File
725        | InputSource::Stream => {
726            unreachable!("body inputs are OpenAPI request bodies")
727        }
728    }
729}
730
731fn request_media_type(source: InputSource) -> &'static str {
732    match source {
733        InputSource::Json => "application/json",
734        InputSource::Form => "application/x-www-form-urlencoded",
735        InputSource::Multipart | InputSource::File => "multipart/form-data",
736        InputSource::Stream => "application/octet-stream",
737        InputSource::Path | InputSource::Query | InputSource::Header | InputSource::Cookie => {
738            unreachable!("parameter inputs do not have a request body media type")
739        }
740    }
741}
742
743fn schema_value(descriptor: &TypeDescriptor) -> Value {
744    if let Some(model) = &descriptor.model {
745        return json!({
746            "$ref": format!("#/components/schemas/{}", model.name),
747            "x-rust-type": descriptor.rust_name
748        });
749    }
750
751    let mut value = match (&descriptor.schema, &descriptor.items) {
752        (SchemaKind::Array(_), Some(items)) => {
753            json!({ "type": "array", "items": schema_value(items) })
754        }
755        _ => schema_kind_value(&descriptor.schema),
756    };
757    apply_known_string_format(&mut value, &descriptor.rust_name);
758    value["x-rust-type"] = Value::String(descriptor.rust_name.clone());
759    value
760}
761
762fn apply_known_string_format(schema: &mut Value, rust_name: &str) {
763    let format = match rust_name {
764        "Uuid" => "uuid",
765        "Url" => "uri",
766        "IpAddress" => "ip",
767        "Date" => "date",
768        "DateTime" => "date-time",
769        "Decimal" => "decimal",
770        _ => return,
771    };
772    schema["format"] = Value::String(format.to_owned());
773}
774
775fn schema_kind_value(schema: &SchemaKind) -> Value {
776    match schema {
777        SchemaKind::String => json!({ "type": "string" }),
778        SchemaKind::Binary => json!({ "type": "string", "format": "binary" }),
779        SchemaKind::Integer => json!({ "type": "integer" }),
780        SchemaKind::Number => json!({ "type": "number" }),
781        SchemaKind::Boolean => json!({ "type": "boolean" }),
782        SchemaKind::Array(item) => {
783            json!({ "type": "array", "items": schema_kind_value(item) })
784        }
785        SchemaKind::Object => json!({ "type": "object" }),
786        SchemaKind::Any => json!({}),
787    }
788}
789
790fn collect_model(descriptor: &TypeDescriptor, schemas: &mut Map<String, Value>) {
791    if let Some(items) = &descriptor.items {
792        collect_model(items, schemas);
793    }
794    if let Some(model) = &descriptor.model {
795        if schemas.contains_key(&model.name) {
796            return;
797        }
798        schemas.insert(model.name.clone(), model_schema(model));
799        for field in &model.fields {
800            collect_model(&field.ty, schemas);
801        }
802    }
803}
804
805fn security_scheme_value(scheme: &SecuritySchemeDescriptor) -> Value {
806    let mut value = match &scheme.kind {
807        SecuritySchemeKind::ApiKey { location, name } => json!({
808            "type": "apiKey",
809            "in": match location {
810                SecurityLocation::Header => "header",
811                SecurityLocation::Query => "query",
812                SecurityLocation::Cookie => "cookie",
813            },
814            "name": name
815        }),
816        SecuritySchemeKind::Http {
817            scheme,
818            bearer_format,
819        } => {
820            let mut value = json!({ "type": "http", "scheme": scheme });
821            if let Some(bearer_format) = bearer_format {
822                value["bearerFormat"] = Value::String(bearer_format.clone());
823            }
824            value
825        }
826        SecuritySchemeKind::OAuth2 {
827            authorization_url,
828            token_url,
829            scopes,
830        } => {
831            let scopes = scopes
832                .iter()
833                .map(|scope| (scope.clone(), Value::String(String::new())))
834                .collect::<Map<_, _>>();
835            let mut flows = Map::new();
836            match (authorization_url, token_url) {
837                (Some(authorization_url), Some(token_url)) => {
838                    flows.insert(
839                        "authorizationCode".to_owned(),
840                        json!({
841                            "authorizationUrl": authorization_url,
842                            "tokenUrl": token_url,
843                            "scopes": scopes
844                        }),
845                    );
846                }
847                (Some(authorization_url), None) => {
848                    flows.insert(
849                        "implicit".to_owned(),
850                        json!({ "authorizationUrl": authorization_url, "scopes": scopes }),
851                    );
852                }
853                (None, Some(token_url)) => {
854                    flows.insert(
855                        "clientCredentials".to_owned(),
856                        json!({ "tokenUrl": token_url, "scopes": scopes }),
857                    );
858                }
859                (None, None) => {}
860            }
861            json!({ "type": "oauth2", "flows": flows })
862        }
863        SecuritySchemeKind::OpenIdConnect { discovery_url } => {
864            json!({ "type": "openIdConnect", "openIdConnectUrl": discovery_url })
865        }
866        SecuritySchemeKind::MutualTls => json!({ "type": "mutualTLS" }),
867    };
868    if let Some(description) = &scheme.description {
869        value["description"] = Value::String(description.clone());
870    }
871    value
872}
873
874fn model_schema(model: &ModelDescriptor) -> Value {
875    let mut properties = Map::new();
876    let mut required = Vec::new();
877
878    for field in &model.fields {
879        let mut schema = schema_value(&field.ty);
880        apply_validation(&mut schema, &field.validation);
881        properties.insert(field.name.clone(), schema);
882        if field.required {
883            required.push(field.name.clone());
884        }
885    }
886
887    json!({
888        "type": "object",
889        "properties": properties,
890        "required": required,
891        "additionalProperties": false
892    })
893}
894
895/// Projects a recovered default, enumeration, or nullability marker.
896///
897/// `OpenAPI` 3.1 follows JSON Schema 2020-12, which dropped the `nullable`
898/// keyword: a value that also accepts `null` widens its own `type` into a union
899/// instead, and a reference is wrapped in an `anyOf` because a `$ref` node has
900/// no type of its own to widen.
901fn apply_field_metadata(schema: &mut Value, metadata: &FieldMetadata) {
902    match metadata {
903        FieldMetadata::Default(value) => schema["default"] = value.clone(),
904        FieldMetadata::Enumeration(values) => {
905            schema["enum"] = Value::Array(
906                values
907                    .iter()
908                    .map(|value| Value::String(value.clone()))
909                    .collect(),
910            );
911        }
912        FieldMetadata::Nullable => widen_with_null(schema),
913    }
914}
915
916fn widen_with_null(schema: &mut Value) {
917    let Some(declared) = schema.as_object().map(|object| object.get("type").cloned()) else {
918        return;
919    };
920    match declared {
921        Some(Value::String(name)) => schema["type"] = json!([name, "null"]),
922        Some(Value::Array(mut names)) => {
923            if !names.iter().any(|name| name.as_str() == Some("null")) {
924                names.push(Value::String("null".to_owned()));
925                schema["type"] = Value::Array(names);
926            }
927        }
928        Some(_) | None => {
929            if schema.get("$ref").is_some() {
930                let referenced = std::mem::replace(schema, Value::Null);
931                *schema = json!({ "anyOf": [referenced, { "type": "null" }] });
932            }
933        }
934    }
935}
936
937fn apply_validation(schema: &mut Value, validation: &[ValidationRule]) {
938    for rule in validation {
939        match rule {
940            ValidationRule::MinLength(value) => schema["minLength"] = json!(value),
941            ValidationRule::MaxLength(value) => schema["maxLength"] = json!(value),
942            ValidationRule::Email => schema["format"] = json!("email"),
943            ValidationRule::Alias(alias) => {
944                let aliases = schema
945                    .as_object_mut()
946                    .expect("validation schema must be an object")
947                    .entry("x-blazingly-aliases")
948                    .or_insert_with(|| Value::Array(Vec::new()));
949                aliases
950                    .as_array_mut()
951                    .expect("alias extension must be an array")
952                    .push(Value::String(alias.clone()));
953            }
954            ValidationRule::Custom(validator) => {
955                // Declarative constraints are encoded as `keyword=value` inside
956                // `Custom`; project the ones that map to a JSON Schema keyword
957                // instead of leaving them as opaque validator strings.
958                if let Some(metadata) = FieldMetadata::parse(validator) {
959                    apply_field_metadata(schema, &metadata);
960                    continue;
961                }
962                #[cfg(feature = "validation")]
963                if let Some(constraint) = blazingly_validation::Constraint::parse(validator) {
964                    constraint.apply_json_schema(schema);
965                    continue;
966                }
967                let validators = schema
968                    .as_object_mut()
969                    .expect("validation schema must be an object")
970                    .entry("x-blazingly-validators")
971                    .or_insert_with(|| Value::Array(Vec::new()));
972                validators
973                    .as_array_mut()
974                    .expect("validator extension must be an array")
975                    .push(Value::String(validator.clone()));
976            }
977            ValidationRule::Nested => {
978                schema["x-blazingly-nested-validation"] = Value::Bool(true);
979            }
980        }
981    }
982}
983
984#[cfg(test)]
985mod tests {
986    use blazingly_core::{
987        AgentPolicy, App, FieldDescriptor, HttpMethod, InputDescriptor, InputSource,
988        McpToolDescriptor, ModelDescriptor, OperationDescriptor, ResponseDescriptor, SchemaKind,
989        SecurityRequirement, SecuritySchemeDescriptor, SecuritySchemeKind, TypeDescriptor,
990        ValidationRule,
991    };
992
993    fn create_user_model() -> ModelDescriptor {
994        ModelDescriptor::new(
995            "CreateUser",
996            vec![
997                FieldDescriptor::new(
998                    "name",
999                    true,
1000                    TypeDescriptor::scalar("String", SchemaKind::String),
1001                    vec![ValidationRule::MinLength(12)],
1002                ),
1003                FieldDescriptor::new(
1004                    "email",
1005                    true,
1006                    TypeDescriptor::scalar("String", SchemaKind::String),
1007                    vec![ValidationRule::Email],
1008                ),
1009                FieldDescriptor::new(
1010                    "age",
1011                    false,
1012                    TypeDescriptor::scalar("u8", SchemaKind::Integer),
1013                    Vec::new(),
1014                ),
1015            ],
1016        )
1017    }
1018
1019    #[test]
1020    fn openapi_is_projected_from_the_operation_model() {
1021        let operation = OperationDescriptor::new(
1022            HttpMethod::Post,
1023            "/users",
1024            "users.create",
1025            "Create a user",
1026            Some(TypeDescriptor::new("CreateUser")),
1027            vec![ResponseDescriptor::success(
1028                201,
1029                Some(TypeDescriptor::new("UserView")),
1030            )],
1031        )
1032        .expect("operation should be valid");
1033        let app = App::new()
1034            .route(operation)
1035            .build()
1036            .expect("application should be valid");
1037
1038        let document = super::to_value(&app);
1039
1040        assert_eq!(
1041            document["paths"]["/users"]["post"]["operationId"],
1042            "users.create"
1043        );
1044        assert_eq!(
1045            document["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"]
1046                ["x-rust-type"],
1047            "CreateUser"
1048        );
1049        assert_eq!(
1050            document["paths"]["/users"]["post"]["responses"]["201"]["content"]["application/json"]
1051                ["schema"]["x-rust-type"],
1052            "UserView"
1053        );
1054    }
1055
1056    #[test]
1057    fn openapi_projects_registered_security_and_operation_scopes() {
1058        let operation = OperationDescriptor::new(
1059            HttpMethod::Get,
1060            "/users",
1061            "users.list",
1062            "List users",
1063            None,
1064            vec![ResponseDescriptor::success(200, None)],
1065        )
1066        .unwrap()
1067        .with_security(vec![
1068            SecurityRequirement::new("oauth").with_scopes(vec!["users:read".to_owned()]),
1069        ]);
1070        let app = App::new()
1071            .route(operation)
1072            .security_scheme(SecuritySchemeDescriptor::new(
1073                "oauth",
1074                SecuritySchemeKind::OAuth2 {
1075                    authorization_url: Some("https://auth.example/authorize".to_owned()),
1076                    token_url: Some("https://auth.example/token".to_owned()),
1077                    scopes: vec!["users:read".to_owned()],
1078                },
1079            ))
1080            .build()
1081            .unwrap();
1082
1083        let document = super::to_value(&app);
1084        assert_eq!(
1085            document["components"]["securitySchemes"]["oauth"]["flows"]["authorizationCode"]["tokenUrl"],
1086            "https://auth.example/token"
1087        );
1088        assert_eq!(
1089            document["paths"]["/users"]["get"]["security"][0]["oauth"][0],
1090            "users:read"
1091        );
1092    }
1093
1094    #[test]
1095    fn operations_are_grouped_by_the_namespace_of_their_identity() {
1096        let create = OperationDescriptor::new(
1097            HttpMethod::Post,
1098            "/users",
1099            "users.create",
1100            "Create a user",
1101            None,
1102            vec![ResponseDescriptor::success(201, None)],
1103        )
1104        .unwrap();
1105        let list = OperationDescriptor::new(
1106            HttpMethod::Get,
1107            "/users",
1108            "users.list",
1109            "List users",
1110            None,
1111            vec![ResponseDescriptor::success(200, None)],
1112        )
1113        .unwrap();
1114        let health = OperationDescriptor::new(
1115            HttpMethod::Get,
1116            "/health",
1117            "health",
1118            "Report health",
1119            None,
1120            vec![ResponseDescriptor::success(200, None)],
1121        )
1122        .unwrap();
1123        let app = App::new()
1124            .route(create)
1125            .route(list)
1126            .route(health)
1127            .build()
1128            .unwrap();
1129
1130        let document = super::to_value(&app);
1131
1132        assert_eq!(document["paths"]["/users"]["post"]["tags"][0], "users");
1133        assert_eq!(document["paths"]["/users"]["get"]["tags"][0], "users");
1134        assert_eq!(
1135            document["tags"].as_array().map(Vec::len),
1136            Some(1),
1137            "one section per namespace: {}",
1138            document["tags"]
1139        );
1140        assert_eq!(document["tags"][0]["name"], "users");
1141        assert!(
1142            document["paths"]["/health"]["get"]["tags"].is_null(),
1143            "an identity without a namespace stays untagged"
1144        );
1145    }
1146
1147    #[test]
1148    fn a_long_description_is_projected_only_when_it_adds_to_the_summary() {
1149        let described = OperationDescriptor::new(
1150            HttpMethod::Post,
1151            "/users",
1152            "users.create",
1153            "Create a user",
1154            None,
1155            vec![ResponseDescriptor::success(201, None)],
1156        )
1157        .unwrap()
1158        .with_mcp_tool(
1159            McpToolDescriptor::new("create_user", "Registers one user and returns its view."),
1160            AgentPolicy::default(),
1161        );
1162        let echoed = OperationDescriptor::new(
1163            HttpMethod::Get,
1164            "/users",
1165            "users.list",
1166            "List users",
1167            None,
1168            vec![ResponseDescriptor::success(200, None)],
1169        )
1170        .unwrap()
1171        .with_mcp_tool(
1172            McpToolDescriptor::new("list_users", "List users"),
1173            AgentPolicy::default(),
1174        );
1175        let app = App::new().route(described).route(echoed).build().unwrap();
1176
1177        let document = super::to_value(&app);
1178
1179        assert_eq!(
1180            document["paths"]["/users"]["post"]["description"],
1181            "Registers one user and returns its view."
1182        );
1183        assert!(document["paths"]["/users"]["get"]["description"].is_null());
1184    }
1185
1186    #[test]
1187    fn bodies_and_parameters_carry_examples_that_satisfy_their_own_schema() {
1188        let operation = OperationDescriptor::new(
1189            HttpMethod::Post,
1190            "/tenants/{tenant_id}/users",
1191            "users.create",
1192            "Create a user",
1193            None,
1194            vec![
1195                ResponseDescriptor::success(201, Some(TypeDescriptor::model(create_user_model()))),
1196                ResponseDescriptor::error(
1197                    409,
1198                    "email_already_exists",
1199                    "A user with this email already exists.",
1200                    None,
1201                ),
1202            ],
1203        )
1204        .unwrap()
1205        .with_inputs(vec![
1206            InputDescriptor::new(
1207                "tenant_id",
1208                InputSource::Path,
1209                true,
1210                TypeDescriptor::scalar("Uuid", SchemaKind::String),
1211            ),
1212            InputDescriptor::new(
1213                "body",
1214                InputSource::Json,
1215                true,
1216                TypeDescriptor::model(create_user_model()),
1217            ),
1218        ]);
1219        let app = App::new().route(operation).build().unwrap();
1220
1221        let document = super::to_value(&app);
1222        let operation = &document["paths"]["/tenants/{tenant_id}/users"]["post"];
1223
1224        let request = &operation["requestBody"]["content"]["application/json"]["example"];
1225        assert_eq!(request["email"], "user@example.com");
1226        assert_eq!(
1227            request["name"], "examplexxxxx",
1228            "a sample must reach its own minLength"
1229        );
1230        assert_eq!(request["age"], 1);
1231        assert_eq!(
1232            operation["responses"]["201"]["content"]["application/json"]["example"]["email"],
1233            "user@example.com"
1234        );
1235        assert_eq!(operation["parameters"][0]["name"], "tenant_id");
1236        assert_eq!(
1237            operation["parameters"][0]["example"],
1238            "00000000-0000-4000-8000-000000000000"
1239        );
1240
1241        let failure = &operation["responses"]["409"]["content"]["application/json"]["example"];
1242        assert_eq!(failure["error"]["code"], "email_already_exists");
1243        assert_eq!(
1244            failure["error"]["message"],
1245            "A user with this email already exists."
1246        );
1247    }
1248
1249    #[test]
1250    fn every_declared_tag_and_example_keeps_the_document_well_formed() {
1251        let operation = OperationDescriptor::new(
1252            HttpMethod::Post,
1253            "/users",
1254            "users.create",
1255            "Create a user",
1256            Some(TypeDescriptor::model(create_user_model())),
1257            vec![
1258                ResponseDescriptor::success(201, Some(TypeDescriptor::model(create_user_model()))),
1259                ResponseDescriptor::error(409, "conflict", "Already exists.", None),
1260            ],
1261        )
1262        .unwrap();
1263        let app = App::new().route(operation).build().unwrap();
1264
1265        let document = super::to_value(&app);
1266        let declared = document["tags"]
1267            .as_array()
1268            .expect("a grouped document declares its tags")
1269            .iter()
1270            .map(|tag| {
1271                tag["name"]
1272                    .as_str()
1273                    .expect("every tag object names a section")
1274                    .to_owned()
1275            })
1276            .collect::<Vec<_>>();
1277        assert_eq!(declared, ["users"]);
1278
1279        for (_, path_item) in document["paths"].as_object().expect("paths is an object") {
1280            for (_, operation) in path_item.as_object().expect("a path item is an object") {
1281                for tag in operation["tags"].as_array().into_iter().flatten() {
1282                    let tag = tag.as_str().expect("an operation tag is a string");
1283                    assert!(
1284                        declared.iter().any(|declared| declared == tag),
1285                        "operation tag {tag} is not declared at the document root"
1286                    );
1287                }
1288                for (_, response) in operation["responses"]
1289                    .as_object()
1290                    .expect("responses is an object")
1291                {
1292                    for (_, media) in response["content"].as_object().into_iter().flatten() {
1293                        assert!(
1294                            !media["schema"].is_null(),
1295                            "an example must accompany a schema, not replace it"
1296                        );
1297                    }
1298                }
1299            }
1300        }
1301    }
1302
1303    #[test]
1304    fn recorded_defaults_enumerations_and_nullability_use_openapi_31_spelling() {
1305        let model = ModelDescriptor::new(
1306            "Article",
1307            vec![
1308                FieldDescriptor::new(
1309                    "status",
1310                    false,
1311                    TypeDescriptor::scalar("String", SchemaKind::String),
1312                    vec![
1313                        ValidationRule::Custom("enum=draft|published".to_owned()),
1314                        ValidationRule::Custom("default=\"draft\"".to_owned()),
1315                    ],
1316                ),
1317                FieldDescriptor::new(
1318                    "subtitle",
1319                    false,
1320                    TypeDescriptor::scalar("String", SchemaKind::String),
1321                    vec![ValidationRule::Custom("nullable=true".to_owned())],
1322                ),
1323                FieldDescriptor::new(
1324                    "author",
1325                    false,
1326                    TypeDescriptor::model(create_user_model()),
1327                    vec![ValidationRule::Custom("nullable=true".to_owned())],
1328                ),
1329            ],
1330        );
1331        let operation = OperationDescriptor::new(
1332            HttpMethod::Post,
1333            "/articles",
1334            "articles.create",
1335            "Create an article",
1336            Some(TypeDescriptor::model(model)),
1337            vec![ResponseDescriptor::success(201, None)],
1338        )
1339        .unwrap();
1340        let app = App::new().route(operation).build().unwrap();
1341
1342        let document = super::to_value(&app);
1343        let properties = &document["components"]["schemas"]["Article"]["properties"];
1344
1345        assert_eq!(properties["status"]["default"], "draft");
1346        assert_eq!(properties["status"]["enum"][0], "draft");
1347        assert_eq!(properties["status"]["enum"][1], "published");
1348        assert_eq!(
1349            properties["subtitle"]["type"],
1350            blazingly_json::json!(["string", "null"]),
1351            "3.1 has no `nullable` keyword"
1352        );
1353        assert_eq!(
1354            properties["author"]["anyOf"][0]["$ref"], "#/components/schemas/CreateUser",
1355            "a nullable reference widens through anyOf"
1356        );
1357        assert_eq!(properties["author"]["anyOf"][1]["type"], "null");
1358        assert!(
1359            properties["status"]["x-blazingly-validators"].is_null(),
1360            "recovered metadata must not also appear as an opaque validator"
1361        );
1362        assert_eq!(
1363            document["paths"]["/articles"]["post"]["requestBody"]["content"]["application/json"]["example"]
1364                ["status"],
1365            "draft",
1366            "a declared default is the most useful sample value"
1367        );
1368    }
1369
1370    #[test]
1371    fn a_body_without_a_documented_shape_carries_no_example() {
1372        let operation = OperationDescriptor::new(
1373            HttpMethod::Post,
1374            "/users",
1375            "users.create",
1376            "Create a user",
1377            Some(TypeDescriptor::new("CreateUser")),
1378            vec![ResponseDescriptor::success(201, None)],
1379        )
1380        .unwrap();
1381        let app = App::new().route(operation).build().unwrap();
1382
1383        let document = super::to_value(&app);
1384
1385        assert!(
1386            document["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]
1387                ["example"]
1388                .is_null(),
1389            "an unconstrained schema must not invent a payload"
1390        );
1391    }
1392}