1use serde::Serialize;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct OpenApiSpec {
7 pub openapi: String,
8 pub info: OpenApiInfo,
9 pub paths: HashMap<String, PathItem>,
10 #[serde(skip_serializing_if = "Option::is_none")]
11 pub components: Option<Components>,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct OpenApiInfo {
16 pub title: String,
17 pub version: String,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct PathItem {
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub get: Option<Operation>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub post: Option<Operation>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub put: Option<Operation>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub delete: Option<Operation>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub patch: Option<Operation>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub head: Option<Operation>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub options: Option<Operation>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct Operation {
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub summary: Option<String>,
42 #[serde(skip_serializing_if = "Vec::is_empty", default)]
43 pub tags: Vec<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub request_body: Option<RequestBody>,
46 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
47 pub responses: HashMap<String, Response>,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct RequestBody {
52 pub content: HashMap<String, MediaType>,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct Response {
57 pub description: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub content: Option<HashMap<String, MediaType>>,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct MediaType {
64 pub schema: Schema,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct Schema {
69 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
70 pub schema_type: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub properties: Option<HashMap<String, Schema>>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub reference: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct Components {
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub schemas: Option<HashMap<String, Schema>>,
81}
82
83pub struct OpenApiBuilder {
84 title: String,
85 version: String,
86 paths: HashMap<String, PathItem>,
87 schemas: HashMap<String, Schema>,
88}
89
90impl OpenApiBuilder {
91 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
92 Self {
93 title: title.into(),
94 version: version.into(),
95 paths: HashMap::new(),
96 schemas: HashMap::new(),
97 }
98 }
99
100 pub fn add_route(
101 mut self,
102 path: impl Into<String>,
103 method: &str,
104 summary: impl Into<String>,
105 tags: Vec<String>,
106 ) -> Self {
107 let entry = self.paths.entry(path.into()).or_insert(PathItem {
108 get: None,
109 post: None,
110 put: None,
111 delete: None,
112 patch: None,
113 head: None,
114 options: None,
115 });
116 let op = Operation {
117 summary: Some(summary.into()),
118 tags,
119 request_body: None,
120 responses: {
121 let mut m = HashMap::new();
122 m.insert(
123 "200".into(),
124 Response {
125 description: "Successful response".into(),
126 content: None,
127 },
128 );
129 m
130 },
131 };
132 match method {
133 "GET" | "get" => entry.get = Some(op),
134 "POST" | "post" => entry.post = Some(op),
135 "PUT" | "put" => entry.put = Some(op),
136 "DELETE" | "delete" => entry.delete = Some(op),
137 "PATCH" | "patch" => entry.patch = Some(op),
138 "HEAD" | "head" => entry.head = Some(op),
139 "OPTIONS" | "options" => entry.options = Some(op),
140 _ => {}
141 }
142 self
143 }
144
145 pub fn add_schema(
146 mut self,
147 name: impl Into<String>,
148 properties: HashMap<String, Schema>,
149 ) -> Self {
150 self.schemas.insert(
151 name.into(),
152 Schema {
153 schema_type: Some("object".into()),
154 properties: Some(properties),
155 reference: None,
156 },
157 );
158 self
159 }
160
161 pub fn build(self) -> OpenApiSpec {
162 let components = if self.schemas.is_empty() {
163 None
164 } else {
165 Some(Components {
166 schemas: Some(self.schemas),
167 })
168 };
169 OpenApiSpec {
170 openapi: "3.0.3".into(),
171 info: OpenApiInfo {
172 title: self.title,
173 version: self.version,
174 },
175 paths: self.paths,
176 components,
177 }
178 }
179}
180
181pub fn schema_ref(name: &str) -> Schema {
182 Schema {
183 schema_type: None,
184 properties: None,
185 reference: Some(format!("#/components/schemas/{name}")),
186 }
187}
188
189pub fn string_schema() -> Schema {
190 Schema {
191 schema_type: Some("string".into()),
192 properties: None,
193 reference: None,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn build_minimal_spec() {
203 let spec = OpenApiBuilder::new("My API", "1.0.0")
204 .add_route("/health", "GET", "Health check", vec!["health".into()])
205 .build();
206 assert_eq!(spec.openapi, "3.0.3");
207 }
208
209 #[test]
210 fn build_json_output() {
211 let spec = OpenApiBuilder::new("Test", "1.0").build();
212 let json = serde_json::to_string(&spec).unwrap();
213 assert!(json.contains("\"openapi\""));
214 }
215
216 #[test]
217 fn all_seven_methods_serialize() {
218 let spec = OpenApiBuilder::new("M", "1")
219 .add_route("/r", "GET", "g", vec![])
220 .add_route("/r", "POST", "p", vec![])
221 .add_route("/r", "PUT", "u", vec![])
222 .add_route("/r", "DELETE", "d", vec![])
223 .add_route("/r", "PATCH", "a", vec![])
224 .add_route("/r", "HEAD", "h", vec![])
225 .add_route("/r", "OPTIONS", "o", vec![])
226 .build();
227 let json = serde_json::to_value(&spec).unwrap();
228 let item = json.pointer("/paths/~1r").unwrap().as_object().unwrap();
229 for method in ["get", "post", "put", "delete", "patch", "head", "options"] {
230 assert!(item.contains_key(method), "missing {method}");
231 }
232 }
233
234 #[test]
235 fn unknown_method_ignored() {
236 let spec = OpenApiBuilder::new("M", "1")
237 .add_route("/x", "TRACE", "t", vec![])
238 .build();
239 let json = serde_json::to_value(&spec).unwrap();
240 let item = json.pointer("/paths/~1x").unwrap().as_object().unwrap();
241 assert!(item.is_empty(), "unknown method must be ignored");
242 }
243
244 #[test]
245 fn add_schema_emits_components() {
246 let spec = OpenApiBuilder::new("T", "1")
247 .add_schema("User", {
248 let mut p = HashMap::new();
249 p.insert("name".into(), string_schema());
250 p
251 })
252 .build();
253 let schemas = spec.components.expect("components present").schemas;
254 assert!(schemas.unwrap().contains_key("User"));
255 }
256
257 #[test]
258 fn build_without_schemas_omits_components() {
259 let spec = OpenApiBuilder::new("T", "1").build();
260 assert!(spec.components.is_none());
261 let json = serde_json::to_string(&spec).unwrap();
262 assert!(!json.contains("components"), "got: {json}");
263 }
264
265 #[test]
266 fn schema_ref_points_to_components() {
267 let s = schema_ref("User");
268 assert_eq!(s.reference.as_deref(), Some("#/components/schemas/User"));
269 assert_eq!(s.schema_type, None);
270 }
271
272 #[test]
273 fn string_schema_has_type() {
274 assert_eq!(string_schema().schema_type.as_deref(), Some("string"));
275 }
276
277 #[test]
278 fn same_method_twice_overwrites() {
279 let spec = OpenApiBuilder::new("T", "1")
280 .add_route("/r", "GET", "first", vec![])
281 .add_route("/r", "GET", "second", vec![])
282 .build();
283 let json = serde_json::to_value(&spec).unwrap();
284 let item = json.pointer("/paths/~1r").unwrap().as_object().unwrap();
285 assert_eq!(item.len(), 1, "only one method slot");
286 assert_eq!(item["get"]["summary"], "second");
287 }
288
289 #[test]
290 fn default_response_200_added() {
291 let spec = OpenApiBuilder::new("T", "1")
292 .add_route("/r", "POST", "s", vec![])
293 .build();
294 let json = serde_json::to_value(&spec).unwrap();
295 assert!(json.pointer("/paths/~1r/post/responses/200").is_some());
296 }
297
298 #[test]
299 fn tags_serialized() {
300 let spec = OpenApiBuilder::new("T", "1")
301 .add_route("/r", "GET", "s", vec!["a".into(), "b".into()])
302 .build();
303 let json = serde_json::to_value(&spec).unwrap();
304 assert_eq!(
305 json.pointer("/paths/~1r/get/tags").unwrap(),
306 &serde_json::json!(["a", "b"])
307 );
308 }
309}