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