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