feat: add API operation metadata contract
This commit is contained in:
Generated
+42
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<Self> {
|
||||
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<LitStr>,
|
||||
status: Option<LitInt>,
|
||||
error_status: Option<LitInt>,
|
||||
}
|
||||
|
||||
impl Parse for RouteArgs {
|
||||
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
|
||||
let path = input.parse()?;
|
||||
let mut result = Self {
|
||||
path,
|
||||
operation_id: None,
|
||||
status: None,
|
||||
error_status: None,
|
||||
};
|
||||
|
||||
while !input.is_empty() {
|
||||
input.parse::<Token![,]>()?;
|
||||
if input.is_empty() {
|
||||
break;
|
||||
}
|
||||
let key: Ident = input.parse()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
match key.to_string().as_str() {
|
||||
"operation_id" => set_once(
|
||||
&mut result.operation_id,
|
||||
input.parse::<LitStr>()?,
|
||||
&key,
|
||||
"operation_id",
|
||||
)?,
|
||||
"status" => set_once(&mut result.status, input.parse::<LitInt>()?, &key, "status")?,
|
||||
"error_status" => set_once(
|
||||
&mut result.error_status,
|
||||
input.parse::<LitInt>()?,
|
||||
&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<T>(slot: &mut Option<T>, 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<Parameter>,
|
||||
request_body: Option<Type>,
|
||||
response_body: Option<Type>,
|
||||
error_body: Option<Type>,
|
||||
response_status: u16,
|
||||
error_status: Option<u16>,
|
||||
}
|
||||
|
||||
struct ApiDefinition {
|
||||
item: ItemTrait,
|
||||
metadata_ident: Ident,
|
||||
operations_module_ident: Ident,
|
||||
operations: Vec<Operation>,
|
||||
}
|
||||
|
||||
fn normalize_api(
|
||||
attr: proc_macro2::TokenStream,
|
||||
mut item: ItemTrait,
|
||||
) -> syn::Result<ApiDefinition> {
|
||||
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::<String, Span>::new();
|
||||
let mut routes = BTreeMap::<(Method, String), Span>::new();
|
||||
let mut marker_names = BTreeMap::<String, (String, Span)>::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<Operation> {
|
||||
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::<RouteArgs>()?;
|
||||
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<Attribute>) -> syn::Result<Option<(Location, Option<String>)>> {
|
||||
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::<LitStr>()?.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<BTreeSet<String>> {
|
||||
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<Option<u16>> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let status = value.base10_parse::<u16>()?;
|
||||
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<Type>, Option<Type>)> {
|
||||
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<Type> {
|
||||
(!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<proc_macro2::TokenStream> {
|
||||
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<proc_macro2::TokenStream> {
|
||||
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::<Ident>(&candidate) {
|
||||
marker.set_span(method.span());
|
||||
return marker;
|
||||
}
|
||||
|
||||
let encoded = ident_text(method)
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<Widget, PublicError>;
|
||||
//!
|
||||
//! #[get("/widgets/{widget_id}", operation_id = "widgets.get")]
|
||||
//! async fn get(&self, widget_id: u64) -> Result<Widget, PublicError>;
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! 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<T, E>` 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<utf8-hex>` 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<ResponseMetadata>,
|
||||
}
|
||||
|
||||
/// 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 {}
|
||||
@@ -0,0 +1,5 @@
|
||||
#[test]
|
||||
fn invalid_api_declarations_have_stable_diagnostics() {
|
||||
let tests = trybuild::TestCases::new();
|
||||
tests.compile_fail("tests/ui/*.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<T>(std::marker::PhantomData<T>);
|
||||
|
||||
#[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<Widget, PublicError>;
|
||||
|
||||
#[get("/widgets/{widget_id}", operation_id = "widgets.get")]
|
||||
async fn lookup(&self, widget_id: u64, #[query] query: Lookup) -> Result<Widget, PublicError>;
|
||||
|
||||
#[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<Widget>;
|
||||
}
|
||||
|
||||
#[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<O>()
|
||||
where
|
||||
O: Operation<
|
||||
Parameters = (CreateWidget, String),
|
||||
RequestBody = CreateWidget,
|
||||
ResponseBody = Widget,
|
||||
ErrorBody = PublicError,
|
||||
>,
|
||||
{
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_exposes_deterministic_metadata_and_type_connections() {
|
||||
assert_operation_types::<widget_api_operations::Create>();
|
||||
|
||||
fn assert_named_wrapper<O: Operation<ResponseBody = SearchResult<Widget>>>() {}
|
||||
assert_named_wrapper::<wrapper_api_operations::Search>();
|
||||
let raw = <raw_identifier_api_operations::Type as Operation>::METADATA;
|
||||
assert_eq!(raw.operation_id, "raw.type");
|
||||
let underscores = <unusual_identifier_api_operations::Operation5f5f as Operation>::METADATA;
|
||||
assert_eq!(underscores.operation_id, "unusual.underscores");
|
||||
let underscore_digit =
|
||||
<unusual_identifier_api_operations::Operation5f30 as Operation>::METADATA;
|
||||
assert_eq!(underscore_digit.operation_id, "unusual.underscore_digit");
|
||||
|
||||
let operations = <WidgetApiMetadata as ApiContract>::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 = <widget_api_operations::Create as Operation>::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 = <widget_api_operations::Lookup as Operation>::METADATA;
|
||||
assert_eq!(lookup.parameters[0].location, ParameterLocation::Path);
|
||||
assert_eq!(lookup.parameters[1].location, ParameterLocation::Query);
|
||||
|
||||
let delete = <widget_api_operations::Delete as Operation>::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<O: Operation<RequestBody = NoBody>>() {}
|
||||
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
|
||||
assert_empty_request::<widget_api_operations::Lookup>();
|
||||
assert_empty_response::<widget_api_operations::Delete>();
|
||||
}
|
||||
@@ -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 {
|
||||
<RenamedApiMetadata as ApiContract>::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),
|
||||
);
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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;
|
||||
| ^^^^^^^^^^
|
||||
@@ -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() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: duplicate operation ID `duplicate`
|
||||
--> tests/ui/duplicate_operation_id.rs:11:14
|
||||
|
|
||||
11 | async fn two(&self) -> Response;
|
||||
| ^^^
|
||||
@@ -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() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: duplicate route for GET `/widgets`
|
||||
--> tests/ui/duplicate_route.rs:11:14
|
||||
|
|
||||
11 | async fn second(&self) -> Response;
|
||||
| ^^^^^^
|
||||
@@ -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<Response, PublicError>;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -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)]
|
||||
| ^^^
|
||||
@@ -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() {}
|
||||
@@ -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;
|
||||
| ^^^^
|
||||
@@ -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() {}
|
||||
@@ -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)]
|
||||
| ^^^
|
||||
@@ -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() {}
|
||||
@@ -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;
|
||||
| ^^^^^^^
|
||||
@@ -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() {}
|
||||
@@ -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,
|
||||
| ^^^^^^
|
||||
@@ -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() {}
|
||||
@@ -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}")]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
@@ -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() {}
|
||||
@@ -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;
|
||||
| ^^^^^^^^^
|
||||
Reference in New Issue
Block a user