Skip to main content

cufflink_types/openapi/
check.rs

1//! What a documented manifest must satisfy for its spec to be generated:
2//! checked when the SDK emits the manifest, at deploy, and before
3//! generation.
4
5use super::custom::{self, Components};
6use super::schema::schema_names;
7use super::{crud, default_style, OpenApiError, PathStyle};
8use crate::{CustomRouteDefinition, RoutePosture, ServiceManifest};
9use serde_json::{Map, Value};
10use std::collections::BTreeSet;
11
12/// The security schemes `route` requires: its doc's, else its posture's.
13/// The scheme of partner-key routes: the `X-Api-Key` header.
14pub(super) const API_KEY_SCHEME: &str = "api_key";
15
16pub(super) fn route_security<'a>(
17    route: &'a CustomRouteDefinition,
18    scheme: &'a str,
19) -> Vec<&'a str> {
20    match route.doc.as_ref().and_then(|doc| doc.security.as_ref()) {
21        Some(names) => names.iter().map(String::as_str).collect(),
22        None => match &route.posture {
23            None | Some(RoutePosture::Public { .. }) | Some(RoutePosture::Internal { .. }) => {
24                Vec::new()
25            }
26            Some(RoutePosture::ApiKey { .. }) => vec![API_KEY_SCHEME],
27            Some(RoutePosture::Authenticated)
28            | Some(RoutePosture::Permission { .. })
29            | Some(RoutePosture::Owner { .. })
30            | Some(RoutePosture::Scoped { .. }) => vec![scheme],
31        },
32    }
33}
34
35/// Check a documented manifest: operation ids unique across its published
36/// custom and CRUD operations, no two published routes documenting the same
37/// method and path, `path = T` naming exactly the route's path parameters,
38/// query and path parameters that are scalars or arrays, security schemes
39/// the document defines, and no DTO sharing a table schema's name. An
40/// undocumented manifest passes: its spec is today's.
41pub fn validate(manifest: &ServiceManifest) -> Result<(), OpenApiError> {
42    if matches!(default_style(manifest, ""), PathStyle::Absolute { .. }) {
43        return Ok(());
44    }
45    unique_operation_ids(manifest)?;
46    unique_operations(manifest)?;
47    let components = Map::from_iter(manifest.components.clone());
48    let lookup = Components(&components);
49    let scheme = manifest
50        .openapi
51        .as_ref()
52        .map_or(crate::OpenApiOptions::DEFAULT_SECURITY_SCHEME, |o| {
53            o.security_scheme()
54        });
55    manifest
56        .custom_routes
57        .iter()
58        .try_for_each(|route| check_route(manifest, route, &lookup, scheme))?;
59    manifest
60        .tables
61        .iter()
62        .flat_map(|table| {
63            let (row, create) = schema_names(table);
64            [row, create]
65        })
66        .chain(
67            (!manifest.tables.is_empty())
68                .then(|| "Pagination".to_string()),
69        )
70        .find(|name| manifest.components.contains_key(name))
71        .map_or(Ok(()), |name| {
72            Err(OpenApiError(format!(
73                "schema name \"{name}\" is both a table schema and a documented type; rename the type with #[schemars(rename = \"…\")]"
74            )))
75        })
76}
77
78fn unique_operation_ids(manifest: &ServiceManifest) -> Result<(), OpenApiError> {
79    let mut seen = BTreeSet::new();
80    manifest
81        .custom_routes
82        .iter()
83        .filter(|route| custom::published(route))
84        .map(|route| custom::operation_id(route).to_string())
85        .chain(manifest.tables.iter().flat_map(crud::operation_ids))
86        .find(|id| !seen.insert(id.clone()))
87        .map_or(Ok(()), |id| {
88            Err(OpenApiError(format!(
89                "operation id '{id}' is used more than once"
90            )))
91        })
92}
93
94/// A legacy route's operation is its handler's `_fn` path, so two routes of
95/// one handler and method would document one operation.
96fn unique_operations(manifest: &ServiceManifest) -> Result<(), OpenApiError> {
97    let mut seen = BTreeSet::new();
98    manifest
99        .custom_routes
100        .iter()
101        .filter(|route| custom::published(route))
102        .map(|route| {
103            (
104                route.method.to_ascii_uppercase(),
105                custom::relative_path(route, manifest.routing),
106            )
107        })
108        .find(|operation| !seen.insert(operation.clone()))
109        .map_or(Ok(()), |(method, path)| {
110            Err(OpenApiError(format!(
111                "two routes document {method} {path}; route a handler once per method"
112            )))
113        })
114}
115
116fn check_route(
117    manifest: &ServiceManifest,
118    route: &CustomRouteDefinition,
119    components: &Components<'_>,
120    scheme: &str,
121) -> Result<(), OpenApiError> {
122    let Some(doc) = &route.doc else {
123        return Ok(());
124    };
125    let name = || {
126        format!(
127            "route '{} {}' ({})",
128            route.method, route.path, route.handler
129        )
130    };
131    if let Some(path) = &doc.path_params {
132        let declared: BTreeSet<String> = custom::properties(components.resolve(path))
133            .map(|(property, _, _)| property.clone())
134            .collect();
135        let params: BTreeSet<String> = custom::path_param_names(route, manifest.routing)
136            .into_iter()
137            .collect();
138        if declared != params {
139            return Err(OpenApiError(format!(
140                "{}: path = … declares {:?} but the path has parameters {:?}",
141                name(),
142                declared,
143                params
144            )));
145        }
146    }
147    [("path", &doc.path_params), ("query", &doc.query)]
148        .into_iter()
149        .filter_map(|(location, schema)| schema.as_ref().map(|schema| (location, schema)))
150        .try_for_each(|(location, schema)| {
151            let object = components.resolve(schema);
152            if object["properties"].as_object().is_none() {
153                return Err(OpenApiError(format!(
154                    "{}: {location} = … must be a struct with named fields",
155                    name()
156                )));
157            }
158            custom::properties(object)
159                .find(|(_, property, _)| !is_parameter_schema(property, components))
160                .map_or(Ok(()), |(property, _, _)| {
161                    Err(OpenApiError(format!(
162                        "{}: {location} parameter '{property}' must be a scalar or an array",
163                        name()
164                    )))
165                })
166        })?;
167    route_security(route, scheme)
168        .into_iter()
169        .find(|security| *security != scheme && *security != API_KEY_SCHEME)
170        .map_or(Ok(()), |unknown| {
171            Err(OpenApiError(format!(
172                "{}: security scheme '{unknown}' is not defined; the document defines '{scheme}' and '{API_KEY_SCHEME}'",
173                name()
174            )))
175        })
176}
177
178/// A schema a parameter can take: scalars, arrays, enums, and their
179/// nullable forms, directly or through a `$ref`.
180fn is_parameter_schema(schema: &Value, components: &Components<'_>) -> bool {
181    let schema = components.resolve(schema);
182    let scalar = |kind: &Value| {
183        matches!(
184            kind.as_str(),
185            Some("string" | "integer" | "number" | "boolean" | "array" | "null")
186        )
187    };
188    let branches = ["anyOf", "oneOf"]
189        .iter()
190        .find_map(|key| schema[*key].as_array());
191    match (&schema["type"], branches) {
192        (Value::String(_), _) => scalar(&schema["type"]),
193        (Value::Array(kinds), _) => kinds.iter().all(scalar),
194        (_, Some(branches)) => branches
195            .iter()
196            .all(|branch| is_parameter_schema(branch, components)),
197        _ => schema.get("enum").is_some() || schema.get("const").is_some(),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::{OpenApiOptions, RouteDoc, RoutingMode};
205    use serde_json::json;
206
207    fn documented(routes: Vec<CustomRouteDefinition>) -> ServiceManifest {
208        ServiceManifest {
209            name: "notes".into(),
210            routing: RoutingMode::Strict,
211            openapi: Some(OpenApiOptions::default()),
212            custom_routes: routes,
213            ..Default::default()
214        }
215    }
216
217    fn route(method: &str, path: &str, handler: &str, doc: RouteDoc) -> CustomRouteDefinition {
218        CustomRouteDefinition {
219            method: method.into(),
220            path: path.into(),
221            handler: handler.into(),
222            posture: Some(RoutePosture::Authenticated),
223            doc: Some(doc),
224            ..Default::default()
225        }
226    }
227
228    #[test]
229    fn undocumented_manifests_are_not_checked() {
230        let mut legacy = documented(vec![
231            route("GET", "/a", "same", RouteDoc::default()),
232            route("POST", "/a", "same", RouteDoc::default()),
233        ]);
234        assert!(validate(&legacy).is_err());
235        legacy.openapi = None;
236        legacy.custom_routes.iter_mut().for_each(|r| r.doc = None);
237        assert!(validate(&legacy).is_ok());
238    }
239
240    #[test]
241    fn operation_ids_are_unique_across_routes_and_crud() {
242        let m = documented(vec![
243            route("GET", "/a", "one", RouteDoc::default()),
244            route(
245                "GET",
246                "/b",
247                "two",
248                RouteDoc {
249                    operation_id: Some("one".into()),
250                    ..Default::default()
251                },
252            ),
253        ]);
254        assert_eq!(
255            validate(&m),
256            Err(OpenApiError(
257                "operation id 'one' is used more than once".into()
258            ))
259        );
260    }
261
262    #[test]
263    fn internal_routes_are_not_published_so_share_no_operation_id() {
264        let mut internal = route("POST", "/sync", "one", RouteDoc::default());
265        internal.posture = Some(RoutePosture::Internal {
266            callers: vec!["*".into()],
267        });
268        let m = documented(vec![
269            route("GET", "/a", "one", RouteDoc::default()),
270            internal,
271        ]);
272        assert_eq!(validate(&m), Ok(()));
273    }
274
275    #[test]
276    fn a_legacy_handler_documents_one_operation_per_method() {
277        let named = |id: &str| RouteDoc {
278            operation_id: Some(id.into()),
279            ..Default::default()
280        };
281        let mut m = documented(vec![
282            route("POST", "/a", "same", named("first")),
283            route("POST", "/b", "same", named("second")),
284        ]);
285        m.routing = RoutingMode::Legacy;
286        assert_eq!(
287            validate(&m),
288            Err(OpenApiError(
289                "two routes document POST /_fn/same; route a handler once per method".into()
290            ))
291        );
292        m.custom_routes[1].method = "GET".into();
293        assert_eq!(validate(&m), Ok(()));
294    }
295
296    #[test]
297    fn path_structs_name_exactly_the_path_parameters() {
298        let path_doc = |properties: Value| RouteDoc {
299            path_params: Some(
300                json!({"type": "object", "properties": properties, "required": ["note_id"]}),
301            ),
302            ..Default::default()
303        };
304        let ok = documented(vec![route(
305            "GET",
306            "/notes/{note_id}",
307            "get_note",
308            path_doc(json!({"note_id": {"type": "string"}})),
309        )]);
310        assert!(validate(&ok).is_ok());
311        let missing = documented(vec![route(
312            "GET",
313            "/notes/{note_id}/{rev}",
314            "get_note",
315            path_doc(json!({"note_id": {"type": "string"}})),
316        )]);
317        let err = validate(&missing).unwrap_err().to_string();
318        assert!(
319            err.contains("path has parameters {\"note_id\", \"rev\"}"),
320            "{err}"
321        );
322    }
323
324    #[test]
325    fn parameters_are_scalars_or_arrays() {
326        let query = |property: Value| RouteDoc {
327            query: Some(json!({"type": "object", "properties": {"q": property}})),
328            ..Default::default()
329        };
330        let mut m = documented(vec![]);
331        m.components
332            .insert("Kind".into(), json!({"type": "string", "enum": ["a", "b"]}));
333        for property in [
334            json!({"type": ["integer", "null"], "format": "int32"}),
335            json!({"type": "array", "items": {"type": "string"}}),
336            json!({"$ref": "#/components/schemas/Kind"}),
337            json!({"anyOf": [{"$ref": "#/components/schemas/Kind"}, {"type": "null"}]}),
338            json!({"enum": ["x"]}),
339        ] {
340            m.custom_routes = vec![route("GET", "/s", "search", query(property.clone()))];
341            assert!(validate(&m).is_ok(), "{property}");
342        }
343        m.custom_routes = vec![route(
344            "GET",
345            "/s",
346            "search",
347            query(json!({"type": "object", "properties": {}})),
348        )];
349        let err = validate(&m).unwrap_err().to_string();
350        assert!(
351            err.contains("query parameter 'q' must be a scalar or an array"),
352            "{err}"
353        );
354        m.custom_routes = vec![route(
355            "GET",
356            "/s",
357            "search",
358            RouteDoc {
359                query: Some(json!({"type": "string"})),
360                ..Default::default()
361            },
362        )];
363        assert!(validate(&m).is_err());
364    }
365
366    #[test]
367    fn security_names_a_defined_scheme() {
368        let m = documented(vec![route(
369            "GET",
370            "/a",
371            "a",
372            RouteDoc {
373                security: Some(vec!["oauth".into()]),
374                ..Default::default()
375            },
376        )]);
377        let err = validate(&m).unwrap_err().to_string();
378        assert!(
379            err.contains("security scheme 'oauth' is not defined"),
380            "{err}"
381        );
382    }
383
384    #[test]
385    fn a_documented_type_may_share_a_private_tables_schema_name() {
386        let notes: crate::TableDefinition = serde_json::from_value(json!({
387            "name": "note", "managed": false,
388            "columns": [{"name": "id", "column_type": "uuid", "primary_key": true}]
389        }))
390        .expect("table");
391        let typed = |tables: Vec<crate::TableDefinition>, private_tables| ServiceManifest {
392            tables,
393            private_tables,
394            components: [("Note".to_string(), json!({"type": "object"}))].into(),
395            ..documented(vec![route("GET", "/notes", "notes", RouteDoc::default())])
396        };
397        assert_eq!(validate(&typed(vec![], vec![notes.clone()])), Ok(()));
398        assert_eq!(
399            validate(&typed(vec![notes], vec![])),
400            Err(OpenApiError(
401                "schema name \"Note\" is both a table schema and a documented type; rename the type with #[schemars(rename = \"…\")]".into()
402            ))
403        );
404    }
405}