1use serde_json::{json, Map, Value};
24
25use crate::error::{Result, SammError};
26use crate::metamodel::{
27 Aspect, Characteristic, CharacteristicKind, Entity, ModelElement, Property,
28};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HttpMethod {
37 Get,
39 Post,
41 Put,
43 Patch,
45 Delete,
47}
48
49impl HttpMethod {
50 fn as_str(self) -> &'static str {
51 match self {
52 HttpMethod::Get => "get",
53 HttpMethod::Post => "post",
54 HttpMethod::Put => "put",
55 HttpMethod::Patch => "patch",
56 HttpMethod::Delete => "delete",
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
67pub struct OpenApiOptions {
68 pub base_path: String,
70 pub api_version: String,
72 pub include_get: bool,
74 pub include_post: bool,
76 pub include_put: bool,
78 pub include_delete: bool,
80 pub use_defs_keyword: bool,
82 pub language: String,
84}
85
86impl Default for OpenApiOptions {
87 fn default() -> Self {
88 Self {
89 base_path: "/api/v1/aspects".to_string(),
90 api_version: "1.0.0".to_string(),
91 include_get: true,
92 include_post: false,
93 include_put: false,
94 include_delete: false,
95 use_defs_keyword: false,
96 language: "en".to_string(),
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
107pub struct OpenApiGenerator {
108 options: OpenApiOptions,
109}
110
111impl OpenApiGenerator {
112 pub fn new(version: impl Into<String>, base_path: impl Into<String>) -> Self {
114 let options = OpenApiOptions {
115 api_version: version.into(),
116 base_path: base_path.into(),
117 ..OpenApiOptions::default()
118 };
119 Self { options }
120 }
121
122 pub fn with_options(options: OpenApiOptions) -> Self {
124 Self { options }
125 }
126
127 pub fn with_post(mut self) -> Self {
129 self.options.include_post = true;
130 self
131 }
132
133 pub fn with_put(mut self) -> Self {
135 self.options.include_put = true;
136 self
137 }
138
139 pub fn with_delete(mut self) -> Self {
141 self.options.include_delete = true;
142 self
143 }
144
145 pub fn generate(&self, aspect: &Aspect) -> Result<Value> {
151 let aspect_name = aspect.name();
152 let path = format!(
153 "{}/{}",
154 self.options.base_path.trim_end_matches('/'),
155 to_kebab_case(&aspect_name)
156 );
157
158 let mut spec = Map::new();
160 spec.insert("openapi".to_string(), Value::String("3.0.3".to_string()));
161 spec.insert("info".to_string(), self.build_info(aspect));
162 spec.insert(
163 "paths".to_string(),
164 json!({ path: self.build_path_item(aspect)? }),
165 );
166 spec.insert("components".to_string(), self.build_components(aspect)?);
167
168 Ok(Value::Object(spec))
169 }
170
171 fn build_info(&self, aspect: &Aspect) -> Value {
176 let aspect_name = aspect.name();
177 let title = aspect
178 .metadata()
179 .get_preferred_name(&self.options.language)
180 .map(|s| s.to_string())
181 .unwrap_or_else(|| aspect_name.clone());
182
183 let mut info = json!({
184 "title": title,
185 "version": self.options.api_version,
186 });
187
188 if let Some(desc) = aspect.metadata().get_description(&self.options.language) {
189 if let Some(obj) = info.as_object_mut() {
190 obj.insert("description".to_string(), Value::String(desc.to_string()));
191 }
192 }
193
194 info
195 }
196
197 fn build_path_item(&self, aspect: &Aspect) -> Result<Value> {
199 let mut item = Map::new();
200
201 let aspect_name = aspect.name();
202
203 if self.options.include_get {
204 item.insert(
205 HttpMethod::Get.as_str().to_string(),
206 self.build_operation(aspect, HttpMethod::Get, &aspect_name)?,
207 );
208 }
209 if self.options.include_post {
210 item.insert(
211 HttpMethod::Post.as_str().to_string(),
212 self.build_operation(aspect, HttpMethod::Post, &aspect_name)?,
213 );
214 }
215 if self.options.include_put {
216 item.insert(
217 HttpMethod::Put.as_str().to_string(),
218 self.build_operation(aspect, HttpMethod::Put, &aspect_name)?,
219 );
220 }
221 if self.options.include_delete {
222 item.insert(
223 HttpMethod::Delete.as_str().to_string(),
224 self.build_operation(aspect, HttpMethod::Delete, &aspect_name)?,
225 );
226 }
227
228 Ok(Value::Object(item))
229 }
230
231 fn build_operation(
232 &self,
233 aspect: &Aspect,
234 method: HttpMethod,
235 schema_ref: &str,
236 ) -> Result<Value> {
237 let (summary, description) = match method {
238 HttpMethod::Get => (
239 format!("Get {} data", schema_ref),
240 format!("Retrieve the current state of the {} aspect", schema_ref),
241 ),
242 HttpMethod::Post => (
243 format!("Create {} instance", schema_ref),
244 format!("Create a new {} aspect instance", schema_ref),
245 ),
246 HttpMethod::Put => (
247 format!("Update {} instance", schema_ref),
248 format!("Replace the {} aspect instance", schema_ref),
249 ),
250 HttpMethod::Patch => (
251 format!("Patch {} instance", schema_ref),
252 format!("Partially update the {} aspect instance", schema_ref),
253 ),
254 HttpMethod::Delete => (
255 format!("Delete {} instance", schema_ref),
256 format!("Delete the {} aspect instance", schema_ref),
257 ),
258 };
259
260 let mut op = Map::new();
261 op.insert("summary".to_string(), Value::String(summary));
262 op.insert("description".to_string(), Value::String(description));
263 op.insert(
264 "operationId".to_string(),
265 Value::String(format!("{}_{}", method.as_str(), to_camel_case(schema_ref))),
266 );
267 op.insert(
268 "tags".to_string(),
269 Value::Array(vec![Value::String(schema_ref.to_string())]),
270 );
271
272 if matches!(
274 method,
275 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
276 ) {
277 op.insert(
278 "requestBody".to_string(),
279 self.build_request_body(schema_ref),
280 );
281 }
282
283 op.insert(
284 "responses".to_string(),
285 self.build_responses(aspect, method, schema_ref),
286 );
287
288 Ok(Value::Object(op))
289 }
290
291 fn build_request_body(&self, schema_ref: &str) -> Value {
292 json!({
293 "required": true,
294 "content": {
295 "application/json": {
296 "schema": {
297 "$ref": format!("#/components/schemas/{}", schema_ref)
298 }
299 }
300 }
301 })
302 }
303
304 fn build_responses(&self, _aspect: &Aspect, method: HttpMethod, schema_ref: &str) -> Value {
305 let success_code = match method {
306 HttpMethod::Post => "201",
307 HttpMethod::Delete => "204",
308 _ => "200",
309 };
310
311 let mut responses = Map::new();
312
313 if method == HttpMethod::Delete {
314 responses.insert(
315 success_code.to_string(),
316 json!({ "description": "Successfully deleted" }),
317 );
318 } else {
319 responses.insert(
320 success_code.to_string(),
321 json!({
322 "description": "Successful response",
323 "content": {
324 "application/json": {
325 "schema": {
326 "$ref": format!("#/components/schemas/{}", schema_ref)
327 }
328 }
329 }
330 }),
331 );
332 }
333
334 responses.insert(
336 "400".to_string(),
337 json!({ "description": "Bad request – invalid input" }),
338 );
339 responses.insert("404".to_string(), json!({ "description": "Not found" }));
340 responses.insert(
341 "500".to_string(),
342 json!({ "description": "Internal server error" }),
343 );
344
345 Value::Object(responses)
346 }
347
348 fn build_components(&self, aspect: &Aspect) -> Result<Value> {
350 let schemas = self.build_schemas(aspect)?;
351 Ok(json!({ "schemas": schemas }))
352 }
353
354 pub fn build_schemas(&self, aspect: &Aspect) -> Result<Value> {
356 let mut schemas = Map::new();
357
358 let aspect_schema = self.build_aspect_schema(aspect)?;
360 schemas.insert(aspect.name(), aspect_schema);
361
362 for prop in aspect.properties() {
364 if let Some(char) = &prop.characteristic {
365 if let CharacteristicKind::SingleEntity { entity_type } = char.kind() {
366 let entity_name = entity_type
367 .split('#')
368 .next_back()
369 .unwrap_or(entity_type.as_str())
370 .to_string();
371 if !schemas.contains_key(&entity_name) {
372 schemas.insert(entity_name, json!({ "type": "object" }));
373 }
374 }
375 }
376 }
377
378 Ok(Value::Object(schemas))
379 }
380
381 fn build_aspect_schema(&self, aspect: &Aspect) -> Result<Value> {
382 let mut schema = Map::new();
383
384 schema.insert("type".to_string(), Value::String("object".to_string()));
385
386 if let Some(desc) = aspect.metadata().get_description(&self.options.language) {
387 schema.insert("description".to_string(), Value::String(desc.to_string()));
388 }
389
390 let (properties_map, required) = self.build_properties_schema(aspect.properties())?;
391 schema.insert("properties".to_string(), Value::Object(properties_map));
392 if !required.is_empty() {
393 schema.insert(
394 "required".to_string(),
395 Value::Array(required.into_iter().map(Value::String).collect()),
396 );
397 }
398
399 Ok(Value::Object(schema))
400 }
401
402 fn build_properties_schema(
403 &self,
404 props: &[Property],
405 ) -> Result<(Map<String, Value>, Vec<String>)> {
406 let mut map = Map::new();
407 let mut required = Vec::new();
408
409 for prop in props {
410 let name = prop.payload_name.clone().unwrap_or_else(|| prop.name());
411 let prop_schema = self.property_schema(prop)?;
412 map.insert(name.clone(), prop_schema);
413 if !prop.optional {
414 required.push(name);
415 }
416 }
417
418 Ok((map, required))
419 }
420
421 fn property_schema(&self, prop: &Property) -> Result<Value> {
422 let mut s = Map::new();
423
424 if let Some(desc) = prop.metadata().get_description(&self.options.language) {
425 s.insert("description".to_string(), Value::String(desc.to_string()));
426 }
427
428 if !prop.example_values.is_empty() {
429 s.insert(
430 "example".to_string(),
431 Value::String(prop.example_values.first().cloned().unwrap_or_default()),
432 );
433 }
434
435 if let Some(char) = &prop.characteristic {
436 let type_schema = self.characteristic_schema(char)?;
437 if let Value::Object(type_map) = type_schema {
438 for (k, v) in type_map {
439 s.insert(k, v);
440 }
441 }
442 } else {
443 s.insert("type".to_string(), Value::String("string".to_string()));
444 }
445
446 Ok(Value::Object(s))
447 }
448
449 fn characteristic_schema(&self, char: &Characteristic) -> Result<Value> {
450 match char.kind() {
451 CharacteristicKind::Trait => {
452 let json_type = char
453 .data_type
454 .as_deref()
455 .map(|dt| xsd_to_openapi_type(dt))
456 .unwrap_or("string");
457 Ok(json!({ "type": json_type }))
458 }
459 CharacteristicKind::Measurement { unit }
460 | CharacteristicKind::Quantifiable { unit } => {
461 let json_type = char
462 .data_type
463 .as_deref()
464 .map(|dt| xsd_to_openapi_type(dt))
465 .unwrap_or("number");
466 Ok(json!({
467 "type": json_type,
468 "description": format!("Value expressed in {}", unit)
469 }))
470 }
471 CharacteristicKind::Duration { unit } => Ok(json!({
472 "type": "number",
473 "description": format!("Duration in {}", unit)
474 })),
475 CharacteristicKind::Enumeration { values } => {
476 let data_type = char
477 .data_type
478 .as_deref()
479 .map(|dt| xsd_to_openapi_type(dt))
480 .unwrap_or("string");
481 Ok(json!({
482 "type": data_type,
483 "enum": values
484 }))
485 }
486 CharacteristicKind::State {
487 values,
488 default_value,
489 } => {
490 let data_type = char
491 .data_type
492 .as_deref()
493 .map(|dt| xsd_to_openapi_type(dt))
494 .unwrap_or("string");
495 let mut s = json!({
496 "type": data_type,
497 "enum": values
498 });
499 if let (Some(obj), Some(default)) = (s.as_object_mut(), default_value.as_deref()) {
500 obj.insert("default".to_string(), Value::String(default.to_string()));
501 }
502 Ok(s)
503 }
504 CharacteristicKind::Collection {
505 element_characteristic,
506 }
507 | CharacteristicKind::List {
508 element_characteristic,
509 }
510 | CharacteristicKind::TimeSeries {
511 element_characteristic,
512 } => {
513 let items = if let Some(inner) = element_characteristic {
514 self.characteristic_schema(inner)?
515 } else {
516 json!({})
517 };
518 Ok(json!({ "type": "array", "items": items }))
519 }
520 CharacteristicKind::Set {
521 element_characteristic,
522 }
523 | CharacteristicKind::SortedSet {
524 element_characteristic,
525 } => {
526 let items = if let Some(inner) = element_characteristic {
527 self.characteristic_schema(inner)?
528 } else {
529 json!({})
530 };
531 Ok(json!({ "type": "array", "items": items, "uniqueItems": true }))
532 }
533 CharacteristicKind::Code => {
534 let format: Option<&'static str> =
535 char.data_type.as_deref().and_then(xsd_to_openapi_format);
536 let mut s = json!({ "type": "string" });
537 if let (Some(obj), Some(fmt)) = (s.as_object_mut(), format) {
538 obj.insert("format".to_string(), Value::String(fmt.to_string()));
539 }
540 Ok(s)
541 }
542 CharacteristicKind::Either { left, right } => {
543 let left_schema = self.characteristic_schema(left)?;
544 let right_schema = self.characteristic_schema(right)?;
545 Ok(json!({ "oneOf": [left_schema, right_schema] }))
546 }
547 CharacteristicKind::SingleEntity { entity_type } => {
548 let ref_name = entity_type
549 .split('#')
550 .next_back()
551 .unwrap_or(entity_type.as_str());
552 Ok(json!({ "$ref": format!("#/components/schemas/{}", ref_name) }))
553 }
554 CharacteristicKind::StructuredValue { .. } => {
555 Ok(json!({ "type": "string", "format": "structured-value" }))
556 }
557 }
558 }
559}
560
561fn xsd_to_openapi_type(dt: &str) -> &'static str {
567 if dt.ends_with("boolean") {
568 return "boolean";
569 }
570 if dt.ends_with("int")
571 || dt.ends_with("integer")
572 || dt.ends_with("long")
573 || dt.ends_with("short")
574 || dt.ends_with("byte")
575 || dt.ends_with("unsignedInt")
576 || dt.ends_with("unsignedLong")
577 || dt.ends_with("unsignedShort")
578 || dt.ends_with("positiveInteger")
579 || dt.ends_with("nonNegativeInteger")
580 {
581 return "integer";
582 }
583 if dt.ends_with("decimal") || dt.ends_with("float") || dt.ends_with("double") {
584 return "number";
585 }
586 "string"
587}
588
589fn xsd_to_openapi_format(dt: &str) -> Option<&'static str> {
591 if dt.ends_with("float") {
592 return Some("float");
593 }
594 if dt.ends_with("double") {
595 return Some("double");
596 }
597 if dt.ends_with("int") || dt.ends_with("integer") {
598 return Some("int32");
599 }
600 if dt.ends_with("long") {
601 return Some("int64");
602 }
603 if dt.ends_with("dateTime") || dt.ends_with("dateTimeStamp") {
604 return Some("date-time");
605 }
606 if dt.ends_with("date") {
607 return Some("date");
608 }
609 if dt.ends_with("base64Binary") {
610 return Some("byte");
611 }
612 if dt.ends_with("hexBinary") {
613 return Some("binary");
614 }
615 None
616}
617
618fn to_kebab_case(s: &str) -> String {
620 let mut result = String::new();
621 for (i, ch) in s.chars().enumerate() {
622 if ch.is_uppercase() && i > 0 {
623 result.push('-');
624 }
625 result.push(ch.to_ascii_lowercase());
626 }
627 result
628}
629
630fn to_camel_case(s: &str) -> String {
632 if s.is_empty() {
633 return s.to_string();
634 }
635 let mut chars = s.chars();
636 let first = chars.next().expect("non-empty string").to_ascii_lowercase();
637 format!("{}{}", first, chars.as_str())
638}
639
640#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::metamodel::{Aspect, Characteristic, CharacteristicKind, Property};
648
649 fn movement_aspect() -> Aspect {
650 let mut aspect = Aspect::new("urn:samm:org.example:1.0.0#Movement".to_string());
651 aspect
652 .metadata
653 .add_preferred_name("en".to_string(), "Movement".to_string());
654 aspect
655 .metadata
656 .add_description("en".to_string(), "Describes movement telemetry".to_string());
657
658 let char = Characteristic::new(
659 "urn:samm:org.example:1.0.0#SpeedChar".to_string(),
660 CharacteristicKind::Measurement {
661 unit: "unit:kilometrePerHour".to_string(),
662 },
663 )
664 .with_data_type("http://www.w3.org/2001/XMLSchema#float".to_string());
665
666 let prop =
667 Property::new("urn:samm:org.example:1.0.0#speed".to_string()).with_characteristic(char);
668
669 aspect.add_property(prop);
670 aspect
671 }
672
673 #[test]
674 fn test_generate_openapi_version() {
675 let aspect = movement_aspect();
676 let gen = OpenApiGenerator::new("1.2.3", "/api/v1/aspects");
677 let spec = gen.generate(&aspect).expect("generation should succeed");
678 assert_eq!(spec["openapi"], "3.0.3");
679 assert_eq!(spec["info"]["version"], "1.2.3");
680 }
681
682 #[test]
683 fn test_path_is_present() {
684 let aspect = movement_aspect();
685 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
686 let spec = gen.generate(&aspect).expect("generation should succeed");
687 let paths = spec["paths"].as_object().expect("paths should be object");
688 assert!(!paths.is_empty(), "paths should not be empty");
689 let path_key = paths.keys().next().expect("at least one path");
690 assert!(
691 path_key.contains("movement"),
692 "path should contain aspect name"
693 );
694 }
695
696 #[test]
697 fn test_get_operation_present_by_default() {
698 let aspect = movement_aspect();
699 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
700 let spec = gen.generate(&aspect).expect("generation should succeed");
701 let paths = spec["paths"].as_object().expect("paths");
702 let path_item = paths.values().next().expect("path item");
703 assert!(
704 path_item.get("get").is_some(),
705 "GET operation should be present"
706 );
707 }
708
709 #[test]
710 fn test_post_not_included_by_default() {
711 let aspect = movement_aspect();
712 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
713 let spec = gen.generate(&aspect).expect("generation should succeed");
714 let paths = spec["paths"].as_object().expect("paths");
715 let path_item = paths.values().next().expect("path item");
716 assert!(
717 path_item.get("post").is_none(),
718 "POST should not be present by default"
719 );
720 }
721
722 #[test]
723 fn test_post_included_when_enabled() {
724 let aspect = movement_aspect();
725 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects").with_post();
726 let spec = gen.generate(&aspect).expect("generation should succeed");
727 let paths = spec["paths"].as_object().expect("paths");
728 let path_item = paths.values().next().expect("path item");
729 assert!(path_item.get("post").is_some(), "POST should be present");
730 }
731
732 #[test]
733 fn test_components_schemas_contains_aspect() {
734 let aspect = movement_aspect();
735 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
736 let spec = gen.generate(&aspect).expect("generation should succeed");
737 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
738 assert!(
739 schemas.contains_key("Movement"),
740 "schemas should include Movement"
741 );
742 }
743
744 #[test]
745 fn test_aspect_schema_has_properties() {
746 let aspect = movement_aspect();
747 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
748 let schemas = gen.build_schemas(&aspect).expect("build_schemas");
749 assert!(schemas["Movement"]["properties"]["speed"].is_object());
750 }
751
752 #[test]
753 fn test_aspect_schema_required_field() {
754 let aspect = movement_aspect();
755 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
756 let schemas = gen.build_schemas(&aspect).expect("build_schemas");
757 let required = schemas["Movement"]["required"]
758 .as_array()
759 .expect("required should be array");
760 assert!(required.iter().any(|v| v == "speed"));
761 }
762
763 #[test]
764 fn test_measurement_type_is_number() {
765 let aspect = movement_aspect();
766 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
767 let schemas = gen.build_schemas(&aspect).expect("build_schemas");
768 assert_eq!(schemas["Movement"]["properties"]["speed"]["type"], "number");
769 }
770
771 #[test]
772 fn test_info_description_present() {
773 let aspect = movement_aspect();
774 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
775 let spec = gen.generate(&aspect).expect("generation should succeed");
776 assert!(spec["info"]["description"].is_string());
777 }
778
779 #[test]
780 fn test_response_contains_success_code() {
781 let aspect = movement_aspect();
782 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
783 let spec = gen.generate(&aspect).expect("generation should succeed");
784 let paths = spec["paths"].as_object().expect("paths");
785 let path_item = paths.values().next().expect("path item");
786 let get_op = &path_item["get"];
787 assert!(get_op["responses"]["200"].is_object());
788 }
789
790 #[test]
791 fn test_delete_responds_204() {
792 let aspect = movement_aspect();
793 let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects").with_delete();
794 let spec = gen.generate(&aspect).expect("generation should succeed");
795 let paths = spec["paths"].as_object().expect("paths");
796 let path_item = paths.values().next().expect("path item");
797 let del_op = &path_item["delete"];
798 assert!(del_op["responses"]["204"].is_object());
799 }
800
801 #[test]
802 fn test_enumeration_generates_enum_schema() {
803 let mut aspect = Aspect::new("urn:samm:org.example:1.0.0#TestAspect".to_string());
804 let char = Characteristic::new(
805 "urn:samm:org.example:1.0.0#StatusEnum".to_string(),
806 CharacteristicKind::Enumeration {
807 values: vec!["Active".to_string(), "Inactive".to_string()],
808 },
809 )
810 .with_data_type("http://www.w3.org/2001/XMLSchema#string".to_string());
811 let prop = Property::new("urn:samm:org.example:1.0.0#status".to_string())
812 .with_characteristic(char);
813 aspect.add_property(prop);
814
815 let gen = OpenApiGenerator::new("1.0.0", "/api");
816 let schemas = gen.build_schemas(&aspect).expect("build_schemas");
817 let status = &schemas["TestAspect"]["properties"]["status"];
818 assert!(status["enum"].is_array());
819 }
820
821 #[test]
822 fn test_to_kebab_case() {
823 assert_eq!(to_kebab_case("Movement"), "movement");
824 assert_eq!(to_kebab_case("MyAspect"), "my-aspect");
825 assert_eq!(to_kebab_case("speed"), "speed");
826 }
827
828 #[test]
829 fn test_xsd_to_openapi_type_mapping() {
830 assert_eq!(
831 xsd_to_openapi_type("http://www.w3.org/2001/XMLSchema#boolean"),
832 "boolean"
833 );
834 assert_eq!(
835 xsd_to_openapi_type("http://www.w3.org/2001/XMLSchema#int"),
836 "integer"
837 );
838 assert_eq!(
839 xsd_to_openapi_type("http://www.w3.org/2001/XMLSchema#float"),
840 "number"
841 );
842 assert_eq!(
843 xsd_to_openapi_type("http://www.w3.org/2001/XMLSchema#string"),
844 "string"
845 );
846 }
847}