mod attributes;
mod shape;
use attributes::{
constraints, field_name, is_described, is_flattened, is_required, is_skipped, variant_name,
};
use shape::{enum_body, struct_body};
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{
Data, DataEnum, DeriveInput, Field, Fields, Lit, LitFloat, LitInt, LitStr, Type, Variant,
parse_macro_input, punctuated::Punctuated, spanned::Spanned, token::Comma,
};
use crate::derive::common::{doc_string, is_deprecated, skip_value};
const NUMERIC: &[&str] = &[
"minimum",
"maximum",
"exclusive_minimum",
"exclusive_maximum",
"multiple_of",
];
const COUNTS: &[&str] = &["min_length", "max_length", "min_items", "max_items"];
pub(crate) fn expand(item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as DeriveInput);
match expand_inner(&input) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
pub(super) fn expand_inner(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
if let Data::Union(data) = &input.data {
return Err(syn::Error::new(
data.union_token.span(),
"`Schema` cannot describe a union: no JSON value corresponds to one",
));
}
reject_untagged(input)?;
check_constraints(input)?;
let name = &input.ident;
let generics = schema_bounded_generics(input);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let component = LitStr::new(&name.to_string(), name.span());
let named = if input.generics.type_params().next().is_some() {
quote!(::core::option::Option::None)
} else {
quote!(::kynos::openapi::ComponentName::sanitized(#component).ok())
};
let container = Container::read(input);
let body = body(input, &container);
Ok(quote! {
#[allow(deprecated)]
impl #impl_generics ::kynos::schema::Schema for #name #ty_generics #where_clause {
fn schema(
registry: &mut ::kynos::schema::registry::Registry,
) -> ::kynos::openapi::Schema {
#body
}
fn name() -> ::core::option::Option<::kynos::openapi::ComponentName> {
#named
}
}
})
}
fn schema_bounded_generics(input: &DeriveInput) -> syn::Generics {
let mut generics = input.generics.clone();
let parameters: Vec<syn::Ident> = generics
.type_params()
.map(|parameter| parameter.ident.clone())
.collect();
if parameters.is_empty() {
return generics;
}
let clause = generics.make_where_clause();
for parameter in parameters {
clause
.predicates
.push(syn::parse_quote!(#parameter: ::kynos::schema::Schema));
}
generics
}
fn check_constraints(input: &DeriveInput) -> syn::Result<()> {
let fields = match &input.data {
Data::Struct(data) => vec![&data.fields],
Data::Enum(data) => data
.variants
.iter()
.map(|variant| &variant.fields)
.collect(),
Data::Union(_) => return Ok(()),
};
for group in fields {
let named = match group {
Fields::Named(named) => &named.named,
Fields::Unnamed(unnamed) => &unnamed.unnamed,
Fields::Unit => continue,
};
for field in named {
for attr in &field.attrs {
if attr.path().is_ident("schema") {
attr.parse_nested_meta(|meta| check_constraint(&meta))?;
}
}
}
}
Ok(())
}
fn check_constraint(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
let Some(key) = meta.path.get_ident() else {
return Ok(());
};
let name = key.to_string();
if name == "format" {
return Err(syn::Error::new(
key.span(),
"`format` says what a value *is*, which follows from its type rather than from the \
field carrying it. Use a type that already claims the format -- `uuid::Uuid` behind \
the `uuid` feature, a date or time type behind `time-chrono` or `time-jiff`, a \
decimal behind `decimal-rust` or `decimal-big` -- or give the value a newtype with \
its own `Schema` implementation. `pattern` is here if what you meant is a \
constraint on this field rather than a claim about the type",
));
}
if name == "unique_items" {
return if meta.input.peek(syn::Token![=]) {
Err(syn::Error::new(
key.span(),
"`unique_items` is a flag; write it alone, or leave it out",
))
} else {
Ok(())
};
}
if NUMERIC.contains(&name.as_str()) {
return match meta.value()?.parse()? {
Lit::Int(_) | Lit::Float(_) => Ok(()),
other => Err(syn::Error::new(
other.span(),
format!("`{name}` takes a number"),
)),
};
}
if COUNTS.contains(&name.as_str()) {
let literal = meta.value()?.parse()?;
return match &literal {
Lit::Int(value) => value.base10_parse::<u64>().map(|_| ()),
other => Err(syn::Error::new(
other.span(),
format!("`{name}` takes a non-negative whole number"),
)),
};
}
if name == "pattern" {
return meta.value()?.parse::<LitStr>().map(|_| ());
}
Err(syn::Error::new(
key.span(),
format!(
"`{name}` is not part of the `#[schema(...)]` grammar, which is the keys of \
`kynos::schema::constraints::Constraints`: `minimum`, `maximum`, \
`exclusive_minimum`, `exclusive_maximum`, `multiple_of`, `min_length`, \
`max_length`, `pattern`, `min_items`, `max_items` and `unique_items`"
),
))
}
fn reject_untagged(input: &DeriveInput) -> syn::Result<()> {
if !matches!(input.data, syn::Data::Enum(_)) {
return Ok(());
}
for attr in &input.attrs {
if !attr.path().is_ident("serde") {
continue;
}
let mut found = None;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("untagged") {
found = Some(meta.path.span());
} else {
skip_value(&meta)?;
}
Ok(())
});
if let Some(span) = found {
return Err(syn::Error::new(
span,
"an untagged enum has no describable decoding rule: `anyOf` without a \
discriminator is ambiguous, and serde's first-match tie-break cannot be \
expressed. Use `#[serde(tag = \"...\")]`, which becomes a `discriminator`",
));
}
}
Ok(())
}
#[derive(Default)]
struct Container {
rename_all: Option<String>,
tag: Option<String>,
content: Option<String>,
doc: Option<String>,
}
impl Container {
fn read(input: &DeriveInput) -> Self {
let mut container = Self {
doc: doc_string(&input.attrs),
..Self::default()
};
for attr in &input.attrs {
if !attr.path().is_ident("serde") {
continue;
}
let _ = attr.parse_nested_meta(|meta| {
let Some(key) = meta.path.get_ident() else {
return skip_value(&meta);
};
match key.to_string().as_str() {
"rename_all" => container.rename_all = string_value(&meta)?,
"tag" => container.tag = string_value(&meta)?,
"content" => container.content = string_value(&meta)?,
_ => skip_value(&meta)?,
}
Ok(())
});
}
container
}
}
fn string_value(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<Option<String>> {
if !meta.input.peek(syn::Token![=]) {
return Ok(None);
}
Ok(Some(meta.value()?.parse::<LitStr>()?.value()))
}
fn body(input: &DeriveInput, container: &Container) -> TokenStream2 {
let described = match &input.data {
Data::Struct(data) => described(
struct_body(&data.fields, container),
container.doc.as_deref(),
),
Data::Enum(data) => described(enum_body(data, container), container.doc.as_deref()),
Data::Union(_) => quote!(::kynos::openapi::Schema::default()),
};
deprecate(described, is_deprecated(&input.attrs))
}
fn deprecate(schema: TokenStream2, deprecated: bool) -> TokenStream2 {
if !deprecated {
return schema;
}
quote! {
{
let mut deprecated = #schema;
if let ::kynos::openapi::Schema::Object(keywords) = &mut deprecated {
keywords.deprecated = ::core::option::Option::Some(true);
}
deprecated
}
}
}
fn described(schema: TokenStream2, doc: Option<&str>) -> TokenStream2 {
let Some(doc) = doc else {
return schema;
};
quote! {
{
let mut described = #schema;
if let ::kynos::openapi::Schema::Object(keywords) = &mut described {
keywords.description =
::core::option::Option::Some(::std::string::String::from(#doc));
}
described
}
}
}
pub(super) fn property_names(input: &DeriveInput, fields: &syn::FieldsNamed) -> Vec<String> {
let container = Container::read(input);
fields
.named
.iter()
.map(|field| field_name(field, &container))
.collect()
}