diff --git a/adapter/rest/src/main.rs b/adapter/rest/src/main.rs index 629bd63..28e95e7 100644 --- a/adapter/rest/src/main.rs +++ b/adapter/rest/src/main.rs @@ -17,6 +17,7 @@ mod content_type; mod request; mod response; mod route; +mod validation; #[tokio::main] async fn main() { diff --git a/adapter/rest/src/request/mod.rs b/adapter/rest/src/request/mod.rs index 12c529f..a63c967 100644 --- a/adapter/rest/src/request/mod.rs +++ b/adapter/rest/src/request/mod.rs @@ -13,6 +13,7 @@ use crate::auth::{authenticate_header_name, validate_flow_auth}; use crate::content_type; use crate::response::{error_to_http_response, flow_execution_to_http_response}; use crate::route::{self, RequestRoute}; +use crate::validation; pub async fn handle( req: Request, @@ -62,6 +63,21 @@ pub async fn handle( Err(response) => return Ok(response), }; + let input_schema = route::extract_flow_setting_as_struct(&flow, "input_schema"); + if let Err(err) = + validation::validate_body_against_schema(input_schema, request_body_value.as_ref()) + { + log::warn!( + "Request body failed input schema validation: flow_id={} error={}", + flow.flow_id, + err + ); + return Ok(error_to_http_response( + StatusCode::BAD_REQUEST, + &err.to_string(), + )); + } + let input = input::build_flow_input( &flow, &path, diff --git a/adapter/rest/src/route.rs b/adapter/rest/src/route.rs index bb5a8ec..d13adb3 100644 --- a/adapter/rest/src/route.rs +++ b/adapter/rest/src/route.rs @@ -1,6 +1,6 @@ use base::traits::IdentifiableFlow; use std::collections::HashMap; -use tucana::shared::{ValidationFlow, value::Kind}; +use tucana::shared::{Struct, ValidationFlow, value::Kind}; pub struct RequestRoute { pub url: String, @@ -277,6 +277,64 @@ fn read_balanced_group(chars: &[char], start: usize) -> Result<(String, usize), Err(String::from("unclosed parameter regex group")) } +pub fn extract_flow_setting_as_struct<'a>( + flow: &'a ValidationFlow, + flow_setting_id: &str, +) -> Option<&'a Struct> { + let setting = match flow + .settings + .iter() + .find(|setting| setting.flow_setting_id == flow_setting_id) + { + Some(setting) => setting, + None => { + log::debug!( + "flow setting is missing: flow_id={} flow_setting_id={}", + flow.flow_id, + flow_setting_id + ); + return None; + } + }; + + let value = match setting.value.as_ref() { + Some(value) => value, + None => { + log::debug!( + "flow setting has no value: flow_id={} flow_setting_id={}", + flow.flow_id, + flow_setting_id + ); + return None; + } + }; + + let kind = match value.kind.as_ref() { + Some(kind) => kind, + None => { + log::debug!( + "flow setting has no kind: flow_id={} flow_setting_id={}", + flow.flow_id, + flow_setting_id + ); + return None; + } + }; + + match kind { + Kind::StructValue(value) => Some(value), + _ => { + log::debug!( + "flow setting has non-struct kind: flow_id={} flow_setting_id={} kind={:?}", + flow.flow_id, + flow_setting_id, + kind + ); + None + } + } +} + fn extract_flow_setting_as_string<'a>( flow: &'a ValidationFlow, flow_setting_id: &str, diff --git a/adapter/rest/src/validation.rs b/adapter/rest/src/validation.rs new file mode 100644 index 0000000..cad1b7d --- /dev/null +++ b/adapter/rest/src/validation.rs @@ -0,0 +1,164 @@ +use lupus::data::{Data, Number}; +use std::collections::BTreeMap; +use tucana::shared::{Struct, Value, helper::value::to_json_value, value::Kind}; + +#[derive(Debug)] +pub enum BodyValidationError { + InvalidSchema(String), + InvalidBody(String), + Validation(String), +} + +impl std::fmt::Display for BodyValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSchema(msg) => write!(f, "flow input schema is invalid: {}", msg), + Self::InvalidBody(msg) => write!(f, "request body could not be validated: {}", msg), + Self::Validation(msg) => write!(f, "request body failed schema validation: {}", msg), + } + } +} + +impl std::error::Error for BodyValidationError {} + +/// Validates `body` against `input_schema` (a JSON Schema stored as a `shared.Struct` on the +/// flow). A flow without an `input_schema` (or with an empty one) accepts any body unvalidated. +pub fn validate_body_against_schema( + input_schema: Option<&Struct>, + body: Option<&Value>, +) -> Result<(), BodyValidationError> { + let Some(input_schema) = input_schema.filter(|schema| !schema.fields.is_empty()) else { + return Ok(()); + }; + + let schema_json = to_json_value(Value { + kind: Some(Kind::StructValue(input_schema.clone())), + }); + let raw = serde_json::to_string(&schema_json) + .map_err(|err| BodyValidationError::InvalidSchema(err.to_string()))?; + let schema = lupus::JsonSchema { raw }; + + let body_value = body.cloned().unwrap_or(Value { + kind: Some(Kind::NullValue(0)), + }); + let body_json = to_json_value(body_value); + let data = json_value_to_data(body_json)?; + + lupus::validation::validate_json_schema(&data, &schema) + .map_err(|err| BodyValidationError::Validation(err.to_string())) +} + +/// Mirrors lupus's internal JSON-to-`Data` conversion. We can't reuse `lupus::formats::json` +/// directly here because the intermediate `tucana::shared::Value` type in this workspace and the +/// one `lupus` depends on resolve to different (semver-incompatible 0.0.x) versions of the +/// `tucana` crate, so we go through `serde_json::Value` instead, which both sides share. +fn json_value_to_data(value: serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Ok(Data::Null), + serde_json::Value::Bool(value) => Ok(Data::Bool(value)), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(Data::Number(Number::I64(value))) + } else if let Some(value) = value.as_u64() { + Ok(Data::Number(Number::U64(value))) + } else if let Some(value) = value.as_f64() { + Ok(Data::Number(Number::F64(value))) + } else { + Err(BodyValidationError::InvalidBody( + "unsupported JSON number".to_string(), + )) + } + } + serde_json::Value::String(value) => Ok(Data::String(value)), + serde_json::Value::Array(values) => values + .into_iter() + .map(json_value_to_data) + .collect::, _>>() + .map(Data::Array), + serde_json::Value::Object(fields) => fields + .into_iter() + .map(|(key, value)| Ok((key, json_value_to_data(value)?))) + .collect::, _>>() + .map(Data::Object), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn schema_struct(raw_schema: serde_json::Value) -> Struct { + let Value { + kind: Some(Kind::StructValue(schema)), + } = tucana::shared::helper::value::from_json_value(raw_schema) + else { + panic!("expected object schema"); + }; + schema + } + + #[test] + fn missing_schema_allows_any_body() { + let body = Value { + kind: Some(Kind::StringValue("anything".to_string())), + }; + assert!(validate_body_against_schema(None, Some(&body)).is_ok()); + } + + #[test] + fn empty_schema_allows_any_body() { + let schema = Struct { + fields: HashMap::new(), + }; + let body = Value { + kind: Some(Kind::StringValue("anything".to_string())), + }; + assert!(validate_body_against_schema(Some(&schema), Some(&body)).is_ok()); + } + + #[test] + fn matching_body_passes_validation() { + let schema = schema_struct(serde_json::json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + })); + + let body = tucana::shared::helper::value::from_json_value(serde_json::json!({ + "name": "Ada" + })); + + assert!(validate_body_against_schema(Some(&schema), Some(&body)).is_ok()); + } + + #[test] + fn mismatched_body_fails_validation() { + let schema = schema_struct(serde_json::json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + })); + + let body = tucana::shared::helper::value::from_json_value(serde_json::json!({ + "age": 42 + })); + + let err = validate_body_against_schema(Some(&schema), Some(&body)).unwrap_err(); + assert!(matches!(err, BodyValidationError::Validation(_))); + } + + #[test] + fn missing_body_is_validated_as_null() { + let schema = schema_struct(serde_json::json!({ + "type": "object" + })); + + let err = validate_body_against_schema(Some(&schema), None).unwrap_err(); + assert!(matches!(err, BodyValidationError::Validation(_))); + } +}