Skip to main content

lino_rest_api/
description.rs

1//! Machine-readable service description (specification section 9).
2//!
3//! The same route registry is rendered twice: once as the native Links Notation
4//! description served at `/.well-known/lino-api`, and once as an OpenAPI 3.1
5//! document served at `/.well-known/openapi.json`, so that existing tooling keeps
6//! working against a Links Notation API.
7
8use lino_objects_codec::LinoValue;
9
10use crate::media_type::supported_media_types;
11use crate::router::RouteDescription;
12use crate::value::{array, boolean, object, string};
13
14/// Version of the description document this package emits.
15pub const LINO_API_DESCRIPTION_VERSION: &str = "1.0";
16
17/// Title, version and optional prose description of a service.
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct ServiceInfo {
20    /// Human readable name of the service.
21    pub title: String,
22    /// Version of the service.
23    pub version: String,
24    /// Prose description, carried by both documents when present.
25    pub description: Option<String>,
26}
27
28impl ServiceInfo {
29    /// A service with a title and a version.
30    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    /// The same service carrying a prose description.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use lino_rest_api::description::ServiceInfo;
44    ///
45    /// let info = ServiceInfo::new("Tasks API", "1.0.0").describing("A task list");
46    /// assert_eq!(info.description.as_deref(), Some("A task list"));
47    /// ```
48    pub fn describing<D: Into<String>>(mut self, description: D) -> Self {
49        self.description = Some(description.into());
50        self
51    }
52}
53
54/// The schema every Links Notation body is declared with.
55fn 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
67/// Render the `info` member shared by both description documents.
68///
69/// The prose description appears only when there is one, so that a service
70/// without one describes itself exactly as the JavaScript and Python packages do.
71pub 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
82/// Render one route as it appears in the `routes` member.
83fn 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
97/// Build the native service description.
98///
99/// # Examples
100///
101/// ```
102/// use lino_rest_api::description::{service_description, ServiceInfo};
103/// use lino_rest_api::value::get;
104///
105/// let document = service_description(&ServiceInfo::new("Tasks", "1.0.0"), &[], None);
106/// assert_eq!(get(&document, "lino_api").and_then(lino_rest_api::value::as_str), Some("1.0"));
107/// ```
108pub 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
124/// Convert a `:parameter` path to the OpenAPI template syntax.
125///
126/// # Examples
127///
128/// ```
129/// use lino_rest_api::description::to_openapi_path;
130///
131/// assert_eq!(to_openapi_path("/items/:id/tags/:tag"), "/items/{id}/tags/{tag}");
132/// ```
133pub 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
143/// Extract the path parameters of a path pattern.
144pub 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
152/// Whether a name may be used as a path parameter.
153fn is_parameter_name(name: &str) -> bool {
154    !name.is_empty()
155        && name
156            .chars()
157            .all(|character| character.is_ascii_alphanumeric() || character == '_')
158}
159
160/// Build an OpenAPI 3.1 document describing the service.
161///
162/// Every request and response body is declared for all negotiable media types,
163/// so that a generated client knows it may ask for `text/lino`.
164pub 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}