Skip to main content

ecat_openapi/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2use 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}