Skip to main content

oxirs_samm/codegen/
openapi.rs

1//! SAMM to OpenAPI 3.0 Specification Generator
2//!
3//! Generates [OpenAPI 3.0.3](https://spec.openapis.org/oas/v3.0.3) specification
4//! documents from SAMM Aspect Models.  The resulting spec exposes a REST endpoint
5//! that returns the Aspect payload, and documents all types as reusable schema
6//! components.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use oxirs_samm::parser::parse_aspect_model;
12//! use oxirs_samm::codegen::OpenApiGenerator;
13//!
14//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
15//! let aspect = parse_aspect_model("Movement.ttl").await?;
16//! let gen = OpenApiGenerator::new("1.0.0", "/api/v1/aspects");
17//! let spec = gen.generate(&aspect)?;
18//! println!("{}", serde_json::to_string_pretty(&spec)?);
19//! # Ok(())
20//! # }
21//! ```
22
23use serde_json::{json, Map, Value};
24
25use crate::error::{Result, SammError};
26use crate::metamodel::{
27    Aspect, Characteristic, CharacteristicKind, Entity, ModelElement, Property,
28};
29
30// ------------------------------------------------------------------ //
31//  HTTP method vocabulary                                              //
32// ------------------------------------------------------------------ //
33
34/// HTTP methods supported in generated path items.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HttpMethod {
37    /// HTTP GET
38    Get,
39    /// HTTP POST
40    Post,
41    /// HTTP PUT
42    Put,
43    /// HTTP PATCH
44    Patch,
45    /// HTTP DELETE
46    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// ------------------------------------------------------------------ //
62//  Configuration                                                       //
63// ------------------------------------------------------------------ //
64
65/// Configuration for [`OpenApiGenerator`].
66#[derive(Debug, Clone)]
67pub struct OpenApiOptions {
68    /// The base path prefix for all generated endpoints.
69    pub base_path: String,
70    /// API version string embedded in the `info` object.
71    pub api_version: String,
72    /// Whether to include a GET endpoint for reading the Aspect.
73    pub include_get: bool,
74    /// Whether to include a POST endpoint for creating Aspect instances.
75    pub include_post: bool,
76    /// Whether to include a PUT endpoint for updating Aspect instances.
77    pub include_put: bool,
78    /// Whether to include a DELETE endpoint.
79    pub include_delete: bool,
80    /// Prefer JSON Schema `$defs` (2020-12) style inside component schemas.
81    pub use_defs_keyword: bool,
82    /// Language used for description / title lookup.
83    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// ------------------------------------------------------------------ //
102//  Generator                                                           //
103// ------------------------------------------------------------------ //
104
105/// Generates OpenAPI 3.0.3 specifications from SAMM Aspect Models.
106#[derive(Debug, Clone)]
107pub struct OpenApiGenerator {
108    options: OpenApiOptions,
109}
110
111impl OpenApiGenerator {
112    /// Create a generator with the given API version and base path.
113    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    /// Create a generator from fully-specified [`OpenApiOptions`].
123    pub fn with_options(options: OpenApiOptions) -> Self {
124        Self { options }
125    }
126
127    /// Enable a POST endpoint in the generated spec.
128    pub fn with_post(mut self) -> Self {
129        self.options.include_post = true;
130        self
131    }
132
133    /// Enable a PUT endpoint in the generated spec.
134    pub fn with_put(mut self) -> Self {
135        self.options.include_put = true;
136        self
137    }
138
139    /// Enable a DELETE endpoint in the generated spec.
140    pub fn with_delete(mut self) -> Self {
141        self.options.include_delete = true;
142        self
143    }
144
145    // ---------------------------------------------------------------- //
146    //  Public API                                                        //
147    // ---------------------------------------------------------------- //
148
149    /// Generate an OpenAPI 3.0.3 specification `Value` for the given `aspect`.
150    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        // Top-level document
159        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    // ---------------------------------------------------------------- //
172    //  Private builders                                                  //
173    // ---------------------------------------------------------------- //
174
175    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    /// Build the path item object that lists all operations for the aspect path.
198    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        // Request body for mutating methods
273        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        // Standard error responses
335        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    /// Build the `components` section, including all schemas.
349    fn build_components(&self, aspect: &Aspect) -> Result<Value> {
350        let schemas = self.build_schemas(aspect)?;
351        Ok(json!({ "schemas": schemas }))
352    }
353
354    /// Build the `components/schemas` mapping.
355    pub fn build_schemas(&self, aspect: &Aspect) -> Result<Value> {
356        let mut schemas = Map::new();
357
358        // Primary aspect schema
359        let aspect_schema = self.build_aspect_schema(aspect)?;
360        schemas.insert(aspect.name(), aspect_schema);
361
362        // Inline entity schemas (SingleEntity references)
363        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
561// ------------------------------------------------------------------ //
562//  Helper functions                                                    //
563// ------------------------------------------------------------------ //
564
565/// Map XSD/SAMM data type string to an OpenAPI type string.
566fn 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
589/// Map XSD/SAMM data type to an OpenAPI `format` hint (returns `None` for string).
590fn 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
618/// Convert PascalCase / camelCase to kebab-case for path segments.
619fn 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
630/// Convert PascalCase or snake_case to camelCase.
631fn 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// ------------------------------------------------------------------ //
641//  Tests                                                               //
642// ------------------------------------------------------------------ //
643
644#[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}