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
36pub fn openapi() -> OpenApi {
38 OpenApiBuilder::new()
39 .paths(default_paths()) .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
49fn 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
92fn 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
101fn default_components() -> Components {
103 let mut components = OPENAPI_COMPONENTS.get_or_init(Components::new).clone();
104
105 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 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 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
222fn default_tags() -> Vec<Tag> {
224 OPENAPI_TAGS.get_or_init(Vec::new).clone()
225}
226
227fn default_servers() -> Vec<Server> {
229 OPENAPI_SERVERS
230 .get_or_init(|| vec![Server::new("/")])
231 .clone()
232}
233
234fn default_securities() -> Vec<SecurityRequirement> {
236 OPENAPI_SECURITIES.get_or_init(Vec::new).clone()
237}
238
239fn default_external_docs() -> Option<ExternalDocs> {
241 OPENAPI_EXTERNAL_DOCS.get().cloned()
242}
243
244static 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
386static OPENAPI_INFO: OnceLock<Table> = OnceLock::new();
388
389static OPENAPI_COMPONENTS: OnceLock<Components> = OnceLock::new();
391
392static OPENAPI_TAGS: OnceLock<Vec<Tag>> = OnceLock::new();
394
395static OPENAPI_SERVERS: OnceLock<Vec<Server>> = OnceLock::new();
397
398static OPENAPI_SECURITIES: OnceLock<Vec<SecurityRequirement>> = OnceLock::new();
400
401static OPENAPI_EXTERNAL_DOCS: OnceLock<ExternalDocs> = OnceLock::new();
403
404static MODEL_DEFINITIONS: OnceLock<HashMap<&str, Table>> = OnceLock::new();