use super::custom::{self, Components};
use super::schema::schema_names;
use super::{crud, default_style, OpenApiError, PathStyle};
use crate::{CustomRouteDefinition, RoutePosture, ServiceManifest};
use serde_json::{Map, Value};
use std::collections::BTreeSet;
pub(super) const API_KEY_SCHEME: &str = "api_key";
pub(super) fn route_security<'a>(
route: &'a CustomRouteDefinition,
scheme: &'a str,
) -> Vec<&'a str> {
match route.doc.as_ref().and_then(|doc| doc.security.as_ref()) {
Some(names) => names.iter().map(String::as_str).collect(),
None => match &route.posture {
None | Some(RoutePosture::Public { .. }) | Some(RoutePosture::Internal { .. }) => {
Vec::new()
}
Some(RoutePosture::ApiKey { .. }) => vec![API_KEY_SCHEME],
Some(RoutePosture::Authenticated)
| Some(RoutePosture::Permission { .. })
| Some(RoutePosture::Owner { .. })
| Some(RoutePosture::Scoped { .. }) => vec![scheme],
},
}
}
pub fn validate(manifest: &ServiceManifest) -> Result<(), OpenApiError> {
if matches!(default_style(manifest, ""), PathStyle::Absolute { .. }) {
return Ok(());
}
unique_operation_ids(manifest)?;
unique_operations(manifest)?;
let components = Map::from_iter(manifest.components.clone());
let lookup = Components(&components);
let scheme = manifest
.openapi
.as_ref()
.map_or(crate::OpenApiOptions::DEFAULT_SECURITY_SCHEME, |o| {
o.security_scheme()
});
manifest
.custom_routes
.iter()
.try_for_each(|route| check_route(manifest, route, &lookup, scheme))?;
manifest
.tables
.iter()
.flat_map(schema_names)
.chain(
(!manifest.tables.is_empty())
.then(|| "Pagination".to_string()),
)
.find(|name| manifest.components.contains_key(name))
.map_or(Ok(()), |name| {
Err(OpenApiError(format!(
"schema name \"{name}\" is both a table schema and a documented type; rename the type with #[schemars(rename = \"…\")]"
)))
})
}
fn unique_operation_ids(manifest: &ServiceManifest) -> Result<(), OpenApiError> {
let mut seen = BTreeSet::new();
manifest
.custom_routes
.iter()
.filter(|route| custom::published(route))
.map(|route| custom::operation_id(route).to_string())
.chain(manifest.tables.iter().flat_map(crud::operation_ids))
.find(|id| !seen.insert(id.clone()))
.map_or(Ok(()), |id| {
Err(OpenApiError(format!(
"operation id '{id}' is used more than once"
)))
})
}
fn unique_operations(manifest: &ServiceManifest) -> Result<(), OpenApiError> {
let mut seen = BTreeSet::new();
manifest
.custom_routes
.iter()
.filter(|route| custom::published(route))
.map(|route| {
(
route.method.to_ascii_uppercase(),
custom::relative_path(route, manifest.routing),
)
})
.find(|operation| !seen.insert(operation.clone()))
.map_or(Ok(()), |(method, path)| {
Err(OpenApiError(format!(
"two routes document {method} {path}; route a handler once per method"
)))
})
}
fn check_route(
manifest: &ServiceManifest,
route: &CustomRouteDefinition,
components: &Components<'_>,
scheme: &str,
) -> Result<(), OpenApiError> {
let Some(doc) = &route.doc else {
return Ok(());
};
let name = || {
format!(
"route '{} {}' ({})",
route.method, route.path, route.handler
)
};
if let Some(path) = &doc.path_params {
let declared: BTreeSet<String> = custom::properties(components.resolve(path))
.map(|(property, _, _)| property.clone())
.collect();
let params: BTreeSet<String> = custom::path_param_names(route, manifest.routing)
.into_iter()
.collect();
if declared != params {
return Err(OpenApiError(format!(
"{}: path = … declares {:?} but the path has parameters {:?}",
name(),
declared,
params
)));
}
}
[("path", &doc.path_params), ("query", &doc.query)]
.into_iter()
.filter_map(|(location, schema)| schema.as_ref().map(|schema| (location, schema)))
.try_for_each(|(location, schema)| {
let object = components.resolve(schema);
if object["properties"].as_object().is_none() {
return Err(OpenApiError(format!(
"{}: {location} = … must be a struct with named fields",
name()
)));
}
custom::properties(object)
.find(|(_, property, _)| !is_parameter_schema(property, components))
.map_or(Ok(()), |(property, _, _)| {
Err(OpenApiError(format!(
"{}: {location} parameter '{property}' must be a scalar or an array",
name()
)))
})
})?;
route_security(route, scheme)
.into_iter()
.find(|security| *security != scheme && *security != API_KEY_SCHEME)
.map_or(Ok(()), |unknown| {
Err(OpenApiError(format!(
"{}: security scheme '{unknown}' is not defined; the document defines '{scheme}' and '{API_KEY_SCHEME}'",
name()
)))
})
}
fn is_parameter_schema(schema: &Value, components: &Components<'_>) -> bool {
let schema = components.resolve(schema);
let scalar = |kind: &Value| {
matches!(
kind.as_str(),
Some("string" | "integer" | "number" | "boolean" | "array" | "null")
)
};
let branches = ["anyOf", "oneOf"]
.iter()
.find_map(|key| schema[*key].as_array());
match (&schema["type"], branches) {
(Value::String(_), _) => scalar(&schema["type"]),
(Value::Array(kinds), _) => kinds.iter().all(scalar),
(_, Some(branches)) => branches
.iter()
.all(|branch| is_parameter_schema(branch, components)),
_ => schema.get("enum").is_some() || schema.get("const").is_some(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{OpenApiOptions, RouteDoc, RoutingMode};
use serde_json::json;
fn documented(routes: Vec<CustomRouteDefinition>) -> ServiceManifest {
ServiceManifest {
name: "notes".into(),
routing: RoutingMode::Strict,
openapi: Some(OpenApiOptions::default()),
custom_routes: routes,
..Default::default()
}
}
fn route(method: &str, path: &str, handler: &str, doc: RouteDoc) -> CustomRouteDefinition {
CustomRouteDefinition {
method: method.into(),
path: path.into(),
handler: handler.into(),
posture: Some(RoutePosture::Authenticated),
doc: Some(doc),
..Default::default()
}
}
#[test]
fn undocumented_manifests_are_not_checked() {
let mut legacy = documented(vec![
route("GET", "/a", "same", RouteDoc::default()),
route("POST", "/a", "same", RouteDoc::default()),
]);
assert!(validate(&legacy).is_err());
legacy.openapi = None;
legacy.custom_routes.iter_mut().for_each(|r| r.doc = None);
assert!(validate(&legacy).is_ok());
}
#[test]
fn operation_ids_are_unique_across_routes_and_crud() {
let m = documented(vec![
route("GET", "/a", "one", RouteDoc::default()),
route(
"GET",
"/b",
"two",
RouteDoc {
operation_id: Some("one".into()),
..Default::default()
},
),
]);
assert_eq!(
validate(&m),
Err(OpenApiError(
"operation id 'one' is used more than once".into()
))
);
}
#[test]
fn internal_routes_are_not_published_so_share_no_operation_id() {
let mut internal = route("POST", "/sync", "one", RouteDoc::default());
internal.posture = Some(RoutePosture::Internal {
callers: vec!["*".into()],
});
let m = documented(vec![
route("GET", "/a", "one", RouteDoc::default()),
internal,
]);
assert_eq!(validate(&m), Ok(()));
}
#[test]
fn a_legacy_handler_documents_one_operation_per_method() {
let named = |id: &str| RouteDoc {
operation_id: Some(id.into()),
..Default::default()
};
let mut m = documented(vec![
route("POST", "/a", "same", named("first")),
route("POST", "/b", "same", named("second")),
]);
m.routing = RoutingMode::Legacy;
assert_eq!(
validate(&m),
Err(OpenApiError(
"two routes document POST /_fn/same; route a handler once per method".into()
))
);
m.custom_routes[1].method = "GET".into();
assert_eq!(validate(&m), Ok(()));
}
#[test]
fn path_structs_name_exactly_the_path_parameters() {
let path_doc = |properties: Value| RouteDoc {
path_params: Some(
json!({"type": "object", "properties": properties, "required": ["note_id"]}),
),
..Default::default()
};
let ok = documented(vec![route(
"GET",
"/notes/{note_id}",
"get_note",
path_doc(json!({"note_id": {"type": "string"}})),
)]);
assert!(validate(&ok).is_ok());
let missing = documented(vec![route(
"GET",
"/notes/{note_id}/{rev}",
"get_note",
path_doc(json!({"note_id": {"type": "string"}})),
)]);
let err = validate(&missing).unwrap_err().to_string();
assert!(
err.contains("path has parameters {\"note_id\", \"rev\"}"),
"{err}"
);
}
#[test]
fn parameters_are_scalars_or_arrays() {
let query = |property: Value| RouteDoc {
query: Some(json!({"type": "object", "properties": {"q": property}})),
..Default::default()
};
let mut m = documented(vec![]);
m.components
.insert("Kind".into(), json!({"type": "string", "enum": ["a", "b"]}));
for property in [
json!({"type": ["integer", "null"], "format": "int32"}),
json!({"type": "array", "items": {"type": "string"}}),
json!({"$ref": "#/components/schemas/Kind"}),
json!({"anyOf": [{"$ref": "#/components/schemas/Kind"}, {"type": "null"}]}),
json!({"enum": ["x"]}),
] {
m.custom_routes = vec![route("GET", "/s", "search", query(property.clone()))];
assert!(validate(&m).is_ok(), "{property}");
}
m.custom_routes = vec![route(
"GET",
"/s",
"search",
query(json!({"type": "object", "properties": {}})),
)];
let err = validate(&m).unwrap_err().to_string();
assert!(
err.contains("query parameter 'q' must be a scalar or an array"),
"{err}"
);
m.custom_routes = vec![route(
"GET",
"/s",
"search",
RouteDoc {
query: Some(json!({"type": "string"})),
..Default::default()
},
)];
assert!(validate(&m).is_err());
}
#[test]
fn security_names_a_defined_scheme() {
let m = documented(vec![route(
"GET",
"/a",
"a",
RouteDoc {
security: Some(vec!["oauth".into()]),
..Default::default()
},
)]);
let err = validate(&m).unwrap_err().to_string();
assert!(
err.contains("security scheme 'oauth' is not defined"),
"{err}"
);
}
#[test]
fn a_documented_type_may_share_a_private_tables_schema_name() {
let notes: crate::TableDefinition = serde_json::from_value(json!({
"name": "note", "managed": false,
"columns": [{"name": "id", "column_type": "uuid", "primary_key": true}]
}))
.expect("table");
let typed = |tables: Vec<crate::TableDefinition>, private_tables| ServiceManifest {
tables,
private_tables,
components: [("Note".to_string(), json!({"type": "object"}))].into(),
..documented(vec![route("GET", "/notes", "notes", RouteDoc::default())])
};
assert_eq!(validate(&typed(vec![], vec![notes.clone()])), Ok(()));
assert_eq!(
validate(&typed(vec![notes], vec![])),
Err(OpenApiError(
"schema name \"Note\" is both a table schema and a documented type; rename the type with #[schemars(rename = \"…\")]".into()
))
);
}
}