Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions adapter/rest/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod content_type;
mod request;
mod response;
mod route;
mod validation;

#[tokio::main]
async fn main() {
Expand Down
16 changes: 16 additions & 0 deletions adapter/rest/src/request/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Incoming>,
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 59 additions & 1 deletion adapter/rest/src/route.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
164 changes: 164 additions & 0 deletions adapter/rest/src/validation.rs
Original file line number Diff line number Diff line change
@@ -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<Data, BodyValidationError> {
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::<Result<Vec<_>, _>>()
.map(Data::Array),
serde_json::Value::Object(fields) => fields
.into_iter()
.map(|(key, value)| Ok((key, json_value_to_data(value)?)))
.collect::<Result<BTreeMap<_, _>, _>>()
.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(_)));
}
}