Skip to main content

oxihuman_export/
openapi_schema_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! OpenAPI 3.0 spec export stub.
6
7use std::collections::BTreeMap;
8
9/// HTTP method.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
11pub enum HttpMethod {
12    Get,
13    Post,
14    Put,
15    Delete,
16    Patch,
17}
18
19impl HttpMethod {
20    /// Lowercase method name string.
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            Self::Get => "get",
24            Self::Post => "post",
25            Self::Put => "put",
26            Self::Delete => "delete",
27            Self::Patch => "patch",
28        }
29    }
30}
31
32/// An OpenAPI path operation.
33#[derive(Debug, Clone)]
34pub struct ApiOperation {
35    pub method: HttpMethod,
36    pub summary: String,
37    pub operation_id: String,
38    pub tags: Vec<String>,
39    pub response_codes: Vec<u16>,
40}
41
42/// An OpenAPI path item.
43#[derive(Debug, Clone, Default)]
44pub struct ApiPath {
45    pub operations: BTreeMap<String, ApiOperation>,
46}
47
48impl ApiPath {
49    /// Add an operation.
50    pub fn add_operation(&mut self, method: HttpMethod, op: ApiOperation) {
51        self.operations.insert(method.as_str().to_string(), op);
52    }
53
54    /// Number of operations.
55    pub fn operation_count(&self) -> usize {
56        self.operations.len()
57    }
58}
59
60/// An OpenAPI info block.
61#[derive(Debug, Clone)]
62pub struct ApiInfo {
63    pub title: String,
64    pub version: String,
65    pub description: Option<String>,
66}
67
68/// An OpenAPI 3.0 document.
69#[derive(Debug, Clone, Default)]
70pub struct OpenApiSpec {
71    pub info: Option<ApiInfo>,
72    pub paths: BTreeMap<String, ApiPath>,
73    pub servers: Vec<String>,
74}
75
76impl OpenApiSpec {
77    /// Add a path.
78    pub fn add_path(&mut self, path: impl Into<String>, item: ApiPath) {
79        self.paths.insert(path.into(), item);
80    }
81
82    /// Number of paths.
83    pub fn path_count(&self) -> usize {
84        self.paths.len()
85    }
86
87    /// Find a path item.
88    pub fn find_path(&self, path: &str) -> Option<&ApiPath> {
89        self.paths.get(path)
90    }
91}
92
93/// Render the spec as a minimal JSON string.
94pub fn render_openapi_json(spec: &OpenApiSpec) -> String {
95    let title = spec
96        .info
97        .as_ref()
98        .map(|i| i.title.as_str())
99        .unwrap_or("API");
100    let version = spec
101        .info
102        .as_ref()
103        .map(|i| i.version.as_str())
104        .unwrap_or("1.0.0");
105    let paths_json: Vec<String> = spec
106        .paths
107        .iter()
108        .map(|(path, item)| {
109            let ops: Vec<String> = item
110                .operations
111                .iter()
112                .map(|(method, op)| {
113                    format!(
114                        r#""{method}":{{"summary":"{}","operationId":"{}"}}"#,
115                        op.summary, op.operation_id
116                    )
117                })
118                .collect();
119            format!(r#""{path}":{{{}}}  "#, ops.join(","))
120        })
121        .collect();
122    format!(
123        r#"{{"openapi":"3.0.0","info":{{"title":"{title}","version":"{version}"}},"paths":{{{}}}}}"#,
124        paths_json.join(",")
125    )
126}
127
128/// Validate spec (must have info and at least one path).
129pub fn validate_spec(spec: &OpenApiSpec) -> bool {
130    spec.info.is_some() && !spec.paths.is_empty()
131}
132
133/// Count total operations across all paths.
134pub fn total_operation_count(spec: &OpenApiSpec) -> usize {
135    spec.paths.values().map(|p| p.operation_count()).sum()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn sample_spec() -> OpenApiSpec {
143        let mut spec = OpenApiSpec {
144            info: Some(ApiInfo {
145                title: "Test API".into(),
146                version: "1.0.0".into(),
147                description: None,
148            }),
149            ..Default::default()
150        };
151        let mut path = ApiPath::default();
152        path.add_operation(
153            HttpMethod::Get,
154            ApiOperation {
155                method: HttpMethod::Get,
156                summary: "List things".into(),
157                operation_id: "listThings".into(),
158                tags: vec!["things".into()],
159                response_codes: vec![200],
160            },
161        );
162        spec.add_path("/things", path);
163        spec
164    }
165
166    #[test]
167    fn path_count() {
168        assert_eq!(sample_spec().path_count(), 1);
169    }
170
171    #[test]
172    fn find_path_found() {
173        assert!(sample_spec().find_path("/things").is_some());
174    }
175
176    #[test]
177    fn operation_count() {
178        let spec = sample_spec();
179        let p = spec.find_path("/things").expect("should succeed");
180        assert_eq!(p.operation_count(), 1);
181    }
182
183    #[test]
184    fn render_contains_openapi_version() {
185        assert!(render_openapi_json(&sample_spec()).contains("3.0.0"));
186    }
187
188    #[test]
189    fn render_contains_title() {
190        assert!(render_openapi_json(&sample_spec()).contains("Test API"));
191    }
192
193    #[test]
194    fn validate_ok() {
195        assert!(validate_spec(&sample_spec()));
196    }
197
198    #[test]
199    fn validate_no_info() {
200        let spec = OpenApiSpec::default();
201        assert!(!validate_spec(&spec));
202    }
203
204    #[test]
205    fn total_operations() {
206        assert_eq!(total_operation_count(&sample_spec()), 1);
207    }
208
209    #[test]
210    fn method_as_str() {
211        assert_eq!(HttpMethod::Post.as_str(), "post");
212    }
213}