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(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
91fn 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
175fn 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}