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
41pub fn openapi() -> OpenApi {
43 OpenApiBuilder::new()
44 .paths(default_paths()) .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
54fn 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
97fn 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
106fn default_components() -> Components {
108 let mut components = OPENAPI_COMPONENTS.get_or_init(Components::new).to_owned();
109
110 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 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 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
227fn default_tags() -> Vec<Tag> {
229 OPENAPI_TAGS.get_or_init(Vec::new).to_owned()
230}
231
232fn default_servers() -> Vec<Server> {
234 OPENAPI_SERVERS
235 .get_or_init(|| vec![Server::new("/")])
236 .to_owned()
237}
238
239fn default_securities() -> Vec<SecurityRequirement> {
241 OPENAPI_SECURITIES.get_or_init(Vec::new).to_owned()
242}
243
244fn default_external_docs() -> Option<ExternalDocs> {
246 OPENAPI_EXTERNAL_DOCS.get().cloned()
247}
248
249fn 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
284fn 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
336fn 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
407static 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
434static OPENAPI_INFO: OnceLock<Table> = OnceLock::new();
436
437static OPENAPI_COMPONENTS: OnceLock<Components> = OnceLock::new();
439
440static OPENAPI_TAGS: OnceLock<Vec<Tag>> = OnceLock::new();
442
443static OPENAPI_SERVERS: OnceLock<Vec<Server>> = OnceLock::new();
445
446static OPENAPI_SECURITIES: OnceLock<Vec<SecurityRequirement>> = OnceLock::new();
448
449static OPENAPI_EXTERNAL_DOCS: OnceLock<ExternalDocs> = OnceLock::new();
451
452static MODEL_DEFINITIONS: OnceLock<HashMap<&str, Table>> = OnceLock::new();