Skip to main content

alux_http_openapi/
route.rs

1//! Composes the endpoints a program states into the document that describes them.
2
3use crate::{OpenApiAnswer, OpenApiArgument, OpenApiSource, OpenApiStated};
4use alux_http::{HttpMethod, HttpSelectorAlg, RouteAlg, RoutePath, SelectorAlg, describe_path, write_header_name};
5use serde_json::{Map, Value, json};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9enum OpenApiSelectorPart {
10    Method(HttpMethod),
11    Path(RoutePath),
12    Prefix(RoutePath),
13}
14
15/// Carries route-selection meaning, which a document reads as a path and a method.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct OpenApiSelector {
18    parts: Vec<OpenApiSelectorPart>,
19}
20
21impl OpenApiSelector {
22    /// Returns the composed path this selector matches.
23    ///
24    /// A document templates a path exactly as a described surface states one, so nothing is spelled
25    /// a second way here.
26    pub fn path(&self) -> String {
27        describe_path(self.parts.iter().filter_map(|part| match part {
28            OpenApiSelectorPart::Path(path) | OpenApiSelectorPart::Prefix(path) => Some(path),
29            OpenApiSelectorPart::Method(_) => None,
30        }))
31    }
32
33    /// Returns the selected method and path, using `*` when no method is selected.
34    pub fn label(&self) -> String {
35        let method = self.method().map_or("*", HttpMethod::label);
36        format!("{method} {}", self.path())
37    }
38
39    fn method(&self) -> Option<HttpMethod> {
40        self.parts.iter().rev().find_map(|part| match part {
41            OpenApiSelectorPart::Method(method) => Some(*method),
42            OpenApiSelectorPart::Path(_) | OpenApiSelectorPart::Prefix(_) => None,
43        })
44    }
45}
46
47/// One endpoint, as a document describes it.
48#[derive(Debug, Clone)]
49pub struct OpenApiEndpoint {
50    pub(crate) operation: &'static str,
51    pub(crate) doc: &'static str,
52    pub(crate) arguments: Vec<(&'static str, OpenApiArgument)>,
53    pub(crate) answers: Vec<OpenApiAnswer>,
54}
55
56impl OpenApiEndpoint {
57    /// Returns the name the operation was declared under, which the document states as its id.
58    pub fn operation(&self) -> &'static str {
59        self.operation
60    }
61
62    /// Returns what the operation was documented as, split the way a doc comment already reads.
63    ///
64    /// The first line is the summary a reader sees beside the operation, and whatever follows is the
65    /// description they see when they open it.
66    fn documented(&self) -> (Option<&str>, Option<&str>) {
67        let doc = self.doc.trim();
68        let (summary, description) = doc.split_once('\n').unwrap_or((doc, ""));
69
70        (Some(summary.trim()).filter(|text| !text.is_empty()), Some(description.trim()).filter(|text| !text.is_empty()))
71    }
72
73    fn parameters(&self) -> Vec<Value> {
74        self.arguments
75            .iter()
76            .filter_map(|(name, argument)| {
77                let location = match argument.source {
78                    OpenApiSource::Path => "path",
79                    OpenApiSource::Query => "query",
80                    OpenApiSource::Header => "header",
81                    OpenApiSource::Cookie => "cookie",
82                    OpenApiSource::Body(_) | OpenApiSource::Unstated => return None,
83                };
84
85                Some(match &argument.stated {
86                    // What a path binds is the argument itself, and it binds every segment it names.
87                    OpenApiStated::Whole(schema) => {
88                        vec![json!({ "name": name, "in": location, "required": location == "path", "schema": schema })]
89                    }
90                    // A collection of names and values states one parameter for each name it carries.
91                    OpenApiStated::Named(named) => named
92                        .iter()
93                        .map(|named| {
94                            json!({
95                                "name": stated_as(argument.source, &named.name),
96                                "in": location,
97                                "required": named.required,
98                                "schema": named.schema,
99                            })
100                        })
101                        .collect(),
102                })
103            })
104            .flatten()
105            .collect()
106    }
107
108    fn request_body(&self) -> Option<Value> {
109        self.arguments.iter().find_map(|(_, argument)| match (argument.source, &argument.stated) {
110            (OpenApiSource::Body(content_type), OpenApiStated::Whole(schema)) => Some(json!({
111                "required": true,
112                "content": { content_type: { "schema": schema } },
113            })),
114            _ => None,
115        })
116    }
117
118    fn responses(&self) -> Value {
119        // A response object states a description of its own. Only a successful answer is what the
120        // operation was documented as; what a failure answers with the program never says in words.
121        let mut responses = Map::new();
122        for answer in &self.answers {
123            let stated = if answer.status.is_success() { self.documented().0.unwrap_or_default() } else { "" };
124            let mut described = match (answer.content_type, &answer.schema) {
125                (Some(content_type), Some(schema)) => json!({
126                    "description": stated,
127                    "content": { content_type: { "schema": schema } },
128                }),
129                _ => json!({ "description": stated }),
130            };
131            // A header an answer carries is one a caller reads, so a document states each of them.
132            if !answer.headers.is_empty()
133                && let Some(described) = described.as_object_mut()
134            {
135                let carried =
136                    answer.headers.iter().map(|name| ((*name).to_owned(), json!({ "schema": { "type": "string" } })));
137                described.insert("headers".into(), Value::Object(carried.collect()));
138            }
139            responses.insert(answer.status.code().to_string(), described);
140        }
141
142        Value::Object(responses)
143    }
144
145    fn operation_object(&self) -> Value {
146        let mut described = Map::new();
147        described.insert("operationId".into(), self.operation.into());
148        let (summary, description) = self.documented();
149        if let Some(summary) = summary {
150            described.insert("summary".into(), summary.into());
151        }
152        if let Some(description) = description {
153            described.insert("description".into(), description.into());
154        }
155        let parameters = self.parameters();
156        if !parameters.is_empty() {
157            described.insert("parameters".into(), Value::Array(parameters));
158        }
159        if let Some(body) = self.request_body() {
160            described.insert("requestBody".into(), body);
161        }
162        described.insert("responses".into(), self.responses());
163
164        Value::Object(described)
165    }
166}
167
168/// Writes a name the way the collection it was read from states one.
169///
170/// A header name is words the wire spells with `-`; everywhere else a name reaches a caller as the
171/// member stated it.
172fn stated_as(source: OpenApiSource, name: &str) -> String {
173    match source {
174        OpenApiSource::Header => write_header_name(name),
175        _ => name.to_owned(),
176    }
177}
178
179#[derive(Debug, Clone)]
180struct OpenApiRouteEntry {
181    selector: OpenApiSelector,
182    endpoint: OpenApiEndpoint,
183}
184
185/// Carries every endpoint a program states, in declaration order.
186#[derive(Debug, Clone, Default)]
187pub struct OpenApiRoute {
188    entries: Vec<OpenApiRouteEntry>,
189}
190
191impl OpenApiRoute {
192    /// Returns each composed selector as `METHOD /path`, in declaration order.
193    pub fn labels(&self) -> Vec<String> {
194        self.entries.iter().map(|entry| entry.selector.label()).collect()
195    }
196
197    /// Returns each composed route path, in declaration order.
198    pub fn paths(&self) -> Vec<String> {
199        self.entries.iter().map(|entry| entry.selector.path()).collect()
200    }
201
202    /// Returns the name each endpoint's operation was declared under, in declaration order.
203    pub fn operations(&self) -> Vec<&'static str> {
204        self.entries.iter().map(|entry| entry.endpoint.operation).collect()
205    }
206
207    /// Returns the paths object this surface states, keyed by path and then by method.
208    ///
209    /// An endpoint composed without a method states nothing a document can key, so it is left out.
210    pub fn paths_object(&self) -> Value {
211        let mut paths = BTreeMap::<String, Map<String, Value>>::new();
212        for entry in &self.entries {
213            let Some(method) = entry.selector.method() else { continue };
214            let methods = paths.entry(entry.selector.path()).or_default();
215            methods.insert(method.label().to_lowercase(), entry.endpoint.operation_object());
216        }
217
218        Value::Object(paths.into_iter().map(|(path, methods)| (path, Value::Object(methods))).collect())
219    }
220}
221
222/// Composes route selection as the path and method a document keys an operation by.
223#[derive(Debug, Default)]
224pub struct OpenApiRouteImpl;
225
226impl SelectorAlg for OpenApiRouteImpl {
227    type Selector = OpenApiSelector;
228
229    fn identity(&self) -> OpenApiSelector {
230        OpenApiSelector::default()
231    }
232
233    fn compose(&self, mut first: OpenApiSelector, second: OpenApiSelector) -> OpenApiSelector {
234        first.parts.extend(second.parts);
235        first
236    }
237}
238
239impl RouteAlg for OpenApiRouteImpl {
240    type Route = OpenApiRoute;
241    type Selector = OpenApiSelector;
242    type Endpoint = OpenApiEndpoint;
243
244    fn initial(&self) -> OpenApiRoute {
245        OpenApiRoute::default()
246    }
247
248    fn coproduct(&self, mut left: OpenApiRoute, right: OpenApiRoute) -> OpenApiRoute {
249        left.entries.extend(right.entries);
250        left
251    }
252
253    fn precompose(&self, selector: OpenApiSelector, mut route: OpenApiRoute) -> OpenApiRoute {
254        for entry in &mut route.entries {
255            entry.selector = self.compose(selector.clone(), core::mem::take(&mut entry.selector));
256        }
257        route
258    }
259
260    fn lift(&self, endpoint: OpenApiEndpoint) -> OpenApiRoute {
261        OpenApiRoute { entries: vec![OpenApiRouteEntry { selector: self.identity(), endpoint }] }
262    }
263}
264
265impl HttpSelectorAlg for OpenApiRouteImpl {
266    type Selector = OpenApiSelector;
267
268    fn http_method(&self, method: HttpMethod) -> OpenApiSelector {
269        OpenApiSelector { parts: vec![OpenApiSelectorPart::Method(method)] }
270    }
271
272    fn http_path(&self, path: &RoutePath) -> OpenApiSelector {
273        OpenApiSelector { parts: vec![OpenApiSelectorPart::Path(path.clone())] }
274    }
275
276    fn http_prefix(&self, prefix: &RoutePath) -> OpenApiSelector {
277        OpenApiSelector { parts: vec![OpenApiSelectorPart::Prefix(prefix.clone())] }
278    }
279}