feat(core): add a procedural macro for validating schema attributes for a struct (#8006)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Kashif
2025-05-20 21:27:01 +05:30
committed by GitHub
parent b9009c50fd
commit 4332299aef
12 changed files with 518 additions and 9 deletions

View File

@ -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<proc_macro2::TokenStream> {
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::<Vec<_>>();
Ok(quote! {
impl #name {
pub fn validate(&self) -> Result<(), String> {
#(#validation_checks)*
Ok(())
}
}
})
}

View File

@ -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<Self> {
let lookahead = input.lookahead1();
if lookahead.peek(keyword::value_type) {
let keyword = input.parse()?;
input.parse::<Token![=]>()?;
let value = input.parse()?;
Ok(Self::ValueType { keyword, value })
} else if lookahead.peek(keyword::min_length) {
let keyword = input.parse()?;
input.parse::<Token![=]>()?;
let value = input.parse()?;
Ok(Self::MinLength { keyword, value })
} else if lookahead.peek(keyword::max_length) {
let keyword = input.parse()?;
input.parse::<Token![=]>()?;
let value = input.parse()?;
Ok(Self::MaxLength { keyword, value })
} else if lookahead.peek(keyword::example) {
let keyword = input.parse()?;
input.parse::<Token![=]>()?;
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<Vec<SchemaParameterVariant>>;
}
impl FieldExt for Field {
fn get_schema_metadata(&self) -> syn::Result<Vec<SchemaParameterVariant>> {
get_metadata_inner("schema", &self.attrs)
}
}
#[derive(Clone, Debug, Default)]
pub struct SchemaParameters {
pub value_type: Option<TypePath>,
pub min_length: Option<usize>,
pub max_length: Option<usize>,
pub example: Option<String>,
}
pub trait HasSchemaParameters {
fn get_schema_parameters(&self) -> syn::Result<SchemaParameters>;
}
impl HasSchemaParameters for Field {
fn get_schema_parameters(&self) -> syn::Result<SchemaParameters> {
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::<usize>()?;
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::<usize>()?;
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
}
}