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