server-less-macros 0.6.0

Proc macros for server-less
Documentation
//! Smithy IDL schema generation macro.
//!
//! Generates Smithy interface definition language schemas.
//! Smithy is AWS's open-source IDL for defining APIs and services.
//!
//! # Schema Generation
//!
//! Creates Smithy IDL from Rust code:
//! - Methods → Operations
//! - Parameters → Input structures
//! - Return types → Output structures
//! - Service definition with operations
//!
//! # Type Mapping
//!
//! - `String` → String
//! - `i32` → Integer
//! - `bool` → Boolean
//! - `Vec<T>` → List member: T
//! - `Option<T>` → Optional member
//!
//! # Generated Methods
//!
//! - `smithy_schema() -> &'static str` - Generated Smithy schema
//!
//! # Example
//!
//! ```ignore
//! use server_less::smithy;
//!
//! struct WeatherService;
//!
//! #[smithy(namespace = "com.example.weather")]
//! impl WeatherService {
//!     fn get_forecast(&self, city: String) -> String {
//!         format!("Forecast for {}", city)
//!     }
//! }
//!
//! let schema = WeatherService::smithy_schema();
//! ```

use crate::app::extract_app_meta;
use crate::context::partition_context_params;
use crate::server_attrs::{has_server_hidden, has_server_skip, validate_server_attrs};
use heck::{ToPascalCase, ToSnakeCase};

use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use server_less_parse::{
    MethodInfo, ParamInfo, extract_methods, get_impl_name, unwrap_option_type, unwrap_result_ok_type,
    unwrap_vec_type,
};
use syn::{ItemImpl, Token, parse::Parse};

/// Arguments for the #[smithy] attribute
#[derive(Default)]
pub(crate) struct SmithyArgs {
    /// Namespace for the service
    namespace: Option<String>,
    /// Service version
    version: Option<String>,
}

impl Parse for SmithyArgs {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        let mut args = SmithyArgs::default();

        while !input.is_empty() {
            let ident: syn::Ident = input.parse()?;
            input.parse::<Token![=]>()?;

            match ident.to_string().as_str() {
                "namespace" => {
                    let lit: syn::LitStr = input.parse()?;
                    args.namespace = Some(lit.value());
                }
                "version" => {
                    let lit: syn::LitStr = input.parse()?;
                    args.version = Some(lit.value());
                }
                other => {
                    const VALID: &[&str] = &["namespace", "version"];
                    let suggestion = crate::did_you_mean(other, VALID)
                        .map(|s| format!(" — did you mean `{s}`?"))
                        .unwrap_or_default();
                    return Err(syn::Error::new(
                        ident.span(),
                        format!(
                            "unknown argument `{other}`{suggestion}. Valid arguments: namespace, version"
                        ),
                    ));
                }
            }

            if input.peek(Token![,]) {
                input.parse::<Token![,]>()?;
            }
        }

        Ok(args)
    }
}

pub(crate) fn expand_smithy(args: SmithyArgs, mut impl_block: ItemImpl) -> syn::Result<TokenStream2> {
    crate::reject_generic_impl(&impl_block)?;
    let app_meta = extract_app_meta(&mut impl_block.attrs);
    let struct_name = get_impl_name(&impl_block)?;
    let (impl_generics, _ty_generics, where_clause) = impl_block.generics.split_for_impl();
    let self_ty = &impl_block.self_ty;
    let struct_name_str = struct_name.to_string();
    let all_methods = extract_methods(&impl_block)?;
    for m in &all_methods {
        validate_server_attrs(m)?;
    }
    let methods: Vec<_> = all_methods
        .into_iter()
        .filter(|m| !has_server_skip(m) && !has_server_hidden(m))
        .collect();

    let namespace = args
        .namespace
        .or_else(|| {
            app_meta
                .name
                .as_ref()
                .map(|n| format!("com.example.{}", n.to_snake_case()))
        })
        .unwrap_or_else(|| format!("com.example.{}", struct_name_str.to_snake_case()));
    let version = args
        .version
        .or_else(|| app_meta.version.into_explicit())
        .unwrap_or_else(|| "2024-01-01".to_string());

    // Check for schema attribute to enable validation
    let schema_path = impl_block.attrs.iter().find_map(|attr| {
        if attr.path().is_ident("schema")
            && let syn::Meta::NameValue(nv) = &attr.meta
            && let syn::Expr::Lit(syn::ExprLit {
                lit: syn::Lit::Str(s),
                ..
            }) = &nv.value
        {
            return Some(s.value());
        }
        None
    });

    // Generate operation definitions
    let operations: Vec<String> = methods.iter().map(generate_operation).collect();

    // Generate structure definitions
    let structures: Vec<String> = methods.iter().flat_map(generate_structures).collect();

    // Generate the Smithy IDL
    let smithy_schema = format!(
        r#"$version: "2"

namespace {namespace}

/// {service_name} service
service {service_name} {{
    version: "{version}"
    operations: [
{operation_list}
    ]
}}

{operations}

{structures}
"#,
        namespace = namespace,
        service_name = struct_name_str,
        version = version,
        operation_list = methods
            .iter()
            .map(|m| format!("        {}", m.name_str().to_pascal_case()))
            .collect::<Vec<_>>()
            .join("\n"),
        operations = operations.join("\n\n"),
        structures = structures.join("\n\n")
    );

    // Generate validation method if schema path provided
    let validation_method = if let Some(path) = schema_path {
        quote! {
            /// Validate that the generated schema matches the expected schema file.
            ///
            /// Returns Ok(()) if schemas match, Err with details if they differ.
            ///
            /// # Limitation: field-presence only, not field order
            ///
            /// This check verifies line-by-line presence in both directions.  It does **not**
            /// verify shape or member ordering.  Smithy is largely order-insensitive in
            /// semantics, but tool generators may be sensitive to member ordering in structures.
            /// Users are responsible for maintaining member ordering stability across schema
            /// versions if their code generators require it.
            pub fn validate_schema() -> ::std::result::Result<(), ::server_less::SchemaValidationError> {
                let expected = include_str!(#path);
                let generated = Self::smithy_schema();

                // Normalize for comparison (trim, split lines, filter empty)
                let expected_lines: ::std::collections::HashSet<String> = expected
                    .lines()
                    .map(|l| l.trim().to_string())
                    .filter(|l| !l.is_empty())
                    .collect();

                let generated_lines: ::std::collections::HashSet<String> = generated
                    .lines()
                    .map(|l| l.trim().to_string())
                    .filter(|l| !l.is_empty())
                    .collect();

                let mut error = ::server_less::SchemaValidationError::new("Smithy");

                for line in &expected_lines {
                    if !generated_lines.contains(line) {
                        error.add_missing(line.clone());
                    }
                }

                for line in &generated_lines {
                    if !expected_lines.contains(line) {
                        error.add_extra(line.clone());
                    }
                }

                if error.has_differences() {
                    Err(error)
                } else {
                    Ok(())
                }
            }

            /// Assert that the schema matches.
            ///
            /// Panics with detailed diff if schemas don't match.
            /// Use `validate_schema()` for programmatic error handling.
            pub fn assert_schema_matches() {
                if let Err(err) = Self::validate_schema() {
                    panic!("{}", err);
                }
            }
        }
    } else {
        quote! {}
    };

    let maybe_impl = if crate::is_protocol_impl_emitter(&impl_block, "smithy") {
        quote! { #impl_block }
    } else {
        quote! {}
    };

    Ok(quote! {
        #maybe_impl

        impl #impl_generics #self_ty #where_clause {
            /// Get the Smithy IDL schema for this service.
            pub fn smithy_schema() -> &'static str {
                #smithy_schema
            }

            /// Write the Smithy schema to a file.
            pub fn write_smithy(path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
                std::fs::write(path, Self::smithy_schema())
            }

            #validation_method
        }
    })
}

/// Generate a Smithy operation definition
fn generate_operation(method: &MethodInfo) -> String {
    let op_name = method.name_str().to_pascal_case();
    let input_name = format!("{}Input", op_name);
    let output_name = format!("{}Output", op_name);

    let doc = method
        .docs
        .as_ref()
        .map(|d| format!("/// {}\n", d))
        .unwrap_or_default();

    format!(
        r#"{doc}operation {op_name} {{
    input: {input_name}
    output: {output_name}
}}"#,
        doc = doc,
        op_name = op_name,
        input_name = input_name,
        output_name = output_name
    )
}

/// Generate Smithy structure definitions for a method
fn generate_structures(method: &MethodInfo) -> Vec<String> {
    let op_name = method.name_str().to_pascal_case();
    let input_name = format!("{}Input", op_name);
    let output_name = format!("{}Output", op_name);

    // Filter out server_less::Context params — they are runtime-injected, not schema fields.
    let (_, schema_params) = partition_context_params(&method.params).unwrap_or((None, method.params.iter().collect()));
    // Generate input structure
    let input_fields: Vec<String> = schema_params.iter().map(|p| generate_field(p)).collect();

    let input_struct = if input_fields.is_empty() {
        format!("structure {} {{}}", input_name)
    } else {
        format!(
            "structure {} {{\n{}\n}}",
            input_name,
            input_fields.join("\n")
        )
    };

    // Generate output structure
    let ret = &method.return_info;
    let output_struct = if ret.is_unit {
        format!("structure {} {{}}", output_name)
    } else {
        let smithy_type = rust_type_to_smithy(&ret.ty);
        format!(
            "structure {} {{\n    @required\n    result: {}\n}}",
            output_name, smithy_type
        )
    };

    vec![input_struct, output_struct]
}

/// Generate a Smithy field definition
fn generate_field(param: &ParamInfo) -> String {
    let name = param.name_str().to_snake_case();
    // Unwrap Option<T> — the field is implicitly optional in Smithy when @required is absent
    let ty = if let Some(inner) = unwrap_option_type(&param.ty) {
        inner.clone()
    } else {
        param.ty.clone()
    };
    let smithy_type = rust_type_to_smithy(&Some(ty));
    let required = if param.is_optional {
        ""
    } else {
        "@required\n    "
    };
    format!("    {required}{name}: {smithy_type}")
}

/// Convert Rust type to Smithy type
fn rust_type_to_smithy(ty: &Option<syn::Type>) -> String {
    let Some(ty) = ty else {
        return "Unit".to_string();
    };
    rust_type_to_smithy_ty(ty)
}

/// Convert a `syn::Type` reference to a Smithy type string.
fn rust_type_to_smithy_ty(ty: &syn::Type) -> String {
    // Unwrap Result<T, E> → T
    if let Some(ok) = unwrap_result_ok_type(ty) {
        return rust_type_to_smithy_ty(ok);
    }
    // Unwrap Option<T> → map inner (optional is expressed by omitting @required)
    if let Some(inner) = unwrap_option_type(ty) {
        return rust_type_to_smithy_ty(inner);
    }
    // Vec<T> requires a named List shape in Smithy IDL. Bare `List` is not
    // valid — a proper model needs a separate `list FooList { member: T }`
    // shape definition. For now we emit `StringList` as a placeholder for
    // Vec<String> / Vec<u8>, which covers the most common case. Complex
    // element types require manual Smithy model authoring.
    if unwrap_vec_type(ty).is_some() {
        return "StringList".to_string();
    }
    // Use exact path-segment matching to avoid false positives on user-defined wrapper types
    // (e.g. `MyI32Wrapper` must not match `i32`, `MyString` must not match `String`).
    let ident = if let syn::Type::Path(tp) = ty {
        tp.path.segments.last().map(|s| s.ident.to_string())
    } else {
        None
    };
    match ident.as_deref() {
        Some("String") | Some("str") => "String".to_string(),
        Some("i8") | Some("u8") => "Byte".to_string(),
        Some("i16") | Some("u16") => "Short".to_string(),
        Some("i32") | Some("u32") => "Integer".to_string(),
        Some("i64") | Some("u64") => "Long".to_string(),
        Some("f32") => "Float".to_string(),
        Some("f64") => "Double".to_string(),
        Some("bool") => "Boolean".to_string(),
        _ => "Document".to_string(),
    }
}