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