1use 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
12pub(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
35pub 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
94fn 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
178fn 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}