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