Skip to main content

stygian_graph/adapters/
openapi_gen.rs

1//! OpenAPI 3.0 spec generator from API discovery reports.
2//!
3//! Takes a [`DiscoveryReport`](crate::domain::discovery::DiscoveryReport) and
4//! produces an [`openapiv3::OpenAPI`] specification.
5//!
6//! # Architecture
7//!
8//! This is an **adapter** that converts domain-level discovery types into
9//! the `openapiv3` representation.  The domain layer has no knowledge of
10//! OpenAPI; this adapter bridges that gap.
11//!
12//! # Example
13//!
14//! ```
15//! use stygian_graph::adapters::openapi_gen::{OpenApiGenerator, SpecConfig};
16//! use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
17//! use serde_json::json;
18//!
19//! let mut report = DiscoveryReport::new();
20//! report.add_endpoint("list_users", ResponseShape::from_body(&json!({"id": 1, "name": "A"})));
21//!
22//! let config = SpecConfig {
23//!     title: "My API".into(),
24//!     version: "1.0.0".into(),
25//!     description: Some("Auto-discovered API".into()),
26//!     servers: vec!["https://api.example.com".into()],
27//! };
28//!
29//! let spec = OpenApiGenerator::generate(&report, &config);
30//! assert_eq!(spec.info.title, "My API");
31//! ```
32
33use crate::domain::discovery::{DiscoveryReport, JsonType};
34use indexmap::IndexMap;
35use openapiv3::{
36    Info, MediaType, OpenAPI, Operation, PathItem, ReferenceOr, Response, Schema, SchemaData,
37    SchemaKind, Server, StatusCode, Type as OaType,
38};
39use std::collections::BTreeMap;
40
41// ─────────────────────────────────────────────────────────────────────────────
42// SpecConfig
43// ─────────────────────────────────────────────────────────────────────────────
44
45/// Configuration for the generated OpenAPI specification.
46///
47/// # Example
48///
49/// ```
50/// use stygian_graph::adapters::openapi_gen::SpecConfig;
51///
52/// let config = SpecConfig {
53///     title: "Pet Store".into(),
54///     version: "2.0.0".into(),
55///     description: Some("A sample API".into()),
56///     servers: vec!["https://petstore.example.com/v2".into()],
57/// };
58/// ```
59#[derive(Debug, Clone)]
60pub struct SpecConfig {
61    /// API title
62    pub title: String,
63    /// API version
64    pub version: String,
65    /// Optional description
66    pub description: Option<String>,
67    /// Server URLs
68    pub servers: Vec<String>,
69}
70
71impl Default for SpecConfig {
72    fn default() -> Self {
73        Self {
74            title: "Discovered API".into(),
75            version: "0.1.0".into(),
76            description: None,
77            servers: Vec::new(),
78        }
79    }
80}
81
82// ─────────────────────────────────────────────────────────────────────────────
83// OpenApiGenerator
84// ─────────────────────────────────────────────────────────────────────────────
85
86/// Generates an OpenAPI 3.0 specification from a [`DiscoveryReport`].
87///
88/// # Example
89///
90/// ```
91/// use stygian_graph::adapters::openapi_gen::{OpenApiGenerator, SpecConfig};
92/// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
93/// use serde_json::json;
94///
95/// let mut report = DiscoveryReport::new();
96/// report.add_endpoint("health", ResponseShape::from_body(&json!({"status": "ok"})));
97///
98/// let spec = OpenApiGenerator::generate(&report, &SpecConfig::default());
99/// assert!(!spec.paths.paths.is_empty());
100/// ```
101pub struct OpenApiGenerator;
102
103impl OpenApiGenerator {
104    /// Generate an [`OpenAPI`] spec from a discovery report and config.
105    ///
106    /// Each endpoint in the report becomes a `GET` path.  Response schemas
107    /// are inferred from the discovered field types.
108    ///
109    /// # Example
110    ///
111    /// ```
112    /// use stygian_graph::adapters::openapi_gen::{OpenApiGenerator, SpecConfig};
113    /// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
114    /// use serde_json::json;
115    ///
116    /// let mut report = DiscoveryReport::new();
117    /// report.add_endpoint("list_items", ResponseShape::from_body(&json!({"id": 1})));
118    ///
119    /// let spec = OpenApiGenerator::generate(&report, &SpecConfig::default());
120    /// let yaml = serde_yaml::to_string(&spec).unwrap();
121    /// assert!(yaml.contains("list_items"));
122    /// ```
123    #[must_use]
124    pub fn generate(report: &DiscoveryReport, config: &SpecConfig) -> OpenAPI {
125        let info = Info {
126            title: config.title.clone(),
127            version: config.version.clone(),
128            description: config.description.clone(),
129            ..Default::default()
130        };
131
132        let servers: Vec<Server> = config
133            .servers
134            .iter()
135            .map(|url| Server {
136                url: url.clone(),
137                ..Default::default()
138            })
139            .collect();
140
141        let mut paths = openapiv3::Paths::default();
142
143        for (name, shape) in report.endpoints() {
144            let path = format!("/{name}");
145            let schema = Self::fields_to_schema(&shape.fields);
146
147            let mut content = IndexMap::new();
148            content.insert(
149                "application/json".to_string(),
150                MediaType {
151                    schema: Some(ReferenceOr::Item(schema)),
152                    ..Default::default()
153                },
154            );
155
156            let response_200 = Response {
157                description: format!("Successful response for {name}"),
158                content,
159                ..Default::default()
160            };
161
162            let mut responses = openapiv3::Responses::default();
163            responses
164                .responses
165                .insert(StatusCode::Code(200), ReferenceOr::Item(response_200));
166
167            let operation = Operation {
168                operation_id: Some(name.clone()),
169                responses,
170                ..Default::default()
171            };
172
173            let path_item = PathItem {
174                get: Some(operation),
175                ..Default::default()
176            };
177
178            paths.paths.insert(path, ReferenceOr::Item(path_item));
179        }
180
181        OpenAPI {
182            openapi: "3.0.3".to_string(),
183            info,
184            servers,
185            paths,
186            ..Default::default()
187        }
188    }
189
190    /// Convert a field map to an OpenAPI object schema.
191    fn fields_to_schema(fields: &BTreeMap<String, JsonType>) -> Schema {
192        let properties: IndexMap<String, ReferenceOr<Box<Schema>>> = fields
193            .iter()
194            .map(|(k, v)| {
195                (
196                    k.clone(),
197                    ReferenceOr::Item(Box::new(Self::json_type_to_schema(v))),
198                )
199            })
200            .collect();
201
202        Schema {
203            schema_data: SchemaData::default(),
204            schema_kind: SchemaKind::Type(OaType::Object(openapiv3::ObjectType {
205                properties,
206                ..Default::default()
207            })),
208        }
209    }
210
211    /// Convert a [`JsonType`] to an OpenAPI schema.
212    fn json_type_to_schema(jt: &JsonType) -> Schema {
213        match jt {
214            JsonType::Null => Schema {
215                schema_data: SchemaData {
216                    nullable: true,
217                    ..Default::default()
218                },
219                schema_kind: SchemaKind::Type(OaType::String(Default::default())),
220            },
221            JsonType::Bool => Schema {
222                schema_data: SchemaData::default(),
223                schema_kind: SchemaKind::Type(OaType::Boolean {}),
224            },
225            JsonType::Integer => Schema {
226                schema_data: SchemaData::default(),
227                schema_kind: SchemaKind::Type(OaType::Integer(Default::default())),
228            },
229            JsonType::Float => Schema {
230                schema_data: SchemaData::default(),
231                schema_kind: SchemaKind::Type(OaType::Number(Default::default())),
232            },
233            JsonType::String | JsonType::Mixed => Schema {
234                schema_data: SchemaData::default(),
235                schema_kind: SchemaKind::Type(OaType::String(Default::default())),
236            },
237            JsonType::Array(inner) => Schema {
238                schema_data: SchemaData::default(),
239                schema_kind: SchemaKind::Type(OaType::Array(openapiv3::ArrayType {
240                    items: Some(ReferenceOr::Item(Box::new(Self::json_type_to_schema(
241                        inner,
242                    )))),
243                    min_items: None,
244                    max_items: None,
245                    unique_items: false,
246                })),
247            },
248            JsonType::Object(fields) => Self::fields_to_schema(fields),
249        }
250    }
251}
252
253// ─────────────────────────────────────────────────────────────────────────────
254// Tests
255// ─────────────────────────────────────────────────────────────────────────────
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::domain::discovery::{DiscoveryReport, ResponseShape};
261    use serde_json::json;
262
263    #[test]
264    fn generate_empty_report_produces_valid_spec() {
265        let report = DiscoveryReport::new();
266        let spec = OpenApiGenerator::generate(&report, &SpecConfig::default());
267        assert_eq!(spec.openapi, "3.0.3");
268        assert_eq!(spec.info.title, "Discovered API");
269        assert!(spec.paths.paths.is_empty());
270    }
271
272    #[test]
273    fn generate_single_endpoint() {
274        let mut report = DiscoveryReport::new();
275        report.add_endpoint(
276            "list_items",
277            ResponseShape::from_body(&json!({"id": 1, "name": "Widget"})),
278        );
279
280        let config = SpecConfig {
281            title: "Test API".into(),
282            version: "1.0.0".into(),
283            description: Some("Test".into()),
284            servers: vec!["https://api.test.com".into()],
285        };
286
287        let spec = OpenApiGenerator::generate(&report, &config);
288        assert_eq!(spec.info.title, "Test API");
289        assert_eq!(spec.servers.len(), 1);
290        assert!(spec.paths.paths.contains_key("/list_items"));
291    }
292
293    #[test]
294    fn generate_multiple_endpoints() {
295        let mut report = DiscoveryReport::new();
296        report.add_endpoint("users", ResponseShape::from_body(&json!({"id": 1})));
297        report.add_endpoint("orders", ResponseShape::from_body(&json!({"total": 42.5})));
298
299        let spec = OpenApiGenerator::generate(&report, &SpecConfig::default());
300        assert_eq!(spec.paths.paths.len(), 2);
301        assert!(spec.paths.paths.contains_key("/users"));
302        assert!(spec.paths.paths.contains_key("/orders"));
303    }
304
305    #[test]
306    fn spec_serialises_to_yaml() {
307        let mut report = DiscoveryReport::new();
308        report.add_endpoint("health", ResponseShape::from_body(&json!({"status": "ok"})));
309
310        let spec = OpenApiGenerator::generate(&report, &SpecConfig::default());
311        let yaml = serde_yaml::to_string(&spec);
312        assert!(yaml.is_ok());
313        let yaml = yaml.expect("yaml serialisation");
314        assert!(yaml.contains("health"));
315        assert!(yaml.contains("3.0.3"));
316    }
317}