1use lino_objects_codec::LinoValue;
9
10use crate::media_type::supported_media_types;
11use crate::router::RouteDescription;
12use crate::value::{array, boolean, object, string};
13
14pub const LINO_API_DESCRIPTION_VERSION: &str = "1.0";
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct ServiceInfo {
20 pub title: String,
22 pub version: String,
24 pub description: Option<String>,
26}
27
28impl ServiceInfo {
29 pub fn new<T: Into<String>, V: Into<String>>(title: T, version: V) -> Self {
31 Self {
32 title: title.into(),
33 version: version.into(),
34 description: None,
35 }
36 }
37
38 pub fn describing<D: Into<String>>(mut self, description: D) -> Self {
49 self.description = Some(description.into());
50 self
51 }
52}
53
54fn lino_schema() -> LinoValue {
56 object([
57 (
58 "description",
59 string(
60 "Links Notation document, see https://github.com/link-foundation/links-notation",
61 ),
62 ),
63 ("type", string("string")),
64 ])
65}
66
67pub fn info_object(info: &ServiceInfo) -> LinoValue {
72 let mut members = vec![
73 ("title".to_string(), string(info.title.clone())),
74 ("version".to_string(), string(info.version.clone())),
75 ];
76 if let Some(description) = info.description.as_deref().filter(|text| !text.is_empty()) {
77 members.push(("description".to_string(), string(description)));
78 }
79 LinoValue::Object(members)
80}
81
82fn route_object(route: &RouteDescription) -> LinoValue {
84 let mut members = vec![
85 ("path".to_string(), string(route.path.clone())),
86 (
87 "methods".to_string(),
88 array(route.methods.iter().map(|method| string(method.clone()))),
89 ),
90 ];
91 if let Some(summary) = route.summary.as_deref().filter(|text| !text.is_empty()) {
92 members.push(("summary".to_string(), string(summary)));
93 }
94 LinoValue::Object(members)
95}
96
97pub fn service_description(
109 info: &ServiceInfo,
110 routes: &[RouteDescription],
111 media_types: Option<&[String]>,
112) -> LinoValue {
113 let types = media_types
114 .map(<[String]>::to_vec)
115 .unwrap_or_else(supported_media_types);
116 object([
117 ("lino_api", string(LINO_API_DESCRIPTION_VERSION)),
118 ("info", info_object(info)),
119 ("media_types", array(types.into_iter().map(string))),
120 ("routes", array(routes.iter().map(route_object))),
121 ])
122}
123
124pub fn to_openapi_path(path: &str) -> String {
134 path.split('/')
135 .map(|segment| match segment.strip_prefix(':') {
136 Some(name) if is_parameter_name(name) => format!("{{{name}}}"),
137 _ => segment.to_string(),
138 })
139 .collect::<Vec<_>>()
140 .join("/")
141}
142
143pub fn path_parameters(path: &str) -> Vec<String> {
145 path.split('/')
146 .filter_map(|segment| segment.strip_prefix(':'))
147 .filter(|name| is_parameter_name(name))
148 .map(str::to_string)
149 .collect()
150}
151
152fn is_parameter_name(name: &str) -> bool {
154 !name.is_empty()
155 && name
156 .chars()
157 .all(|character| character.is_ascii_alphanumeric() || character == '_')
158}
159
160pub fn openapi_document(
165 info: &ServiceInfo,
166 routes: &[RouteDescription],
167 media_types: Option<&[String]>,
168) -> LinoValue {
169 let types = media_types
170 .map(<[String]>::to_vec)
171 .unwrap_or_else(supported_media_types);
172 let content = LinoValue::Object(
173 types
174 .iter()
175 .map(|media_type| (media_type.clone(), object([("schema", lino_schema())])))
176 .collect(),
177 );
178
179 let mut paths: Vec<(String, LinoValue)> = Vec::new();
180 for route in routes {
181 let template = to_openapi_path(&route.path);
182 let parameters: Vec<LinoValue> = path_parameters(&route.path)
183 .into_iter()
184 .map(|name| {
185 object([
186 ("name", string(name)),
187 ("in", string("path")),
188 ("required", boolean(true)),
189 ("schema", object([("type", string("string"))])),
190 ])
191 })
192 .collect();
193
194 let mut operations: Vec<(String, LinoValue)> = Vec::new();
195 for method in &route.methods {
196 let summary = route
197 .summary
198 .clone()
199 .filter(|text| !text.is_empty())
200 .unwrap_or_else(|| format!("{method} {}", route.path));
201 let mut operation = vec![
202 ("summary".to_string(), string(summary)),
203 (
204 "responses".to_string(),
205 object([
206 (
207 "200",
208 object([
209 ("description", string("Success")),
210 ("content", content.clone()),
211 ]),
212 ),
213 (
214 "default",
215 object([
216 (
217 "description",
218 string("RFC 9457 problem details in Links Notation"),
219 ),
220 ("content", content.clone()),
221 ]),
222 ),
223 ]),
224 ),
225 ];
226 if !parameters.is_empty() {
227 operation.push((
228 "parameters".to_string(),
229 LinoValue::Array(parameters.clone()),
230 ));
231 }
232 if matches!(method.as_str(), "POST" | "PUT" | "PATCH") {
233 operation.push((
234 "requestBody".to_string(),
235 object([("required", boolean(true)), ("content", content.clone())]),
236 ));
237 }
238 operations.push((method.to_lowercase(), LinoValue::Object(operation)));
239 }
240 paths.push((template, LinoValue::Object(operations)));
241 }
242
243 object([
244 ("openapi", string("3.1.0")),
245 ("info", info_object(info)),
246 ("paths", LinoValue::Object(paths)),
247 ])
248}