1use 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#[derive(Debug, Clone)]
60pub struct SpecConfig {
61 pub title: String,
63 pub version: String,
65 pub description: Option<String>,
67 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
82pub struct OpenApiGenerator;
102
103impl OpenApiGenerator {
104 #[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 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 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#[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}