Skip to main content

reinhardt_urls/routers/
openapi_integration.rs

1//! OpenAPI integration for automatic schema generation
2//!
3//! This module provides utilities for generating OpenAPI schemas from registered routes.
4//! It integrates with the `reinhardt-openapi` crate to provide automatic documentation.
5//!
6//! # Examples
7//!
8//! ```
9//! use reinhardt_urls::routers::openapi_integration::{OpenApiBuilder, PathItem};
10//! use hyper::Method;
11//!
12//! let mut builder = OpenApiBuilder::new("My API", "1.0.0");
13//!
14//! // Add routes
15//! builder.add_path(
16//!     "/api/users/",
17//!     PathItem::new()
18//!         .with_method(Method::GET, "List all users", Some("api:users:list"))
19//! );
20//!
21//! // Generate OpenAPI spec
22//! let spec = builder.build();
23//! assert_eq!(spec.info.title, "My API");
24//! ```
25
26use crate::routers::introspection::{RouteInfo, RouteInspector};
27use hyper::Method;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30
31/// OpenAPI specification version
32const OPENAPI_VERSION: &str = "3.0.3";
33
34/// OpenAPI specification
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct OpenApiSpec {
37	/// OpenAPI version
38	pub openapi: String,
39
40	/// API information
41	pub info: InfoObject,
42
43	/// Server information
44	#[serde(skip_serializing_if = "Vec::is_empty")]
45	pub servers: Vec<ServerObject>,
46
47	/// API paths
48	pub paths: HashMap<String, PathItemObject>,
49
50	/// Reusable components
51	#[serde(skip_serializing_if = "Option::is_none")]
52	pub components: Option<ComponentsObject>,
53
54	/// Security requirements
55	#[serde(skip_serializing_if = "Vec::is_empty")]
56	pub security: Vec<HashMap<String, Vec<String>>>,
57
58	/// Tags for grouping operations
59	#[serde(skip_serializing_if = "Vec::is_empty")]
60	pub tags: Vec<TagObject>,
61}
62
63/// API information object
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct InfoObject {
66	/// API title
67	pub title: String,
68
69	/// API version
70	pub version: String,
71
72	/// API description
73	#[serde(skip_serializing_if = "Option::is_none")]
74	pub description: Option<String>,
75
76	/// Terms of service URL
77	#[serde(skip_serializing_if = "Option::is_none")]
78	#[serde(rename = "termsOfService")]
79	pub terms_of_service: Option<String>,
80
81	/// Contact information
82	#[serde(skip_serializing_if = "Option::is_none")]
83	pub contact: Option<ContactObject>,
84
85	/// License information
86	#[serde(skip_serializing_if = "Option::is_none")]
87	pub license: Option<LicenseObject>,
88}
89
90/// Contact information
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ContactObject {
93	/// Contact name.
94	#[serde(skip_serializing_if = "Option::is_none")]
95	pub name: Option<String>,
96
97	/// Contact URL.
98	#[serde(skip_serializing_if = "Option::is_none")]
99	pub url: Option<String>,
100
101	/// Contact email address.
102	#[serde(skip_serializing_if = "Option::is_none")]
103	pub email: Option<String>,
104}
105
106/// License information
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct LicenseObject {
109	/// License name (e.g., `"MIT"`, `"Apache-2.0"`).
110	pub name: String,
111
112	/// URL to the full license text.
113	#[serde(skip_serializing_if = "Option::is_none")]
114	pub url: Option<String>,
115}
116
117/// Server object
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ServerObject {
120	/// Base URL of the server.
121	pub url: String,
122
123	/// Human-readable description of the server.
124	#[serde(skip_serializing_if = "Option::is_none")]
125	pub description: Option<String>,
126}
127
128/// Path item object
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PathItemObject {
131	/// GET operation for this path.
132	#[serde(skip_serializing_if = "Option::is_none")]
133	pub get: Option<OperationObject>,
134
135	/// POST operation for this path.
136	#[serde(skip_serializing_if = "Option::is_none")]
137	pub post: Option<OperationObject>,
138
139	/// PUT operation for this path.
140	#[serde(skip_serializing_if = "Option::is_none")]
141	pub put: Option<OperationObject>,
142
143	/// PATCH operation for this path.
144	#[serde(skip_serializing_if = "Option::is_none")]
145	pub patch: Option<OperationObject>,
146
147	/// DELETE operation for this path.
148	#[serde(skip_serializing_if = "Option::is_none")]
149	pub delete: Option<OperationObject>,
150
151	/// Path-level parameters shared by all operations.
152	#[serde(skip_serializing_if = "Vec::is_empty")]
153	pub parameters: Vec<ParameterObject>,
154}
155
156/// Operation object (HTTP method on a path)
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct OperationObject {
159	/// Short summary of the operation.
160	#[serde(skip_serializing_if = "Option::is_none")]
161	pub summary: Option<String>,
162
163	/// Detailed description of the operation.
164	#[serde(skip_serializing_if = "Option::is_none")]
165	pub description: Option<String>,
166
167	/// Unique identifier for the operation.
168	#[serde(skip_serializing_if = "Option::is_none")]
169	#[serde(rename = "operationId")]
170	pub operation_id: Option<String>,
171
172	/// Tags for grouping this operation.
173	#[serde(skip_serializing_if = "Vec::is_empty")]
174	pub tags: Vec<String>,
175
176	/// Operation-specific parameters.
177	#[serde(skip_serializing_if = "Vec::is_empty")]
178	pub parameters: Vec<ParameterObject>,
179
180	/// Map of HTTP status codes to response descriptions.
181	pub responses: HashMap<String, ResponseObject>,
182}
183
184/// Parameter object
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ParameterObject {
187	/// Parameter name.
188	pub name: String,
189
190	/// Location of the parameter (`"path"`, `"query"`, `"header"`, or `"cookie"`).
191	#[serde(rename = "in")]
192	pub location: String,
193
194	/// Human-readable description of the parameter.
195	#[serde(skip_serializing_if = "Option::is_none")]
196	pub description: Option<String>,
197
198	/// Whether the parameter is required.
199	pub required: bool,
200
201	/// Schema describing the parameter type.
202	pub schema: SchemaObject,
203}
204
205/// Response object
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ResponseObject {
208	/// Human-readable description of the response.
209	pub description: String,
210
211	/// Map of media types to their schemas.
212	#[serde(skip_serializing_if = "Option::is_none")]
213	pub content: Option<HashMap<String, MediaTypeObject>>,
214}
215
216/// Media type object
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct MediaTypeObject {
219	/// Schema describing the media type content.
220	pub schema: SchemaObject,
221}
222
223/// Schema object (simplified)
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct SchemaObject {
226	/// The JSON Schema type (e.g., `"string"`, `"integer"`, `"object"`).
227	#[serde(rename = "type")]
228	pub schema_type: String,
229
230	/// Additional format hint (e.g., `"int64"`, `"date-time"`).
231	#[serde(skip_serializing_if = "Option::is_none")]
232	pub format: Option<String>,
233}
234
235/// Components object (reusable schemas)
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ComponentsObject {
238	/// Map of schema names to their definitions.
239	#[serde(skip_serializing_if = "HashMap::is_empty")]
240	pub schemas: HashMap<String, SchemaObject>,
241}
242
243/// Tag object
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct TagObject {
246	/// Tag name used for grouping operations.
247	pub name: String,
248
249	/// Human-readable description of the tag.
250	#[serde(skip_serializing_if = "Option::is_none")]
251	pub description: Option<String>,
252}
253
254/// Builder for creating PathItem objects
255#[derive(Debug, Clone, Default)]
256pub struct PathItem {
257	operations: HashMap<Method, OperationObject>,
258	parameters: Vec<ParameterObject>,
259}
260
261impl PathItem {
262	/// Create a new PathItem builder
263	///
264	/// # Examples
265	///
266	/// ```
267	/// use reinhardt_urls::routers::openapi_integration::PathItem;
268	///
269	/// let path_item = PathItem::new();
270	/// ```
271	pub fn new() -> Self {
272		Self::default()
273	}
274
275	/// Add an operation for an HTTP method
276	///
277	/// # Examples
278	///
279	/// ```
280	/// use reinhardt_urls::routers::openapi_integration::PathItem;
281	/// use hyper::Method;
282	///
283	/// let path_item = PathItem::new()
284	///     .with_method(Method::GET, "Get user", Some("users:detail"));
285	/// ```
286	pub fn with_method(
287		mut self,
288		method: Method,
289		summary: impl Into<String>,
290		operation_id: Option<impl Into<String>>,
291	) -> Self {
292		let operation = OperationObject {
293			summary: Some(summary.into()),
294			description: None,
295			operation_id: operation_id.map(|s| s.into()),
296			tags: Vec::new(),
297			parameters: Vec::new(),
298			responses: HashMap::new(),
299		};
300
301		self.operations.insert(method, operation);
302		self
303	}
304
305	/// Add a path parameter
306	///
307	/// # Examples
308	///
309	/// ```
310	/// use reinhardt_urls::routers::openapi_integration::PathItem;
311	///
312	/// let path_item = PathItem::new()
313	///     .with_parameter("id", "User ID", "string");
314	/// ```
315	pub fn with_parameter(
316		mut self,
317		name: impl Into<String>,
318		description: impl Into<String>,
319		schema_type: impl Into<String>,
320	) -> Self {
321		let param = ParameterObject {
322			name: name.into(),
323			location: "path".to_string(),
324			description: Some(description.into()),
325			required: true,
326			schema: SchemaObject {
327				schema_type: schema_type.into(),
328				format: None,
329			},
330		};
331
332		self.parameters.push(param);
333		self
334	}
335
336	/// Build the PathItemObject
337	fn build(self) -> PathItemObject {
338		let mut path_item = PathItemObject {
339			get: None,
340			post: None,
341			put: None,
342			patch: None,
343			delete: None,
344			parameters: self.parameters,
345		};
346
347		for (method, operation) in self.operations {
348			match method {
349				Method::GET => path_item.get = Some(operation),
350				Method::POST => path_item.post = Some(operation),
351				Method::PUT => path_item.put = Some(operation),
352				Method::PATCH => path_item.patch = Some(operation),
353				Method::DELETE => path_item.delete = Some(operation),
354				_ => {}
355			}
356		}
357
358		path_item
359	}
360}
361
362/// OpenAPI specification builder
363///
364/// # Examples
365///
366/// ```
367/// use reinhardt_urls::routers::openapi_integration::{OpenApiBuilder, PathItem};
368/// use hyper::Method;
369///
370/// let mut builder = OpenApiBuilder::new("My API", "1.0.0");
371/// builder.description("A sample API");
372/// builder.add_server("https://api.example.com", None);
373///
374/// builder.add_path(
375///     "/users/",
376///     PathItem::new().with_method(Method::GET, "List users", Some("users:list"))
377/// );
378///
379/// let spec = builder.build();
380/// assert_eq!(spec.info.title, "My API");
381/// ```
382pub struct OpenApiBuilder {
383	info: InfoObject,
384	servers: Vec<ServerObject>,
385	paths: HashMap<String, PathItemObject>,
386	tags: Vec<TagObject>,
387}
388
389impl OpenApiBuilder {
390	/// Create a new OpenAPI builder
391	///
392	/// # Examples
393	///
394	/// ```
395	/// use reinhardt_urls::routers::openapi_integration::OpenApiBuilder;
396	///
397	/// let builder = OpenApiBuilder::new("My API", "1.0.0");
398	/// ```
399	pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
400		Self {
401			info: InfoObject {
402				title: title.into(),
403				version: version.into(),
404				description: None,
405				terms_of_service: None,
406				contact: None,
407				license: None,
408			},
409			servers: Vec::new(),
410			paths: HashMap::new(),
411			tags: Vec::new(),
412		}
413	}
414
415	/// Set API description
416	pub fn description(&mut self, description: impl Into<String>) -> &mut Self {
417		self.info.description = Some(description.into());
418		self
419	}
420
421	/// Add a server
422	pub fn add_server(&mut self, url: impl Into<String>, description: Option<String>) -> &mut Self {
423		self.servers.push(ServerObject {
424			url: url.into(),
425			description,
426		});
427		self
428	}
429
430	/// Add a tag
431	pub fn add_tag(&mut self, name: impl Into<String>, description: Option<String>) -> &mut Self {
432		self.tags.push(TagObject {
433			name: name.into(),
434			description,
435		});
436		self
437	}
438
439	/// Add a path
440	pub fn add_path(&mut self, path: impl Into<String>, path_item: PathItem) -> &mut Self {
441		self.paths.insert(path.into(), path_item.build());
442		self
443	}
444
445	/// Build the OpenAPI specification
446	pub fn build(self) -> OpenApiSpec {
447		OpenApiSpec {
448			openapi: OPENAPI_VERSION.to_string(),
449			info: self.info,
450			servers: self.servers,
451			paths: self.paths,
452			components: None,
453			security: Vec::new(),
454			tags: self.tags,
455		}
456	}
457
458	/// Build from a RouteInspector
459	///
460	/// # Examples
461	///
462	/// ```
463	/// use reinhardt_urls::routers::introspection::RouteInspector;
464	/// use reinhardt_urls::routers::openapi_integration::OpenApiBuilder;
465	/// use hyper::Method;
466	///
467	/// let mut inspector = RouteInspector::new();
468	/// inspector.add_route("/users/", vec![Method::GET], Some("users:list"), None);
469	///
470	/// let spec = OpenApiBuilder::from_inspector(
471	///     "My API",
472	///     "1.0.0",
473	///     &inspector
474	/// ).build();
475	///
476	/// assert_eq!(spec.paths.len(), 1);
477	/// ```
478	pub fn from_inspector(
479		title: impl Into<String>,
480		version: impl Into<String>,
481		inspector: &RouteInspector,
482	) -> Self {
483		let mut builder = Self::new(title, version);
484
485		// Extract tags from namespaces
486		for namespace in inspector.all_namespaces() {
487			builder.add_tag(&namespace, None);
488		}
489
490		// Add paths
491		for route in inspector.all_routes() {
492			builder.add_route_info(route);
493		}
494
495		builder
496	}
497
498	/// Add a route from RouteInfo
499	fn add_route_info(&mut self, route: &RouteInfo) {
500		let mut path_item = PathItem::new();
501
502		// Add parameters from path
503		for param in &route.params {
504			path_item = path_item.with_parameter(param, format!("{} parameter", param), "string");
505		}
506
507		// Add operations for each method
508		for method_str in &route.methods {
509			// Parse string back to Method
510			if let Ok(method) = method_str.parse::<Method>() {
511				let operation_id = route.name.clone();
512				let summary = route
513					.metadata
514					.get("summary")
515					.cloned()
516					.unwrap_or_else(|| format!("{} {}", method.as_str(), route.path));
517
518				path_item = path_item.with_method(method, summary, operation_id);
519			}
520		}
521
522		self.paths.insert(route.path.clone(), path_item.build());
523	}
524}
525
526impl OpenApiSpec {
527	/// Export as JSON
528	///
529	/// # Examples
530	///
531	/// ```
532	/// use reinhardt_urls::routers::openapi_integration::OpenApiBuilder;
533	///
534	/// let spec = OpenApiBuilder::new("My API", "1.0.0").build();
535	/// let json = spec.to_json().unwrap();
536	/// assert!(json.contains("My API"));
537	/// ```
538	pub fn to_json(&self) -> Result<String, serde_json::Error> {
539		serde_json::to_string_pretty(self)
540	}
541
542	/// Export as YAML
543	///
544	/// # Examples
545	///
546	/// ```
547	/// use reinhardt_urls::routers::openapi_integration::OpenApiBuilder;
548	///
549	/// let spec = OpenApiBuilder::new("My API", "1.0.0").build();
550	/// let yaml = spec.to_yaml().unwrap();
551	/// assert!(yaml.contains("My API"));
552	/// ```
553	pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
554		serde_yaml::to_string(self)
555	}
556}
557
558#[cfg(test)]
559mod tests {
560	use super::*;
561
562	#[test]
563	fn test_openapi_builder_basic() {
564		let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
565		builder.description("A test API");
566
567		let spec = builder.build();
568		assert_eq!(spec.info.title, "Test API");
569		assert_eq!(spec.info.version, "1.0.0");
570		assert_eq!(spec.info.description, Some("A test API".to_string()));
571	}
572
573	#[test]
574	fn test_openapi_builder_with_server() {
575		let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
576		builder.add_server(
577			"https://api.example.com",
578			Some("Production server".to_string()),
579		);
580
581		let spec = builder.build();
582		assert_eq!(spec.servers.len(), 1);
583		assert_eq!(spec.servers[0].url, "https://api.example.com");
584	}
585
586	#[test]
587	fn test_path_item_builder() {
588		let path_item = PathItem::new()
589			.with_method(Method::GET, "Get user", Some("users:detail"))
590			.with_parameter("id", "User ID", "string");
591
592		let path_item_obj = path_item.build();
593		assert!(path_item_obj.get.is_some());
594		assert_eq!(path_item_obj.parameters.len(), 1);
595	}
596
597	#[test]
598	fn test_openapi_builder_from_inspector() {
599		let mut inspector = RouteInspector::new();
600		inspector.add_route("/users/", vec![Method::GET], Some("users:list"), None);
601		inspector.add_route(
602			"/users/{id}/",
603			vec![Method::GET],
604			Some("users:detail"),
605			None,
606		);
607
608		let spec = OpenApiBuilder::from_inspector("Test API", "1.0.0", &inspector).build();
609
610		assert_eq!(spec.paths.len(), 2);
611		assert!(spec.paths.contains_key("/users/"));
612		assert!(spec.paths.contains_key("/users/{id}/"));
613	}
614
615	#[test]
616	fn test_openapi_spec_to_json() {
617		let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
618		let json = spec.to_json().unwrap();
619		assert!(json.contains("Test API"));
620		assert!(json.contains("3.0.3"));
621	}
622}