diff --git a/Cargo.lock b/Cargo.lock index b429be87b..aeed48d99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,24 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "api-macros" +version = "0.1.0" +dependencies = [ + "api-macros-impl", + "trybuild", +] + +[[package]] +name = "api-macros-impl" +version = "0.1.0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -3238,6 +3256,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4827,6 +4854,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -5883,6 +5922,9 @@ name = "winnow" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] [[package]] name = "wiremock" diff --git a/Cargo.toml b/Cargo.toml index 4b922b316..c7bd87a64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "crates/client", "crates/agen", "crates/agen-macros", + "crates/api-macros", + "crates/api-macros-impl", "crates/session-store", "crates/standalone", "crates/secrets", @@ -36,6 +38,8 @@ default-members = [ "crates/client", "crates/agen", "crates/agen-macros", + "crates/api-macros", + "crates/api-macros-impl", "crates/session-store", "crates/standalone", "crates/secrets", @@ -79,6 +83,7 @@ debug = false client = { path = "crates/client" } agen = { path = "crates/agen", version = "0.2" } agen-macros = { path = "crates/agen-macros", version = "0.2" } +api-macros = { path = "crates/api-macros" } manifest = { path = "crates/manifest" } mcp = { path = "crates/mcp" } lint-common = { path = "crates/lint-common" } diff --git a/crates/api-macros-impl/Cargo.toml b/crates/api-macros-impl/Cargo.toml new file mode 100644 index 000000000..52c228754 --- /dev/null +++ b/crates/api-macros-impl/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "api-macros-impl" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro-crate = "3" +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs new file mode 100644 index 000000000..66cd9ab0c --- /dev/null +++ b/crates/api-macros-impl/src/lib.rs @@ -0,0 +1,972 @@ +//! Implementation of `api-macros`. + +use std::collections::{BTreeMap, BTreeSet}; + +use proc_macro::TokenStream; +use proc_macro_crate::{FoundCrate, crate_name}; +use proc_macro2::Span; +use quote::{format_ident, quote}; +use syn::{ + Attribute, FnArg, GenericArgument, Ident, ItemTrait, LitInt, LitStr, Pat, PathArguments, + ReturnType, Token, TraitItem, TraitItemFn, Type, Visibility, + parse::{Parse, ParseStream}, + parse_macro_input, + spanned::Spanned, +}; + +#[proc_macro_attribute] +pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream { + let attr = proc_macro2::TokenStream::from(attr); + let item = parse_macro_input!(item as ItemTrait); + + normalize_api(attr, item) + .and_then(expand_api) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Method { + Get, + Post, + Put, + Patch, + Delete, + Head, + Options, +} + +impl Method { + fn from_ident(ident: &Ident) -> Option { + match ident.to_string().as_str() { + "get" => Some(Self::Get), + "post" => Some(Self::Post), + "put" => Some(Self::Put), + "patch" => Some(Self::Patch), + "delete" => Some(Self::Delete), + "head" => Some(Self::Head), + "options" => Some(Self::Options), + _ => None, + } + } + + fn tokens(self, api_crate: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { + let variant = match self { + Self::Get => quote!(Get), + Self::Post => quote!(Post), + Self::Put => quote!(Put), + Self::Patch => quote!(Patch), + Self::Delete => quote!(Delete), + Self::Head => quote!(Head), + Self::Options => quote!(Options), + }; + quote!(#api_crate::HttpMethod::#variant) + } + + fn permits_request_body(self) -> bool { + matches!(self, Self::Post | Self::Put | Self::Patch | Self::Delete) + } +} + +struct RouteArgs { + path: LitStr, + operation_id: Option, + status: Option, + error_status: Option, +} + +impl Parse for RouteArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let path = input.parse()?; + let mut result = Self { + path, + operation_id: None, + status: None, + error_status: None, + }; + + while !input.is_empty() { + input.parse::()?; + if input.is_empty() { + break; + } + let key: Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "operation_id" => set_once( + &mut result.operation_id, + input.parse::()?, + &key, + "operation_id", + )?, + "status" => set_once(&mut result.status, input.parse::()?, &key, "status")?, + "error_status" => set_once( + &mut result.error_status, + input.parse::()?, + &key, + "error_status", + )?, + _ => { + return Err(syn::Error::new( + key.span(), + "unsupported route option; expected operation_id, status, or error_status", + )); + } + } + } + + Ok(result) + } +} + +fn set_once(slot: &mut Option, value: T, key: &Ident, display: &str) -> syn::Result<()> { + if slot.replace(value).is_some() { + return Err(syn::Error::new( + key.span(), + format!("duplicate `{display}` route option"), + )); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum Location { + Path, + Query, + Header, + Body, +} + +impl Location { + fn tokens(self, api_crate: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { + let variant = match self { + Self::Path => quote!(Path), + Self::Query => quote!(Query), + Self::Header => quote!(Header), + Self::Body => quote!(Body), + }; + quote!(#api_crate::ParameterLocation::#variant) + } +} + +struct Parameter { + rust_name: String, + wire_name: String, + location: Location, + ty: Type, +} + +struct Operation { + method_ident: Ident, + marker_ident: Ident, + operation_id: String, + method: Method, + path: String, + parameters: Vec, + request_body: Option, + response_body: Option, + error_body: Option, + response_status: u16, + error_status: Option, +} + +struct ApiDefinition { + item: ItemTrait, + metadata_ident: Ident, + operations_module_ident: Ident, + operations: Vec, +} + +fn normalize_api( + attr: proc_macro2::TokenStream, + mut item: ItemTrait, +) -> syn::Result { + if !attr.is_empty() { + return Err(syn::Error::new( + attr.span(), + "api does not accept arguments", + )); + } + if !matches!(item.vis, Visibility::Public(_)) { + return Err(syn::Error::new_spanned( + &item.ident, + "an #[api] trait must be public", + )); + } + if !item.generics.params.is_empty() || item.generics.where_clause.is_some() { + return Err(syn::Error::new_spanned( + &item.generics, + "an #[api] trait cannot be generic", + )); + } + + let trait_ident = item.ident.clone(); + let metadata_ident = format_ident!("{}Metadata", ident_text(&trait_ident)); + let operations_module_ident = format_ident!("{}_operations", to_snake_case(&trait_ident)); + let mut operations = Vec::new(); + let mut operation_ids = BTreeMap::::new(); + let mut routes = BTreeMap::<(Method, String), Span>::new(); + let mut marker_names = BTreeMap::::new(); + + for trait_item in &mut item.items { + let TraitItem::Fn(method) = trait_item else { + return Err(syn::Error::new_spanned( + trait_item, + "an #[api] trait may contain methods only", + )); + }; + let operation = normalize_operation(method)?; + + let marker_name = operation.marker_ident.to_string(); + if let Some((first_method, first_span)) = marker_names.insert( + marker_name.clone(), + (ident_text(&method.sig.ident), method.sig.ident.span()), + ) { + let mut error = syn::Error::new( + method.sig.ident.span(), + format!( + "generated operation marker `{marker_name}` collides with method `{first_method}`" + ), + ); + error.combine(syn::Error::new( + first_span, + format!("first method generating marker `{marker_name}`"), + )); + return Err(error); + } + + if operation_ids + .insert(operation.operation_id.clone(), method.sig.ident.span()) + .is_some() + { + return Err(syn::Error::new( + method.sig.ident.span(), + format!("duplicate operation ID `{}`", operation.operation_id), + )); + } + if routes + .insert( + (operation.method, operation.path.clone()), + method.sig.ident.span(), + ) + .is_some() + { + return Err(syn::Error::new( + method.sig.ident.span(), + format!( + "duplicate route for {} `{}`", + method_name(operation.method), + operation.path + ), + )); + } + operations.push(operation); + } + + if operations.is_empty() { + return Err(syn::Error::new_spanned( + &item.ident, + "an #[api] trait must declare at least one operation", + )); + } + + operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id)); + + Ok(ApiDefinition { + item, + metadata_ident, + operations_module_ident, + operations, + }) +} + +fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { + validate_signature(method)?; + + let mut route = None; + let mut retained_attrs = Vec::new(); + for attr in std::mem::take(&mut method.attrs) { + let maybe_method = attr.path().get_ident().and_then(Method::from_ident); + if let Some(http_method) = maybe_method { + if route.is_some() { + return Err(syn::Error::new_spanned( + attr, + "an API method must have exactly one HTTP method attribute", + )); + } + let args = attr.parse_args::()?; + route = Some((http_method, args)); + } else { + retained_attrs.push(attr); + } + } + method.attrs = retained_attrs; + + let (http_method, route) = route.ok_or_else(|| { + syn::Error::new_spanned( + &method.sig.ident, + "an API method requires one of #[get], #[post], #[put], #[patch], #[delete], #[head], or #[options]", + ) + })?; + + let path = route.path.value(); + let placeholders = parse_path_template(&route.path)?; + let operation_id = route + .operation_id + .as_ref() + .map(LitStr::value) + .unwrap_or_else(|| ident_text(&method.sig.ident)); + validate_operation_id( + &operation_id, + route.operation_id.as_ref().unwrap_or(&route.path), + )?; + + let (response_body, error_body) = parse_return_type(&method.sig.output)?; + if let Some(ty) = response_body.as_ref() { + validate_named_body_type(ty, "response")?; + } + if let Some(ty) = error_body.as_ref() { + validate_named_body_type(ty, "error response")?; + } + + let response_status = parse_status(route.status.as_ref(), "status")? + .unwrap_or(if response_body.is_some() { 200 } else { 204 }); + if !(200..=299).contains(&response_status) { + return Err(syn::Error::new( + route + .status + .as_ref() + .map_or(route.path.span(), Spanned::span), + "status must be a success status between 200 and 299", + )); + } + let error_status = parse_status(route.error_status.as_ref(), "error_status")?; + if error_status.is_some() && error_body.is_none() { + return Err(syn::Error::new_spanned( + route.error_status, + "error_status requires a public error response type", + )); + } + if let Some(status) = error_status + && status < 400 + { + return Err(syn::Error::new_spanned( + route.error_status, + "error_status must be between 400 and 599", + )); + } + let error_status = error_body.as_ref().map(|_| error_status.unwrap_or(400)); + + if http_method == Method::Head && response_body.is_some() { + return Err(syn::Error::new_spanned( + &method.sig.output, + "HEAD operations cannot declare a response body", + )); + } + if matches!(response_status, 204 | 205) && response_body.is_some() { + return Err(syn::Error::new_spanned( + &method.sig.output, + format!("status {response_status} cannot have a response body"), + )); + } + + let mut parameters = Vec::new(); + let mut body_type = None; + let mut path_parameters = BTreeSet::new(); + for input in &mut method.sig.inputs { + let FnArg::Typed(input) = input else { + continue; + }; + let ident = argument_ident(&input.pat)?; + let rust_name = ident_text(ident); + let explicit = take_location(&mut input.attrs)?; + let (location, wire_name) = match explicit { + Some((Location::Path, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[path] does not accept a wire name", + )); + } + (Location::Path, rust_name.clone()) + } + Some((Location::Query, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[query] does not accept a wire name", + )); + } + (Location::Query, rust_name.clone()) + } + Some((Location::Header, wire_name)) => ( + Location::Header, + wire_name.unwrap_or_else(|| rust_name.clone()), + ), + Some((Location::Body, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[body] does not accept a wire name", + )); + } + (Location::Body, rust_name.clone()) + } + None if placeholders.contains(&rust_name) => (Location::Path, rust_name.clone()), + None => { + return Err(syn::Error::new( + ident.span(), + "API arguments must use #[path], #[query], #[header], or #[body]; path arguments may omit #[path] when their name matches a placeholder", + )); + } + }; + + if matches!(location, Location::Path) { + path_parameters.insert(rust_name.clone()); + } + if matches!(location, Location::Body) { + if body_type.is_some() { + return Err(syn::Error::new( + ident.span(), + "an API operation may declare only one request body", + )); + } + validate_named_body_type(&input.ty, "request")?; + body_type = Some((*input.ty).clone()); + } + parameters.push(Parameter { + rust_name, + wire_name, + location, + ty: (*input.ty).clone(), + }); + } + + let missing: Vec<_> = placeholders.difference(&path_parameters).cloned().collect(); + if !missing.is_empty() { + return Err(syn::Error::new( + route.path.span(), + format!( + "path placeholder(s) have no matching path parameter: {}", + missing.join(", ") + ), + )); + } + let extra: Vec<_> = path_parameters.difference(&placeholders).cloned().collect(); + if !extra.is_empty() { + return Err(syn::Error::new( + route.path.span(), + format!( + "path parameter(s) have no matching placeholder: {}", + extra.join(", ") + ), + )); + } + if body_type.is_some() && !http_method.permits_request_body() { + return Err(syn::Error::new_spanned( + &method.sig.ident, + format!( + "{} operations cannot declare a request body", + method_name(http_method) + ), + )); + } + + Ok(Operation { + marker_ident: operation_marker_ident(&method.sig.ident), + method_ident: method.sig.ident.clone(), + operation_id, + method: http_method, + path, + parameters, + request_body: body_type, + response_body, + error_body, + response_status, + error_status, + }) +} + +fn validate_signature(method: &TraitItemFn) -> syn::Result<()> { + let sig = &method.sig; + if sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + &sig.fn_token, + "API methods must be async", + )); + } + if sig.unsafety.is_some() + || sig.abi.is_some() + || sig.variadic.is_some() + || !sig.generics.params.is_empty() + || sig.generics.where_clause.is_some() + { + return Err(syn::Error::new_spanned( + sig, + "API methods cannot be unsafe, extern, variadic, or generic", + )); + } + if method.default.is_some() { + return Err(syn::Error::new_spanned( + method, + "API methods cannot provide a default implementation", + )); + } + match sig.inputs.first() { + Some(FnArg::Receiver(receiver)) + if receiver.reference.is_some() && receiver.mutability.is_none() => {} + _ => { + return Err(syn::Error::new_spanned( + &sig.inputs, + "API methods must take `&self` as their first argument", + )); + } + } + Ok(()) +} + +fn take_location(attrs: &mut Vec) -> syn::Result)>> { + let mut result = None; + let mut retained = Vec::new(); + for attr in std::mem::take(attrs) { + let location = match attr.path().get_ident().map(ToString::to_string).as_deref() { + Some("path") => Some(Location::Path), + Some("query") => Some(Location::Query), + Some("header") => Some(Location::Header), + Some("body") => Some(Location::Body), + Some("stream") | Some("multipart") | Some("binary") => { + return Err(syn::Error::new_spanned( + attr, + "this wire kind is explicitly unsupported by the initial API contract", + )); + } + _ => None, + }; + let Some(location) = location else { + retained.push(attr); + continue; + }; + if result.is_some() { + return Err(syn::Error::new_spanned( + attr, + "an API argument may have only one location attribute", + )); + } + let wire_name = if matches!(location, Location::Header) { + if matches!(attr.meta, syn::Meta::Path(_)) { + None + } else { + Some(attr.parse_args::()?.value()) + } + } else if matches!(attr.meta, syn::Meta::Path(_)) { + None + } else { + return Err(syn::Error::new_spanned( + attr, + "only #[header(\"wire-name\")] accepts an argument", + )); + }; + result = Some((location, wire_name)); + } + *attrs = retained; + Ok(result) +} + +fn parse_path_template(path: &LitStr) -> syn::Result> { + let value = path.value(); + if !value.starts_with('/') { + return Err(syn::Error::new( + path.span(), + "API paths must start with `/`", + )); + } + if value.contains('?') || value.contains('#') { + return Err(syn::Error::new( + path.span(), + "API path templates cannot contain a query string or fragment", + )); + } + + let mut placeholders = BTreeSet::new(); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'{' => { + let rest = &value[index + 1..]; + let Some(end) = rest.find('}') else { + return Err(syn::Error::new(path.span(), "unclosed path placeholder")); + }; + let name = &rest[..end]; + if name.is_empty() + || !name.chars().enumerate().all(|(index, ch)| { + ch == '_' + || ch.is_ascii_alphanumeric() && (index > 0 || !ch.is_ascii_digit()) + }) + { + return Err(syn::Error::new( + path.span(), + "path placeholders must be valid Rust identifiers", + )); + } + if !placeholders.insert(name.to_owned()) { + return Err(syn::Error::new( + path.span(), + format!("duplicate path placeholder `{name}`"), + )); + } + index += end + 2; + } + b'}' => { + return Err(syn::Error::new( + path.span(), + "path template contains an unmatched `}`", + )); + } + _ => index += 1, + } + } + Ok(placeholders) +} + +fn validate_operation_id(operation_id: &str, source: &impl Spanned) -> syn::Result<()> { + if operation_id.is_empty() + || !operation_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ':' | '/')) + { + return Err(syn::Error::new( + source.span(), + "operation_id must be non-empty and contain only ASCII letters, digits, `.`, `-`, `_`, `:`, or `/`", + )); + } + Ok(()) +} + +fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result> { + let Some(value) = value else { + return Ok(None); + }; + let status = value.base10_parse::()?; + if !(100..=599).contains(&status) { + return Err(syn::Error::new( + value.span(), + format!("{name} must be an HTTP status between 100 and 599"), + )); + } + Ok(Some(status)) +} + +fn parse_return_type(output: &ReturnType) -> syn::Result<(Option, Option)> { + let ReturnType::Type(_, ty) = output else { + return Ok((None, None)); + }; + if is_unit(ty) { + return Ok((None, None)); + } + + if let Type::Path(type_path) = ty.as_ref() + && let Some(last) = type_path.path.segments.last() + && (last.ident == "Result" || last.ident == "ApiResult") + && let PathArguments::AngleBracketed(arguments) = &last.arguments + { + let types: Vec<_> = arguments + .args + .iter() + .filter_map(|argument| match argument { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) + .collect(); + return match types.as_slice() { + [response] => Ok((body_type(response), None)), + [response, error] => Ok((body_type(response), body_type(error))), + _ => Err(syn::Error::new_spanned( + arguments, + "API result types must have one response type and at most one error type", + )), + }; + } + + Ok((Some((**ty).clone()), None)) +} + +fn body_type(ty: &Type) -> Option { + (!is_unit(ty)).then(|| ty.clone()) +} + +fn is_unit(ty: &Type) -> bool { + matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) +} + +fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> { + let Type::Path(path) = ty else { + return Err(syn::Error::new_spanned( + ty, + format!("{kind} bodies must use a named Rust type"), + )); + }; + if path.qself.is_some() { + return Err(syn::Error::new_spanned( + ty, + format!("{kind} bodies must use a direct named Rust type"), + )); + } + let last = path.path.segments.last().expect("type path has a segment"); + let builtin = matches!( + last.ident.to_string().as_str(), + "bool" + | "char" + | "str" + | "String" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "Vec" + | "Option" + | "Box" + ); + if builtin { + return Err(syn::Error::new_spanned( + ty, + format!("{kind} bodies must use an explicit named DTO type"), + )); + } + Ok(()) +} + +fn argument_ident(pat: &Pat) -> syn::Result<&Ident> { + let Pat::Ident(ident) = pat else { + return Err(syn::Error::new_spanned( + pat, + "API arguments must use simple identifier patterns", + )); + }; + Ok(&ident.ident) +} + +fn api_crate_path() -> syn::Result { + match crate_name("api-macros") { + Ok(FoundCrate::Itself) => Ok(quote!(::api_macros)), + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, Span::call_site()); + Ok(quote!(::#ident)) + } + Err(error) => Err(syn::Error::new( + Span::call_site(), + format!("could not resolve the api-macros support crate: {error}"), + )), + } +} + +fn expand_api(api: ApiDefinition) -> syn::Result { + let api_crate = api_crate_path()?; + let item = api.item; + let metadata_ident = api.metadata_ident; + let module_ident = api.operations_module_ident; + + let marker_items: Vec<_> = api + .operations + .iter() + .map(|operation| { + let marker_ident = &operation.marker_ident; + let method_ident = &operation.method_ident; + quote! { + #[doc = concat!("Typed operation marker for Rust method `", stringify!(#method_ident), "`.")] + #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] + pub struct #marker_ident; + } + }) + .collect(); + let operation_impls: Vec<_> = api + .operations + .iter() + .map(|operation| { + let marker_ident = &operation.marker_ident; + let metadata = metadata_tokens(operation, &api_crate); + let parameter_types = operation.parameters.iter().map(|parameter| ¶meter.ty); + let request_type = operation + .request_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + let response_type = operation + .response_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + let error_type = operation + .error_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + quote! { + impl #api_crate::Operation for #module_ident::#marker_ident { + type Parameters = (#(#parameter_types,)*); + type RequestBody = #request_type; + type ResponseBody = #response_type; + type ErrorBody = #error_type; + + const METADATA: #api_crate::OperationMetadata = #metadata; + } + } + }) + .collect(); + let inventory: Vec<_> = api + .operations + .iter() + .map(|operation| metadata_tokens(operation, &api_crate)) + .collect(); + + Ok(quote! { + #item + + #[doc = "Deterministic operation inventory for the API trait."] + #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] + pub struct #metadata_ident; + + impl #api_crate::ApiContract for #metadata_ident { + const OPERATIONS: &'static [#api_crate::OperationMetadata] = &[ + #(#inventory),* + ]; + } + + #[doc = "Typed markers generated for this API trait's operations."] + pub mod #module_ident { + #(#marker_items)* + } + + #(#operation_impls)* + }) +} + +fn metadata_tokens( + operation: &Operation, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let operation_id = &operation.operation_id; + let method = operation.method.tokens(api_crate); + let path = &operation.path; + let request_kind = if operation.request_body.is_some() { + quote!(#api_crate::WireKind::Json) + } else { + quote!(#api_crate::WireKind::Empty) + }; + let response_kind = if operation.response_body.is_some() { + quote!(#api_crate::WireKind::Json) + } else { + quote!(#api_crate::WireKind::Empty) + }; + let response_status = operation.response_status; + let error = match operation.error_status { + Some(status) => quote! { + ::core::option::Option::Some(#api_crate::ResponseMetadata { + status: #status, + body: #api_crate::BodyMetadata { + wire_kind: #api_crate::WireKind::Json, + }, + }) + }, + None => quote!(::core::option::Option::None), + }; + let parameters = operation.parameters.iter().map(|parameter| { + let rust_name = ¶meter.rust_name; + let wire_name = ¶meter.wire_name; + let location = parameter.location.tokens(api_crate); + quote! { + #api_crate::ParameterMetadata { + rust_name: #rust_name, + wire_name: #wire_name, + location: #location, + } + } + }); + + quote! { + #api_crate::OperationMetadata { + operation_id: #operation_id, + method: #method, + path: #path, + parameters: &[#(#parameters),*], + request_body: #api_crate::BodyMetadata { wire_kind: #request_kind }, + response: #api_crate::ResponseMetadata { + status: #response_status, + body: #api_crate::BodyMetadata { wire_kind: #response_kind }, + }, + error_response: #error, + } + } +} + +fn method_name(method: Method) -> &'static str { + match method { + Method::Get => "GET", + Method::Post => "POST", + Method::Put => "PUT", + Method::Patch => "PATCH", + Method::Delete => "DELETE", + Method::Head => "HEAD", + Method::Options => "OPTIONS", + } +} + +fn ident_text(ident: &Ident) -> String { + let value = ident.to_string(); + value.strip_prefix("r#").unwrap_or(&value).to_owned() +} + +fn operation_marker_ident(method: &Ident) -> Ident { + let candidate = to_pascal_case(method); + if let Ok(mut marker) = syn::parse_str::(&candidate) { + marker.set_span(method.span()); + return marker; + } + + let encoded = ident_text(method) + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ident::new(&format!("Operation{encoded}"), method.span()) +} + +fn to_pascal_case(ident: &Ident) -> String { + ident_text(ident) + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect() +} + +fn to_snake_case(ident: &Ident) -> String { + let value = ident_text(ident); + let mut result = String::new(); + for (index, ch) in value.chars().enumerate() { + if ch.is_ascii_uppercase() { + if index > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } + result +} diff --git a/crates/api-macros/Cargo.toml b/crates/api-macros/Cargo.toml new file mode 100644 index 000000000..73515abad --- /dev/null +++ b/crates/api-macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "api-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +api-macros-impl = { path = "../api-macros-impl" } + +[dev-dependencies] +trybuild = "1" diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs new file mode 100644 index 000000000..006dd6e4b --- /dev/null +++ b/crates/api-macros/src/lib.rs @@ -0,0 +1,152 @@ +//! Shared API trait declarations for Yoi services. +//! +//! `#[api]` turns a public trait into a single, transport-neutral API definition. The +//! original trait remains usable by Rust code; generated metadata and zero-sized operation +//! markers give adapters and generators a stable view of the same declaration. +//! +//! # Declaration syntax +//! +//! ``` +//! # mod example { +//! use api_macros::api; +//! +//! pub struct CreateWidget; +//! pub struct Widget; +//! pub struct PublicError; +//! +//! #[api] +//! pub trait WidgetApi { +//! #[post( +//! "/widgets", +//! operation_id = "widgets.create", +//! status = 201, +//! error_status = 422 +//! )] +//! async fn create( +//! &self, +//! #[body] request: CreateWidget, +//! #[header("x-request-id")] request_id: String, +//! ) -> Result; +//! +//! #[get("/widgets/{widget_id}", operation_id = "widgets.get")] +//! async fn get(&self, widget_id: u64) -> Result; +//! } +//! # } +//! ``` +//! +//! Supported route attributes are `get`, `post`, `put`, `patch`, `delete`, `head`, and +//! `options`. The first argument is a path template. `operation_id` defaults to the Rust +//! method name but should be set explicitly for contracts which must remain stable while Rust +//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to +//! `204`. A public error inferred from `Result` defaults to status `400`. +//! +//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, or `#[path]`. An +//! unannotated argument whose Rust name occurs in the route template is inferred as a path +//! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`. +//! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named +//! Rust types; tuples, references, arrays, and other anonymous structural types are rejected. +//! +//! # Generated names +//! +//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`], +//! plus a `widget_api_operations` module. That module contains one marker per Rust method +//! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete +//! body types at compile time. Unusual method names which have no valid PascalCase form use the +//! deterministic `Operation` fallback. The `ApiContract::OPERATIONS` inventory is +//! sorted by operation ID, so its ordering is independent of source method order. +//! +//! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves +//! explicit variants for future transport work; accepting one requires a deliberate macro and +//! adapter change rather than silently treating it as JSON. + +extern crate self as api_macros; + +pub use api_macros_impl::api; + +/// A transport-neutral HTTP method. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, + Head, + Options, +} + +/// Where an operation argument is encoded on the wire. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum ParameterLocation { + Path, + Query, + Header, + Body, +} + +/// Body encodings understood or explicitly reserved by the API contract. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum WireKind { + Empty, + Json, + Streaming, + Multipart, + Binary, +} + +/// Stable metadata for one Rust method argument. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ParameterMetadata { + pub rust_name: &'static str, + pub wire_name: &'static str, + pub location: ParameterLocation, +} + +/// Stable metadata for a request or response body. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BodyMetadata { + pub wire_kind: WireKind, +} + +/// Stable metadata for one HTTP response. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ResponseMetadata { + pub status: u16, + pub body: BodyMetadata, +} + +/// Complete transport metadata for one operation. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct OperationMetadata { + pub operation_id: &'static str, + pub method: HttpMethod, + pub path: &'static str, + pub parameters: &'static [ParameterMetadata], + pub request_body: BodyMetadata, + pub response: ResponseMetadata, + pub error_response: Option, +} + +/// Deterministic operation inventory emitted for an `#[api]` trait. +pub trait ApiContract { + const OPERATIONS: &'static [OperationMetadata]; +} + +/// Compile-time connection between operation metadata and its concrete body types. +pub trait Operation { + /// Tuple of all non-receiver parameter types in declaration order. + type Parameters; + /// JSON request body type, or [`NoBody`]. + type RequestBody; + /// JSON success response body type, or [`NoBody`]. + type ResponseBody; + /// JSON public error body type, or [`NoBody`]. + type ErrorBody; + + const METADATA: OperationMetadata; +} + +/// Marker used when an operation has no request, response, or public error body. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum NoBody {} diff --git a/crates/api-macros/tests/compile_fail.rs b/crates/api-macros/tests/compile_fail.rs new file mode 100644 index 000000000..5858355df --- /dev/null +++ b/crates/api-macros/tests/compile_fail.rs @@ -0,0 +1,5 @@ +#[test] +fn invalid_api_declarations_have_stable_diagnostics() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/ui/*.rs"); +} diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs new file mode 100644 index 000000000..a52ae4a78 --- /dev/null +++ b/crates/api-macros/tests/expansion.rs @@ -0,0 +1,109 @@ +#![allow(async_fn_in_trait)] + +use api_macros::{ApiContract, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api}; + +pub struct CreateWidget; +pub struct Widget; +pub struct Lookup; +pub struct PublicError; +pub struct SearchResult(std::marker::PhantomData); + +#[api] +pub trait WidgetApi { + #[post( + "/widgets", + operation_id = "widgets.create", + status = 201, + error_status = 422 + )] + async fn create( + &self, + #[body] request: CreateWidget, + #[header("x-request-id")] request_id: String, + ) -> Result; + + #[get("/widgets/{widget_id}", operation_id = "widgets.get")] + async fn lookup(&self, widget_id: u64, #[query] query: Lookup) -> Result; + + #[delete("/widgets/{widget_id}", operation_id = "widgets.delete")] + async fn delete(&self, #[path] widget_id: u64) -> (); +} + +#[api] +pub trait WrapperApi { + #[get("/search", operation_id = "search")] + async fn search(&self) -> SearchResult; +} + +#[api] +pub trait RawIdentifierApi { + #[get("/type", operation_id = "raw.type")] + async fn r#type(&self) -> Widget; +} + +#[api] +pub trait UnusualIdentifierApi { + #[get("/underscores", operation_id = "unusual.underscores")] + async fn __(&self) -> Widget; + + #[get("/underscore-digit", operation_id = "unusual.underscore_digit")] + async fn _0(&self) -> Widget; +} + +fn assert_operation_types() +where + O: Operation< + Parameters = (CreateWidget, String), + RequestBody = CreateWidget, + ResponseBody = Widget, + ErrorBody = PublicError, + >, +{ +} + +#[test] +fn expansion_exposes_deterministic_metadata_and_type_connections() { + assert_operation_types::(); + + fn assert_named_wrapper>>() {} + assert_named_wrapper::(); + let raw = ::METADATA; + assert_eq!(raw.operation_id, "raw.type"); + let underscores = ::METADATA; + assert_eq!(underscores.operation_id, "unusual.underscores"); + let underscore_digit = + ::METADATA; + assert_eq!(underscore_digit.operation_id, "unusual.underscore_digit"); + + let operations = ::OPERATIONS; + assert_eq!(operations.len(), 3); + assert_eq!(operations[0].operation_id, "widgets.create"); + assert_eq!(operations[1].operation_id, "widgets.delete"); + assert_eq!(operations[2].operation_id, "widgets.get"); + + let create = ::METADATA; + assert_eq!(operations[0], create); + assert_eq!(create.method, HttpMethod::Post); + assert_eq!(create.path, "/widgets"); + assert_eq!(create.request_body.wire_kind, WireKind::Json); + assert_eq!(create.response.status, 201); + assert_eq!(create.response.body.wire_kind, WireKind::Json); + assert_eq!(create.error_response.unwrap().status, 422); + assert_eq!(create.parameters[0].location, ParameterLocation::Body); + assert_eq!(create.parameters[1].location, ParameterLocation::Header); + assert_eq!(create.parameters[1].wire_name, "x-request-id"); + + let lookup = ::METADATA; + assert_eq!(lookup.parameters[0].location, ParameterLocation::Path); + assert_eq!(lookup.parameters[1].location, ParameterLocation::Query); + + let delete = ::METADATA; + assert_eq!(delete.response.status, 204); + assert_eq!(delete.response.body.wire_kind, WireKind::Empty); + assert_eq!(delete.error_response, None); + + fn assert_empty_request>() {} + fn assert_empty_response>() {} + assert_empty_request::(); + assert_empty_response::(); +} diff --git a/crates/api-macros/tests/renamed_dependency.rs b/crates/api-macros/tests/renamed_dependency.rs new file mode 100644 index 000000000..6c0c5c9bd --- /dev/null +++ b/crates/api-macros/tests/renamed_dependency.rs @@ -0,0 +1,66 @@ +use std::{fs, path::PathBuf, process::Command}; + +#[test] +fn macro_expansion_resolves_a_renamed_support_dependency() { + let fixture = std::env::temp_dir().join(format!( + "api-macros-renamed-dependency-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&fixture); + fs::create_dir_all(fixture.join("src")).expect("create fixture source directory"); + + let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let package_path = package_path + .to_str() + .expect("package path is UTF-8") + .replace('\\', "\\\\"); + fs::write( + fixture.join("Cargo.toml"), + format!( + r#"[package] +name = "renamed-api-consumer" +version = "0.0.0" +edition = "2024" + +[workspace] + +[dependencies] +yoi-api = {{ package = "api-macros", path = "{package_path}" }} +"#, + ), + ) + .expect("write fixture manifest"); + fs::write( + fixture.join("src/lib.rs"), + r#"use yoi_api::{ApiContract, api}; + +pub struct Output; + +#[api] +pub trait RenamedApi { + #[get("/output", operation_id = "renamed.output")] + async fn output(&self) -> Output; +} + +pub fn operation_count() -> usize { + ::OPERATIONS.len() +} +"#, + ) + .expect("write fixture source"); + + let output = Command::new(env!("CARGO")) + .args(["check", "--quiet", "--offline"]) + .current_dir(&fixture) + .env("CARGO_TARGET_DIR", fixture.join("target")) + .output() + .expect("run cargo check for renamed dependency fixture"); + let _ = fs::remove_dir_all(&fixture); + + assert!( + output.status.success(), + "renamed dependency fixture failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/api-macros/tests/ui/anonymous_body.rs b/crates/api-macros/tests/ui/anonymous_body.rs new file mode 100644 index 000000000..708ec9434 --- /dev/null +++ b/crates/api-macros/tests/ui/anonymous_body.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait AnonymousBody { + #[post("/widgets")] + async fn create(&self, #[body] request: (u64, u64)) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/anonymous_body.stderr b/crates/api-macros/tests/ui/anonymous_body.stderr new file mode 100644 index 000000000..251ba1e12 --- /dev/null +++ b/crates/api-macros/tests/ui/anonymous_body.stderr @@ -0,0 +1,5 @@ +error: request bodies must use a named Rust type + --> tests/ui/anonymous_body.rs:8:45 + | +8 | async fn create(&self, #[body] request: (u64, u64)) -> Response; + | ^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/duplicate_operation_id.rs b/crates/api-macros/tests/ui/duplicate_operation_id.rs new file mode 100644 index 000000000..e75349269 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_operation_id.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait DuplicateOperationId { + #[get("/one", operation_id = "duplicate")] + async fn one(&self) -> Response; + + #[get("/two", operation_id = "duplicate")] + async fn two(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_operation_id.stderr b/crates/api-macros/tests/ui/duplicate_operation_id.stderr new file mode 100644 index 000000000..59bbd9096 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_operation_id.stderr @@ -0,0 +1,5 @@ +error: duplicate operation ID `duplicate` + --> tests/ui/duplicate_operation_id.rs:11:14 + | +11 | async fn two(&self) -> Response; + | ^^^ diff --git a/crates/api-macros/tests/ui/duplicate_route.rs b/crates/api-macros/tests/ui/duplicate_route.rs new file mode 100644 index 000000000..86290c8dd --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_route.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait DuplicateRoute { + #[get("/widgets", operation_id = "widgets.first")] + async fn first(&self) -> Response; + + #[get("/widgets", operation_id = "widgets.second")] + async fn second(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_route.stderr b/crates/api-macros/tests/ui/duplicate_route.stderr new file mode 100644 index 000000000..8f4999be7 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_route.stderr @@ -0,0 +1,5 @@ +error: duplicate route for GET `/widgets` + --> tests/ui/duplicate_route.rs:11:14 + | +11 | async fn second(&self) -> Response; + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_error_status.rs b/crates/api-macros/tests/ui/invalid_error_status.rs new file mode 100644 index 000000000..16e14af8e --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_error_status.rs @@ -0,0 +1,12 @@ +use api_macros::api; + +pub struct Response; +pub struct PublicError; + +#[api] +pub trait InvalidErrorStatus { + #[get("/widgets", error_status = 200)] + async fn list(&self) -> Result; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_error_status.stderr b/crates/api-macros/tests/ui/invalid_error_status.stderr new file mode 100644 index 000000000..efaaf8604 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_error_status.stderr @@ -0,0 +1,5 @@ +error: error_status must be between 400 and 599 + --> tests/ui/invalid_error_status.rs:8:38 + | +8 | #[get("/widgets", error_status = 200)] + | ^^^ diff --git a/crates/api-macros/tests/ui/invalid_method_body.rs b/crates/api-macros/tests/ui/invalid_method_body.rs new file mode 100644 index 000000000..0c8321b76 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_method_body.rs @@ -0,0 +1,12 @@ +use api_macros::api; + +pub struct Request; +pub struct Response; + +#[api] +pub trait InvalidMethodBody { + #[get("/widgets")] + async fn list(&self, #[body] request: Request) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_method_body.stderr b/crates/api-macros/tests/ui/invalid_method_body.stderr new file mode 100644 index 000000000..e4d47b676 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_method_body.stderr @@ -0,0 +1,5 @@ +error: GET operations cannot declare a request body + --> tests/ui/invalid_method_body.rs:9:14 + | +9 | async fn list(&self, #[body] request: Request) -> Response; + | ^^^^ diff --git a/crates/api-macros/tests/ui/invalid_success_status.rs b/crates/api-macros/tests/ui/invalid_success_status.rs new file mode 100644 index 000000000..bf80274cf --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_success_status.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait InvalidSuccessStatus { + #[get("/widgets", status = 404)] + async fn list(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_success_status.stderr b/crates/api-macros/tests/ui/invalid_success_status.stderr new file mode 100644 index 000000000..14e030505 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_success_status.stderr @@ -0,0 +1,5 @@ +error: status must be a success status between 200 and 299 + --> tests/ui/invalid_success_status.rs:7:32 + | +7 | #[get("/widgets", status = 404)] + | ^^^ diff --git a/crates/api-macros/tests/ui/marker_collision.rs b/crates/api-macros/tests/ui/marker_collision.rs new file mode 100644 index 000000000..b6a188f25 --- /dev/null +++ b/crates/api-macros/tests/ui/marker_collision.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait MarkerCollision { + #[get("/first")] + async fn foo_bar(&self) -> Response; + + #[get("/second")] + async fn foo__bar(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/marker_collision.stderr b/crates/api-macros/tests/ui/marker_collision.stderr new file mode 100644 index 000000000..4286e6991 --- /dev/null +++ b/crates/api-macros/tests/ui/marker_collision.stderr @@ -0,0 +1,11 @@ +error: generated operation marker `FooBar` collides with method `foo_bar` + --> tests/ui/marker_collision.rs:11:14 + | +11 | async fn foo__bar(&self) -> Response; + | ^^^^^^^^ + +error: first method generating marker `FooBar` + --> tests/ui/marker_collision.rs:8:14 + | +8 | async fn foo_bar(&self) -> Response; + | ^^^^^^^ diff --git a/crates/api-macros/tests/ui/multiple_bodies.rs b/crates/api-macros/tests/ui/multiple_bodies.rs new file mode 100644 index 000000000..1c8b139c6 --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_bodies.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct FirstBody; +pub struct SecondBody; +pub struct Response; + +#[api] +pub trait MultipleBodies { + #[post("/widgets")] + async fn create( + &self, + #[body] first: FirstBody, + #[body] second: SecondBody, + ) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/multiple_bodies.stderr b/crates/api-macros/tests/ui/multiple_bodies.stderr new file mode 100644 index 000000000..1b259838b --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_bodies.stderr @@ -0,0 +1,5 @@ +error: an API operation may declare only one request body + --> tests/ui/multiple_bodies.rs:13:17 + | +13 | #[body] second: SecondBody, + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/path_mismatch.rs b/crates/api-macros/tests/ui/path_mismatch.rs new file mode 100644 index 000000000..bec7543ae --- /dev/null +++ b/crates/api-macros/tests/ui/path_mismatch.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait PathMismatch { + #[get("/widgets/{widget_id}")] + async fn get(&self, #[path] other_id: u64) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/path_mismatch.stderr b/crates/api-macros/tests/ui/path_mismatch.stderr new file mode 100644 index 000000000..c7c55cf66 --- /dev/null +++ b/crates/api-macros/tests/ui/path_mismatch.stderr @@ -0,0 +1,5 @@ +error: path placeholder(s) have no matching path parameter: widget_id + --> tests/ui/path_mismatch.rs:7:11 + | +7 | #[get("/widgets/{widget_id}")] + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/private_body.rs b/crates/api-macros/tests/ui/private_body.rs new file mode 100644 index 000000000..50ee62629 --- /dev/null +++ b/crates/api-macros/tests/ui/private_body.rs @@ -0,0 +1,14 @@ +#![allow(async_fn_in_trait, private_interfaces)] + +use api_macros::api; + +struct PrivateRequest; +pub struct Response; + +#[api] +pub trait PrivateBody { + #[post("/widgets")] + async fn create(&self, #[body] request: PrivateRequest) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/private_body.stderr b/crates/api-macros/tests/ui/private_body.stderr new file mode 100644 index 000000000..c77408cd3 --- /dev/null +++ b/crates/api-macros/tests/ui/private_body.stderr @@ -0,0 +1,10 @@ +error[E0446]: private type `PrivateRequest` in public interface + --> tests/ui/private_body.rs:8:1 + | +5 | struct PrivateRequest; + | --------------------- `PrivateRequest` declared as private +... +8 | #[api] + | ^^^^^^ can't leak private type + | + = note: this error originates in the attribute macro `api` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/api-macros/tests/ui/unsupported_wire_kind.rs b/crates/api-macros/tests/ui/unsupported_wire_kind.rs new file mode 100644 index 000000000..bb7f064d3 --- /dev/null +++ b/crates/api-macros/tests/ui/unsupported_wire_kind.rs @@ -0,0 +1,12 @@ +use api_macros::api; + +pub struct StreamRequest; +pub struct Response; + +#[api] +pub trait UnsupportedWireKind { + #[post("/widgets")] + async fn create(&self, #[stream] request: StreamRequest) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/unsupported_wire_kind.stderr b/crates/api-macros/tests/ui/unsupported_wire_kind.stderr new file mode 100644 index 000000000..20da89534 --- /dev/null +++ b/crates/api-macros/tests/ui/unsupported_wire_kind.stderr @@ -0,0 +1,5 @@ +error: this wire kind is explicitly unsupported by the initial API contract + --> tests/ui/unsupported_wire_kind.rs:9:28 + | +9 | async fn create(&self, #[stream] request: StreamRequest) -> Response; + | ^^^^^^^^^