zino_openapi/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc(html_favicon_url = "https://zino.cc/assets/zino-logo.png")]
3#![doc(html_logo_url = "https://zino.cc/assets/zino-logo.svg")]
4#![forbid(unsafe_code)]
5
6use ahash::{HashMap, HashMapExt};
7use convert_case::{Case, Casing};
8use serde_json::json;
9use std::{collections::BTreeMap, fs, io::ErrorKind, sync::OnceLock};
10use toml::Table;
11use utoipa::openapi::{
12    content::ContentBuilder,
13    external_docs::ExternalDocs,
14    info::{Contact, Info, License},
15    path::{PathItem, Paths, PathsBuilder},
16    response::ResponseBuilder,
17    schema::{
18        Components, ComponentsBuilder, KnownFormat, Object, ObjectBuilder, Ref, SchemaFormat, Type,
19    },
20    security::SecurityRequirement,
21    server::Server,
22    tag::Tag,
23    OpenApi, OpenApiBuilder,
24};
25use zino_core::{
26    application::{Agent, Application},
27    extension::TomlTableExt,
28    LazyLock, Uuid,
29};
30
31mod model;
32mod parser;
33
34pub use model::translate_model_entry;
35
36/// Gets the [OpenAPI](https://spec.openapis.org/oas/latest.html) document.
37pub fn openapi() -> OpenApi {
38    OpenApiBuilder::new()
39        .paths(default_paths()) // should come first to load OpenAPI files
40        .components(Some(default_components()))
41        .tags(Some(default_tags()))
42        .servers(Some(default_servers()))
43        .security(Some(default_securities()))
44        .external_docs(default_external_docs())
45        .info(openapi_info(Agent::name(), Agent::version()))
46        .build()
47}
48
49/// Constructs the OpenAPI `Info` object.
50fn openapi_info(title: &str, version: &str) -> Info {
51    let mut info = Info::new(title, version);
52    if let Some(config) = OPENAPI_INFO.get() {
53        if let Some(title) = config.get_str("title") {
54            title.clone_into(&mut info.title);
55        }
56        if let Some(description) = config.get_str("description") {
57            info.description = Some(description.to_owned());
58        }
59        if let Some(terms_of_service) = config.get_str("terms_of_service") {
60            info.terms_of_service = Some(terms_of_service.to_owned());
61        }
62        if let Some(contact_config) = config.get_table("contact") {
63            let mut contact = Contact::new();
64            if let Some(contact_name) = contact_config.get_str("name") {
65                contact.name = Some(contact_name.to_owned());
66            }
67            if let Some(contact_url) = contact_config.get_str("url") {
68                contact.url = Some(contact_url.to_owned());
69            }
70            if let Some(contact_email) = contact_config.get_str("email") {
71                contact.email = Some(contact_email.to_owned());
72            }
73            info.contact = Some(contact);
74        }
75        if let Some(license) = config.get_str("license") {
76            info.license = Some(License::new(license));
77        } else if let Some(license_config) = config.get_table("license") {
78            let license_name = license_config.get_str("name").unwrap_or_default();
79            let mut license = License::new(license_name);
80            if let Some(license_url) = license_config.get_str("url") {
81                license.url = Some(license_url.to_owned());
82            }
83            info.license = Some(license);
84        }
85        if let Some(version) = config.get_str("version") {
86            version.clone_into(&mut info.version);
87        }
88    }
89    info
90}
91
92/// Returns the default OpenAPI paths.
93fn default_paths() -> Paths {
94    let mut paths_builder = PathsBuilder::new();
95    for (path, item) in OPENAPI_PATHS.iter() {
96        paths_builder = paths_builder.path(path, item.clone());
97    }
98    paths_builder.build()
99}
100
101/// Returns the default OpenAPI components.
102fn default_components() -> Components {
103    let mut components = OPENAPI_COMPONENTS.get_or_init(Components::new).clone();
104
105    // Request ID
106    let request_id_example = Uuid::now_v7();
107    let request_id_schema = ObjectBuilder::new()
108        .schema_type(Type::String)
109        .format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid)))
110        .build();
111
112    // Default response
113    let status_schema = ObjectBuilder::new()
114        .schema_type(Type::Integer)
115        .examples(Some(200))
116        .build();
117    let success_schema = ObjectBuilder::new()
118        .schema_type(Type::Boolean)
119        .examples(Some(true))
120        .build();
121    let message_schema = ObjectBuilder::new()
122        .schema_type(Type::String)
123        .examples(Some("OK"))
124        .build();
125    let default_response_schema = ObjectBuilder::new()
126        .schema_type(Type::Object)
127        .property("status", status_schema)
128        .property("success", success_schema)
129        .property("message", message_schema)
130        .property("request_id", request_id_schema.clone())
131        .property("data", Object::new())
132        .required("status")
133        .required("success")
134        .required("message")
135        .required("request_id")
136        .build();
137    let default_response_example = json!({
138        "status": 200,
139        "success": true,
140        "message": "OK",
141        "request_id": request_id_example,
142        "data": {},
143    });
144    let default_response_content = ContentBuilder::new()
145        .schema(Some(Ref::from_schema_name("defaultResponse")))
146        .example(Some(default_response_example))
147        .build();
148    let default_response = ResponseBuilder::new()
149        .content("application/json", default_response_content)
150        .build();
151    components
152        .schemas
153        .insert("defaultResponse".to_owned(), default_response_schema.into());
154    components
155        .responses
156        .insert("default".to_owned(), default_response.into());
157
158    // 4XX error response
159    let model_id_example = Uuid::now_v7();
160    let detail_example = format!("404 Not Found: cannot find the model `{model_id_example}`");
161    let instance_example = format!("/model/{model_id_example}/view");
162    let status_schema = ObjectBuilder::new()
163        .schema_type(Type::Integer)
164        .examples(Some(404))
165        .build();
166    let success_schema = ObjectBuilder::new()
167        .schema_type(Type::Boolean)
168        .examples(Some(false))
169        .build();
170    let title_schema = ObjectBuilder::new()
171        .schema_type(Type::String)
172        .examples(Some("NotFound"))
173        .build();
174    let detail_schema = ObjectBuilder::new()
175        .schema_type(Type::String)
176        .examples(Some(detail_example.as_str()))
177        .build();
178    let instance_schema = ObjectBuilder::new()
179        .schema_type(Type::String)
180        .examples(Some(instance_example.as_str()))
181        .build();
182    let error_response_schema = ObjectBuilder::new()
183        .schema_type(Type::Object)
184        .property("status", status_schema)
185        .property("success", success_schema)
186        .property("title", title_schema)
187        .property("detail", detail_schema)
188        .property("instance", instance_schema)
189        .property("request_id", request_id_schema)
190        .required("status")
191        .required("success")
192        .required("title")
193        .required("detail")
194        .required("instance")
195        .required("request_id")
196        .build();
197    let error_response_example = json!({
198        "status": 404,
199        "success": false,
200        "title": "NotFound",
201        "detail": detail_example,
202        "instance": instance_example,
203        "request_id": request_id_example,
204    });
205    let error_response_content = ContentBuilder::new()
206        .schema(Some(Ref::from_schema_name("errorResponse")))
207        .example(Some(error_response_example))
208        .build();
209    let error_response = ResponseBuilder::new()
210        .content("application/json", error_response_content)
211        .build();
212    components
213        .schemas
214        .insert("errorResponse".to_owned(), error_response_schema.into());
215    components
216        .responses
217        .insert("4XX".to_owned(), error_response.into());
218
219    components
220}
221
222/// Returns the default OpenAPI tags.
223fn default_tags() -> Vec<Tag> {
224    OPENAPI_TAGS.get_or_init(Vec::new).clone()
225}
226
227/// Returns the default OpenAPI servers.
228fn default_servers() -> Vec<Server> {
229    OPENAPI_SERVERS
230        .get_or_init(|| vec![Server::new("/")])
231        .clone()
232}
233
234/// Returns the default OpenAPI security requirements.
235fn default_securities() -> Vec<SecurityRequirement> {
236    OPENAPI_SECURITIES.get_or_init(Vec::new).clone()
237}
238
239/// Returns the default OpenAPI external docs.
240fn default_external_docs() -> Option<ExternalDocs> {
241    OPENAPI_EXTERNAL_DOCS.get().cloned()
242}
243
244/// OpenAPI paths.
245static OPENAPI_PATHS: LazyLock<BTreeMap<String, PathItem>> = LazyLock::new(|| {
246    let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
247    let openapi_dir = Agent::config_dir().join("openapi");
248    match fs::read_dir(openapi_dir) {
249        Ok(entries) => {
250            let mut openapi_tags = Vec::new();
251            let mut model_definitions = HashMap::new();
252            let mut components_builder = ComponentsBuilder::new();
253            let files = entries
254                .filter_map(|entry| entry.ok())
255                .filter(|entry| entry.file_type().is_ok_and(|f| f.is_file()));
256            for file in files {
257                let openapi_file = file.path();
258                let openapi_config = fs::read_to_string(&openapi_file)
259                    .unwrap_or_else(|err| {
260                        let openapi_file = openapi_file.display();
261                        panic!("fail to read the OpenAPI file `{openapi_file}`: {err}");
262                    })
263                    .parse::<Table>()
264                    .expect("fail to parse the OpenAPI file as a TOML table");
265                if file.file_name() == "OPENAPI.toml" {
266                    if let Some(info_config) = openapi_config.get_table("info") {
267                        if OPENAPI_INFO.set(info_config.clone()).is_err() {
268                            panic!("fail to set OpenAPI info");
269                        }
270                    }
271                    if let Some(servers) = openapi_config.get_array("servers") {
272                        let servers = servers
273                            .iter()
274                            .filter_map(|v| v.as_table())
275                            .map(parser::parse_server)
276                            .collect::<Vec<_>>();
277                        if OPENAPI_SERVERS.set(servers).is_err() {
278                            panic!("fail to set OpenAPI servers");
279                        }
280                    }
281                    if let Some(security_schemes) = openapi_config.get_table("security_schemes") {
282                        for (name, scheme) in security_schemes {
283                            if let Some(scheme_config) = scheme.as_table() {
284                                let scheme = parser::parse_security_scheme(scheme_config);
285                                components_builder =
286                                    components_builder.security_scheme(name, scheme);
287                            }
288                        }
289                    }
290                    if let Some(securities) = openapi_config.get_array("securities") {
291                        let security_requirements = securities
292                            .iter()
293                            .filter_map(|v| v.as_table())
294                            .map(parser::parse_security_requirement)
295                            .collect::<Vec<_>>();
296                        if OPENAPI_SECURITIES.set(security_requirements).is_err() {
297                            panic!("fail to set OpenAPI security requirements");
298                        }
299                    }
300                    if let Some(external_docs) = openapi_config.get_table("external_docs") {
301                        let external_docs = parser::parse_external_docs(external_docs);
302                        if OPENAPI_EXTERNAL_DOCS.set(external_docs).is_err() {
303                            panic!("fail to set OpenAPI external docs");
304                        }
305                    }
306                    continue;
307                }
308
309                let name = openapi_config
310                    .get_str("name")
311                    .map(|s| s.to_owned())
312                    .unwrap_or_else(|| {
313                        file.file_name()
314                            .to_string_lossy()
315                            .trim_end_matches(".toml")
316                            .to_owned()
317                    });
318                let ignore_securities = openapi_config
319                    .get_array("securities")
320                    .is_some_and(|v| v.is_empty());
321                if let Some(endpoints) = openapi_config.get_array("endpoints") {
322                    for endpoint in endpoints.iter().filter_map(|v| v.as_table()) {
323                        let path = endpoint.get_str("path").unwrap_or("/");
324                        let method = endpoint
325                            .get_str("method")
326                            .unwrap_or_default()
327                            .to_ascii_uppercase();
328                        let http_method = parser::parse_http_method(&method);
329                        let operation =
330                            parser::parse_operation(&name, path, endpoint, ignore_securities);
331                        let path_item = PathItem::new(http_method, operation);
332                        if let Some(item) = paths.get_mut(path) {
333                            item.merge_operations(path_item);
334                        } else {
335                            paths.insert(path.to_owned(), path_item);
336                        }
337                    }
338                }
339                if let Some(schemas) = openapi_config.get_table("schemas") {
340                    for (key, value) in schemas.iter() {
341                        if let Some(config) = value.as_table() {
342                            let name = key.to_case(Case::Camel);
343                            let schema = parser::parse_schema(config);
344                            components_builder = components_builder.schema(name, schema);
345                        }
346                    }
347                }
348                if let Some(responses) = openapi_config.get_table("responses") {
349                    for (key, value) in responses.iter() {
350                        if let Some(config) = value.as_table() {
351                            let name = key.to_case(Case::Camel);
352                            let response = parser::parse_response(config);
353                            components_builder = components_builder.response(name, response);
354                        }
355                    }
356                }
357                if let Some(models) = openapi_config.get_table("models") {
358                    for (model_name, model_fields) in models {
359                        if let Some(fields) = model_fields.as_table() {
360                            let model_name = model_name.to_owned().leak() as &'static str;
361                            model_definitions.insert(model_name, fields.to_owned());
362                        }
363                    }
364                }
365                openapi_tags.push(parser::parse_tag(&name, &openapi_config))
366            }
367            if OPENAPI_COMPONENTS.set(components_builder.build()).is_err() {
368                panic!("fail to set OpenAPI components");
369            }
370            if OPENAPI_TAGS.set(openapi_tags).is_err() {
371                panic!("fail to set OpenAPI tags");
372            }
373            if MODEL_DEFINITIONS.set(model_definitions).is_err() {
374                panic!("fail to set model definitions");
375            }
376        }
377        Err(err) => {
378            if err.kind() != ErrorKind::NotFound {
379                tracing::error!("{err}");
380            }
381        }
382    }
383    paths
384});
385
386/// OpenAPI info.
387static OPENAPI_INFO: OnceLock<Table> = OnceLock::new();
388
389/// OpenAPI components.
390static OPENAPI_COMPONENTS: OnceLock<Components> = OnceLock::new();
391
392/// OpenAPI tags.
393static OPENAPI_TAGS: OnceLock<Vec<Tag>> = OnceLock::new();
394
395/// OpenAPI servers.
396static OPENAPI_SERVERS: OnceLock<Vec<Server>> = OnceLock::new();
397
398/// OpenAPI securities.
399static OPENAPI_SECURITIES: OnceLock<Vec<SecurityRequirement>> = OnceLock::new();
400
401/// OpenAPI external docs.
402static OPENAPI_EXTERNAL_DOCS: OnceLock<ExternalDocs> = OnceLock::new();
403
404/// Model definitions.
405static MODEL_DEFINITIONS: OnceLock<HashMap<&str, Table>> = OnceLock::new();