diff --git a/Cargo.lock b/Cargo.lock index 2f69054921..85eb9fb234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6586,6 +6586,7 @@ dependencies = [ "serde_json", "strum 0.26.3", "syn 2.0.100", + "url", "utoipa", ] diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index 2315a94b5e..c4bf23abfa 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -19091,7 +19091,8 @@ "type": "string", "description": "The URL to which you want the user to be redirected after the completion of the payment operation", "example": "https://hyperswitch.io", - "nullable": true + "nullable": true, + "maxLength": 255 }, "setup_future_usage": { "allOf": [ @@ -19181,7 +19182,7 @@ "description": "A unique identifier to link the payment to a mandate. To do Recurring payments after a mandate has been created, pass the mandate_id instead of payment_method_data", "example": "mandate_iwer89rnjef349dni3", "nullable": true, - "maxLength": 255 + "maxLength": 64 }, "browser_info": { "allOf": [ @@ -19495,7 +19496,8 @@ "type": "string", "description": "The URL to which you want the user to be redirected after the completion of the payment operation", "example": "https://hyperswitch.io", - "nullable": true + "nullable": true, + "maxLength": 255 }, "setup_future_usage": { "allOf": [ @@ -19579,7 +19581,7 @@ "description": "A unique identifier to link the payment to a mandate. To do Recurring payments after a mandate has been created, pass the mandate_id instead of payment_method_data", "example": "mandate_iwer89rnjef349dni3", "nullable": true, - "maxLength": 255 + "maxLength": 64 }, "browser_info": { "allOf": [ @@ -20752,7 +20754,8 @@ "type": "string", "description": "The URL to which you want the user to be redirected after the completion of the payment operation", "example": "https://hyperswitch.io", - "nullable": true + "nullable": true, + "maxLength": 255 }, "setup_future_usage": { "allOf": [ @@ -20848,7 +20851,7 @@ "description": "A unique identifier to link the payment to a mandate. To do Recurring payments after a mandate has been created, pass the mandate_id instead of payment_method_data", "example": "mandate_iwer89rnjef349dni3", "nullable": true, - "maxLength": 255 + "maxLength": 64 }, "browser_info": { "allOf": [ @@ -21962,7 +21965,8 @@ "type": "string", "description": "The URL to which you want the user to be redirected after the completion of the payment operation", "example": "https://hyperswitch.io", - "nullable": true + "nullable": true, + "maxLength": 255 }, "setup_future_usage": { "allOf": [ diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index b313a3b303..5286f071a1 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -847,6 +847,7 @@ impl AmountDetailsUpdate { Clone, ToSchema, router_derive::PolymorphicSchema, + router_derive::ValidateSchema, )] #[generate_schemas(PaymentsCreateRequest, PaymentsUpdateRequest, PaymentsConfirmRequest)] #[serde(deny_unknown_fields)] @@ -963,7 +964,7 @@ pub struct PaymentsRequest { pub description: Option, /// The URL to which you want the user to be redirected after the completion of the payment operation - #[schema(value_type = Option, example = "https://hyperswitch.io")] + #[schema(value_type = Option, example = "https://hyperswitch.io", max_length = 255)] pub return_url: Option, #[schema(value_type = Option, example = "off_session")] @@ -1018,7 +1019,7 @@ pub struct PaymentsRequest { pub customer_acceptance: Option, /// A unique identifier to link the payment to a mandate. To do Recurring payments after a mandate has been created, pass the mandate_id instead of payment_method_data - #[schema(max_length = 255, example = "mandate_iwer89rnjef349dni3")] + #[schema(max_length = 64, example = "mandate_iwer89rnjef349dni3")] #[remove_in(PaymentsUpdateRequest)] pub mandate_id: Option, diff --git a/crates/router/src/routes/payments.rs b/crates/router/src/routes/payments.rs index 3ea5049742..36c8b727b5 100644 --- a/crates/router/src/routes/payments.rs +++ b/crates/router/src/routes/payments.rs @@ -37,6 +37,12 @@ pub async fn payments_create( ) -> impl Responder { let flow = Flow::PaymentsCreate; let mut payload = json_payload.into_inner(); + if let Err(err) = payload + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + { + return api::log_and_return_error_response(err.into()); + }; if let Some(api_enums::CaptureMethod::Scheduled) = payload.capture_method { return http_not_implemented(); @@ -575,6 +581,12 @@ pub async fn payments_update( ) -> impl Responder { let flow = Flow::PaymentsUpdate; let mut payload = json_payload.into_inner(); + if let Err(err) = payload + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + { + return api::log_and_return_error_response(err.into()); + }; if let Some(api_enums::CaptureMethod::Scheduled) = payload.capture_method { return http_not_implemented(); @@ -755,6 +767,12 @@ pub async fn payments_confirm( ) -> impl Responder { let flow = Flow::PaymentsConfirm; let mut payload = json_payload.into_inner(); + if let Err(err) = payload + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + { + return api::log_and_return_error_response(err.into()); + }; if let Some(api_enums::CaptureMethod::Scheduled) = payload.capture_method { return http_not_implemented(); diff --git a/crates/router/tests/macros.rs b/crates/router/tests/macros.rs index 3a5301efa5..50244b9951 100644 --- a/crates/router/tests/macros.rs +++ b/crates/router/tests/macros.rs @@ -40,3 +40,64 @@ mod flat_struct_test { assert_eq!(flat_user_map, required_map); } } + +#[cfg(test)] +mod validate_schema_test { + #![allow(clippy::unwrap_used)] + + use router_derive::ValidateSchema; + use url::Url; + + #[test] + fn test_validate_schema() { + #[derive(ValidateSchema)] + struct Payment { + #[schema(min_length = 5, max_length = 12)] + payment_id: String, + + #[schema(min_length = 10, max_length = 100)] + description: Option, + + #[schema(max_length = 255)] + return_url: Option, + } + + // Valid case + let valid_payment = Payment { + payment_id: "payment_123".to_string(), + description: Some("This is a valid description".to_string()), + return_url: Some("https://example.com/return".parse().unwrap()), + }; + assert!(valid_payment.validate().is_ok()); + + // Invalid: payment_id too short + let invalid_id = Payment { + payment_id: "pay".to_string(), + description: Some("This is a valid description".to_string()), + return_url: Some("https://example.com/return".parse().unwrap()), + }; + let err = invalid_id.validate().unwrap_err(); + assert!( + err.contains("payment_id must be at least 5 characters long. Received 3 characters") + ); + + // Invalid: payment_id too long + let invalid_desc = Payment { + payment_id: "payment_12345".to_string(), + description: Some("This is a valid description".to_string()), + return_url: Some("https://example.com/return".parse().unwrap()), + }; + let err = invalid_desc.validate().unwrap_err(); + assert!( + err.contains("payment_id must be at most 12 characters long. Received 13 characters") + ); + + // None values should pass validation + let none_values = Payment { + payment_id: "payment_123".to_string(), + description: None, + return_url: None, + }; + assert!(none_values.validate().is_ok()); + } +} diff --git a/crates/router_derive/Cargo.toml b/crates/router_derive/Cargo.toml index 72cf9605f9..d6d25f281c 100644 --- a/crates/router_derive/Cargo.toml +++ b/crates/router_derive/Cargo.toml @@ -24,6 +24,7 @@ diesel = { version = "2.2.3", features = ["postgres"] } error-stack = "0.4.1" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" +url = { version = "2.5.0", features = ["serde"] } utoipa = "4.2.0" common_utils = { version = "0.1.0", path = "../common_utils" } diff --git a/crates/router_derive/src/lib.rs b/crates/router_derive/src/lib.rs index d1fb543318..5e422dcda5 100644 --- a/crates/router_derive/src/lib.rs +++ b/crates/router_derive/src/lib.rs @@ -764,3 +764,65 @@ pub fn derive_to_encryption_attr(input: proc_macro::TokenStream) -> proc_macro:: .unwrap_or_else(|err| err.into_compile_error()) .into() } + +/// Derives validation functionality for structs with string-based fields that have +/// schema attributes specifying constraints like minimum and maximum lengths. +/// +/// This macro generates a `validate()` method that checks if string based fields +/// meet the length requirements specified in their schema attributes. +/// +/// ## Supported Types +/// - Option or T: where T: String or Url +/// +/// ## Supported Schema Attributes +/// +/// - `min_length`: Specifies the minimum allowed character length +/// - `max_length`: Specifies the maximum allowed character length +/// +/// ## Example +/// +/// ``` +/// use utoipa::ToSchema; +/// use router_derive::ValidateSchema; +/// use url::Url; +/// +/// #[derive(Default, ToSchema, ValidateSchema)] +/// pub struct PaymentRequest { +/// #[schema(min_length = 10, max_length = 255)] +/// pub description: String, +/// +/// #[schema(example = "https://example.com/return", max_length = 255)] +/// pub return_url: Option, +/// +/// // Field without constraints +/// pub amount: u64, +/// } +/// +/// let payment = PaymentRequest { +/// description: "Too short".to_string(), +/// return_url: Some(Url::parse("https://very-long-domain.com/callback").unwrap()), +/// amount: 1000, +/// }; +/// +/// let validation_result = payment.validate(); +/// assert!(validation_result.is_err()); +/// assert_eq!( +/// validation_result.unwrap_err(), +/// "description must be at least 10 characters long. Received 9 characters" +/// ); +/// ``` +/// +/// ## Notes +/// - For `Option` fields, validation is only performed when the value is `Some` +/// - Fields without schema attributes or with unsupported types are ignored +/// - The validation stops on the first error encountered +/// - The generated `validate()` method returns `Ok(())` if all validations pass, or +/// `Err(String)` with an error message if any validations fail. +#[proc_macro_derive(ValidateSchema, attributes(schema))] +pub fn validate_schema(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + + macros::validate_schema_derive(input) + .unwrap_or_else(|error| error.into_compile_error()) + .into() +} diff --git a/crates/router_derive/src/macros.rs b/crates/router_derive/src/macros.rs index e227c6533e..42afb638cf 100644 --- a/crates/router_derive/src/macros.rs +++ b/crates/router_derive/src/macros.rs @@ -4,6 +4,7 @@ pub(crate) mod generate_permissions; pub(crate) mod generate_schema; pub(crate) mod misc; pub(crate) mod operation; +pub(crate) mod schema; pub(crate) mod to_encryptable; pub(crate) mod try_get_enum; @@ -18,6 +19,7 @@ pub(crate) use self::{ diesel::{diesel_enum_derive_inner, diesel_enum_text_derive_inner}, generate_permissions::generate_permissions_inner, generate_schema::polymorphic_macro_derive_inner, + schema::validate_schema_derive, to_encryptable::derive_to_encryption, }; diff --git a/crates/router_derive/src/macros/schema.rs b/crates/router_derive/src/macros/schema.rs new file mode 100644 index 0000000000..c79d9a071c --- /dev/null +++ b/crates/router_derive/src/macros/schema.rs @@ -0,0 +1,88 @@ +mod helpers; + +use quote::quote; + +use crate::macros::{ + helpers as macro_helpers, + schema::helpers::{HasSchemaParameters, IsSchemaFieldApplicableForValidation}, +}; + +pub fn validate_schema_derive(input: syn::DeriveInput) -> syn::Result { + let name = &input.ident; + + // Extract struct fields + let fields = macro_helpers::get_struct_fields(input.data) + .map_err(|error| syn::Error::new(proc_macro2::Span::call_site(), error))?; + + // Map over each field + let validation_checks = fields.iter().filter_map(|field| { + let field_name = field.ident.as_ref()?; + let field_type = &field.ty; + + // Check if field type is valid for validation + let is_field_valid = match IsSchemaFieldApplicableForValidation::from(field_type) { + IsSchemaFieldApplicableForValidation::Invalid => return None, + val => val, + }; + + // Parse attribute parameters for 'schema' + let schema_params = match field.get_schema_parameters() { + Ok(params) => params, + Err(_) => return None, + }; + + let min_length = schema_params.min_length; + let max_length = schema_params.max_length; + + // Skip if no length validation is needed + if min_length.is_none() && max_length.is_none() { + return None; + } + + let min_check = min_length.map(|min_val| { + quote! { + if value_len < #min_val { + return Err(format!("{} must be at least {} characters long. Received {} characters", + stringify!(#field_name), #min_val, value_len)); + } + } + }).unwrap_or_else(|| quote! {}); + + let max_check = max_length.map(|max_val| { + quote! { + if value_len > #max_val { + return Err(format!("{} must be at most {} characters long. Received {} characters", + stringify!(#field_name), #max_val, value_len)); + } + } + }).unwrap_or_else(|| quote! {}); + + // Generate length validation + if is_field_valid == IsSchemaFieldApplicableForValidation::ValidOptional { + Some(quote! { + if let Some(value) = &self.#field_name { + let value_len = value.as_str().len(); + #min_check + #max_check + } + }) + } else { + Some(quote! { + { + let value_len = self.#field_name.as_str().len(); + #min_check + #max_check + } + }) + } + }).collect::>(); + + Ok(quote! { + impl #name { + pub fn validate(&self) -> Result<(), String> { + #(#validation_checks)* + Ok(()) + } + } + }) +} diff --git a/crates/router_derive/src/macros/schema/helpers.rs b/crates/router_derive/src/macros/schema/helpers.rs new file mode 100644 index 0000000000..54954e24d6 --- /dev/null +++ b/crates/router_derive/src/macros/schema/helpers.rs @@ -0,0 +1,189 @@ +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::{parse::Parse, Field, LitInt, LitStr, Token, TypePath}; + +use crate::macros::helpers::{get_metadata_inner, occurrence_error}; + +mod keyword { + use syn::custom_keyword; + + // Schema metadata + custom_keyword!(value_type); + custom_keyword!(min_length); + custom_keyword!(max_length); + custom_keyword!(example); +} + +pub enum SchemaParameterVariant { + ValueType { + keyword: keyword::value_type, + value: TypePath, + }, + MinLength { + keyword: keyword::min_length, + value: LitInt, + }, + MaxLength { + keyword: keyword::max_length, + value: LitInt, + }, + Example { + keyword: keyword::example, + value: LitStr, + }, +} + +impl Parse for SchemaParameterVariant { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let lookahead = input.lookahead1(); + if lookahead.peek(keyword::value_type) { + let keyword = input.parse()?; + input.parse::()?; + let value = input.parse()?; + Ok(Self::ValueType { keyword, value }) + } else if lookahead.peek(keyword::min_length) { + let keyword = input.parse()?; + input.parse::()?; + let value = input.parse()?; + Ok(Self::MinLength { keyword, value }) + } else if lookahead.peek(keyword::max_length) { + let keyword = input.parse()?; + input.parse::()?; + let value = input.parse()?; + Ok(Self::MaxLength { keyword, value }) + } else if lookahead.peek(keyword::example) { + let keyword = input.parse()?; + input.parse::()?; + let value = input.parse()?; + Ok(Self::Example { keyword, value }) + } else { + Err(lookahead.error()) + } + } +} + +impl ToTokens for SchemaParameterVariant { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::ValueType { keyword, .. } => keyword.to_tokens(tokens), + Self::MinLength { keyword, .. } => keyword.to_tokens(tokens), + Self::MaxLength { keyword, .. } => keyword.to_tokens(tokens), + Self::Example { keyword, .. } => keyword.to_tokens(tokens), + } + } +} + +pub trait FieldExt { + /// Get all the schema metadata associated with a field. + fn get_schema_metadata(&self) -> syn::Result>; +} + +impl FieldExt for Field { + fn get_schema_metadata(&self) -> syn::Result> { + get_metadata_inner("schema", &self.attrs) + } +} + +#[derive(Clone, Debug, Default)] +pub struct SchemaParameters { + pub value_type: Option, + pub min_length: Option, + pub max_length: Option, + pub example: Option, +} + +pub trait HasSchemaParameters { + fn get_schema_parameters(&self) -> syn::Result; +} + +impl HasSchemaParameters for Field { + fn get_schema_parameters(&self) -> syn::Result { + let mut output = SchemaParameters::default(); + + let mut value_type_keyword = None; + let mut min_length_keyword = None; + let mut max_length_keyword = None; + let mut example_keyword = None; + + for meta in self.get_schema_metadata()? { + match meta { + SchemaParameterVariant::ValueType { keyword, value } => { + if let Some(first_keyword) = value_type_keyword { + return Err(occurrence_error(first_keyword, keyword, "value_type")); + } + + value_type_keyword = Some(keyword); + output.value_type = Some(value); + } + SchemaParameterVariant::MinLength { keyword, value } => { + if let Some(first_keyword) = min_length_keyword { + return Err(occurrence_error(first_keyword, keyword, "min_length")); + } + + min_length_keyword = Some(keyword); + let min_length = value.base10_parse::()?; + output.min_length = Some(min_length); + } + SchemaParameterVariant::MaxLength { keyword, value } => { + if let Some(first_keyword) = max_length_keyword { + return Err(occurrence_error(first_keyword, keyword, "max_length")); + } + + max_length_keyword = Some(keyword); + let max_length = value.base10_parse::()?; + output.max_length = Some(max_length); + } + SchemaParameterVariant::Example { keyword, value } => { + if let Some(first_keyword) = example_keyword { + return Err(occurrence_error(first_keyword, keyword, "example")); + } + + example_keyword = Some(keyword); + output.example = Some(value.value()); + } + } + } + + Ok(output) + } +} + +/// Check if the field is applicable for running validations +#[derive(PartialEq)] +pub enum IsSchemaFieldApplicableForValidation { + /// Not applicable for running validation checks + Invalid, + /// Applicable for running validation checks + Valid, + /// Applicable for validation but field is optional - this is needed for generating validation code only if the value of the field is present + ValidOptional, +} + +/// From implementation for checking if the field type is applicable for running schema validations +impl From<&syn::Type> for IsSchemaFieldApplicableForValidation { + fn from(ty: &syn::Type) -> Self { + if let syn::Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + let ident = &segment.ident; + if ident == "String" || ident == "Url" { + return Self::Valid; + } + + if ident == "Option" { + if let syn::PathArguments::AngleBracketed(generic_args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(syn::Type::Path(inner_path))) = + generic_args.args.first() + { + if let Some(inner_segment) = inner_path.path.segments.last() { + if inner_segment.ident == "String" || inner_segment.ident == "Url" { + return Self::ValidOptional; + } + } + } + } + } + } + } + Self::Invalid + } +} diff --git a/cypress-tests/cypress/e2e/configs/Payment/Commons.js b/cypress-tests/cypress/e2e/configs/Payment/Commons.js index 33285c8258..6d64f40f19 100644 --- a/cypress-tests/cypress/e2e/configs/Payment/Commons.js +++ b/cypress-tests/cypress/e2e/configs/Payment/Commons.js @@ -1611,4 +1611,53 @@ export const connectorDetails = { }, }, }, + return_url_variations: { + "return_url_too_long": getCustomExchange({ + Request: { + customer_id: "customer_1234567890", + return_url: "http://example.com/" + "a".repeat(237), + }, + Response: { + status: 400, + body: { + error: { + message: "return_url must be at most 255 characters long. Received 256 characters", + code: "IR_06", + type: "invalid_request" + }, + }, + }, + }), + "return_url_invalid_format": getCustomExchange({ + Request: { + return_url: "not_a_valid_url", + }, + Response: { + status: 400, + body: { + error: { + message: "Json deserialize error: relative URL without a base: \"not_a_valid_url\" at line 1 column 357", + code: "IR_06", + error_type: "invalid_request" + }, + }, + }, + }), + }, + mandate_id_too_long: getCustomExchange({ + Request: { + mandate_id: "mnd_" + "a".repeat(63), + off_session: true, + }, + Response: { + status: 400, + body: { + error: { + message: "mandate_id must be at most 64 characters long. Received 67 characters", + code: "IR_06", + type: "invalid_request" + }, + }, + }, + }), }; diff --git a/cypress-tests/cypress/e2e/spec/Payment/00022-Variations.cy.js b/cypress-tests/cypress/e2e/spec/Payment/00022-Variations.cy.js index 173443a591..8d9dd20df4 100644 --- a/cypress-tests/cypress/e2e/spec/Payment/00022-Variations.cy.js +++ b/cypress-tests/cypress/e2e/spec/Payment/00022-Variations.cy.js @@ -151,6 +151,39 @@ describe("Corner cases", () => { globalState ); }); + + it("[Payment] return_url - too long", () => { + const data = getConnectorDetails(globalState.get("connectorId"))["return_url_variations"]["return_url_too_long"]; + cy.createConfirmPaymentTest( + paymentCreateConfirmBody, + data, + "no_three_ds", + "automatic", + globalState + ); + }); + + it("[Payment] return_url - invalid format", () => { + const data = getConnectorDetails(globalState.get("connectorId"))["return_url_variations"]["return_url_invalid_format"]; + cy.createConfirmPaymentTest( + paymentCreateConfirmBody, + data, + "no_three_ds", + "automatic", + globalState + ); + }); + + it("[Payment] mandate_id - too long", () => { + const data = getConnectorDetails(globalState.get("connectorId"))["mandate_id_too_long"]; + cy.createConfirmPaymentTest( + paymentCreateConfirmBody, + data, + "no_three_ds", + "automatic", + globalState + ); + }); }); context("[Payment] Confirm w/o PMD", () => {