diff --git a/examples/hello_world/Cargo.lock b/examples/hello_world/Cargo.lock index 24c8c17..4e0fb1d 100644 --- a/examples/hello_world/Cargo.lock +++ b/examples/hello_world/Cargo.lock @@ -472,7 +472,7 @@ dependencies = [ [[package]] name = "rovo" -version = "0.4.4" +version = "0.4.6" dependencies = [ "aide", "axum", @@ -484,10 +484,11 @@ dependencies = [ [[package]] name = "rovo-macros" -version = "0.4.4" +version = "0.4.6" dependencies = [ "proc-macro2", "quote", + "serde_derive_internals", "syn", ] diff --git a/examples/hello_world/src/main.rs b/examples/hello_world/src/main.rs index ab44028..8128492 100644 --- a/examples/hello_world/src/main.rs +++ b/examples/hello_world/src/main.rs @@ -4,10 +4,9 @@ use rovo::aide::axum::IntoApiResponse; use rovo::aide::openapi::OpenApi; use rovo::response::Json; use rovo::schemars::JsonSchema; -use rovo::{routing::get, rovo, schema, Router}; +use rovo::{routing::get, rovo, Router}; use serde::Serialize; -#[schema] #[derive(Debug, Serialize, JsonSchema)] struct Greeting { message: String, diff --git a/rovo-macros/src/lib.rs b/rovo-macros/src/lib.rs index 5699669..0bb89bb 100644 --- a/rovo-macros/src/lib.rs +++ b/rovo-macros/src/lib.rs @@ -457,19 +457,20 @@ pub fn rovo(_attr: TokenStream, item: TokenStream) -> TokenStream { } } -/// Configures `schemars` derives to use rovo's re-exported crate path. +/// Derive macro for [`JsonSchema`](trait@::schemars::JsonSchema) that automatically +/// resolves rovo's re-exported `schemars` crate path. /// -/// When deriving [`JsonSchema`] via `rovo::schemars::JsonSchema` without `schemars` as a -/// direct dependency, the derived code fails to resolve the `schemars` crate. This attribute -/// automatically injects `#[schemars(crate = "::rovo::schemars")]` so it just works. +/// This is a drop-in replacement for `schemars`' `JsonSchema` derive. It generates +/// identical code but defaults to `::rovo::schemars` as the crate path, so +/// `#[derive(JsonSchema)]` works without a direct `schemars` dependency or any +/// helper attributes. /// -/// Place this attribute **above** your `#[derive(...)]`: +/// # Example /// /// ```rust,ignore -/// use rovo::{schema, schemars::JsonSchema}; +/// use rovo::schemars::JsonSchema; /// use serde::{Serialize, Deserialize}; /// -/// #[schema] /// #[derive(Debug, Serialize, Deserialize, JsonSchema)] /// struct CallbackQuery { /// code: String, @@ -477,12 +478,22 @@ pub fn rovo(_attr: TokenStream, item: TokenStream) -> TokenStream { /// } /// ``` /// -/// If you already have `#[schemars(crate = "...")]` on the item, this macro is a no-op. -#[proc_macro_attribute] -pub fn schema(_attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input = syn::parse_macro_input!(item as syn::DeriveInput); +/// You can still override the crate path with `#[schemars(crate = "...")]` if needed. +#[proc_macro_derive(JsonSchema, attributes(schemars, serde, validate, garde))] +pub fn derive_json_schema(input: TokenStream) -> TokenStream { + let mut hidden = syn::parse_macro_input!(input as syn::DeriveInput); + let original_name = hidden.ident.clone(); + let hidden_name = quote::format_ident!("__rovo_{}", original_name); + hidden.ident = hidden_name.clone(); + + // Strip #[derive(...)] and #[doc(...)] attributes — keep schemars/serde/validate/garde. + hidden.attrs.retain(|attr| { + let path = attr.path(); + !path.is_ident("derive") && !path.is_ident("doc") + }); - let has_schemars_crate = input.attrs.iter().any(|attr| { + // Only inject the crate path if the user hasn't set one already. + let has_crate_attr = hidden.attrs.iter().any(|attr| { if !attr.path().is_ident("schemars") { return false; } @@ -495,11 +506,57 @@ pub fn schema(_attr: TokenStream, item: TokenStream) -> TokenStream { .any(|tt| matches!(tt, proc_macro2::TokenTree::Ident(ref ident) if ident == "crate")) }); - if !has_schemars_crate { - input - .attrs - .push(syn::parse_quote!(#[schemars(crate = "::rovo::schemars")])); - } + let crate_attr = if has_crate_attr { + quote! {} + } else { + quote! { #[schemars(crate = "::rovo::schemars")] } + }; - quote!(#input).into() + let (impl_generics, ty_generics, where_clause) = hidden.generics.split_for_impl(); + + // Require the hidden type to implement JsonSchema so that generic bounds + // propagate correctly (schemars adds `T: JsonSchema` on the hidden impl). + let extended_where = where_clause.map_or_else( + || quote! { where #hidden_name #ty_generics: ::rovo::schemars::JsonSchema }, + |wc| quote! { #wc, #hidden_name #ty_generics: ::rovo::schemars::JsonSchema }, + ); + + quote! { + const _: () = { + #[derive(::rovo::__schemars::JsonSchema)] + #crate_attr + #[allow(dead_code, non_camel_case_types)] + #hidden + + #[automatically_derived] + impl #impl_generics ::rovo::schemars::JsonSchema for #original_name #ty_generics #extended_where { + fn schema_name() -> ::std::borrow::Cow<'static, str> { + let raw = <#hidden_name #ty_generics as ::rovo::schemars::JsonSchema>::schema_name(); + ::std::borrow::Cow::Owned(raw.replace(stringify!(#hidden_name), stringify!(#original_name))) + } + + fn schema_id() -> ::std::borrow::Cow<'static, str> { + let raw = <#hidden_name #ty_generics as ::rovo::schemars::JsonSchema>::schema_id(); + ::std::borrow::Cow::Owned(raw.replace(stringify!(#hidden_name), stringify!(#original_name))) + } + + fn json_schema(gen: &mut ::rovo::schemars::SchemaGenerator) -> ::rovo::schemars::Schema { + <#hidden_name #ty_generics as ::rovo::schemars::JsonSchema>::json_schema(gen) + } + + fn inline_schema() -> bool { + <#hidden_name #ty_generics as ::rovo::schemars::JsonSchema>::inline_schema() + } + + fn _schemars_private_non_optional_json_schema(gen: &mut ::rovo::schemars::SchemaGenerator) -> ::rovo::schemars::Schema { + <#hidden_name #ty_generics as ::rovo::schemars::JsonSchema>::_schemars_private_non_optional_json_schema(gen) + } + + fn _schemars_private_is_option() -> bool { + <#hidden_name #ty_generics as ::rovo::schemars::JsonSchema>::_schemars_private_is_option() + } + } + }; + } + .into() } diff --git a/src/lib.rs b/src/lib.rs index 9b4a969..8f77cde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,11 +100,35 @@ //! **Special directives:** //! - `@rovo-ignore` - Stop processing annotations after this point -pub use rovo_macros::{rovo, schema}; +pub use rovo_macros::rovo; -// Re-export aide and schemars for convenience +// Re-export aide for convenience pub use aide; -pub use schemars; + +/// Raw schemars crate re-export, used internally by the `JsonSchema` derive. +#[doc(hidden)] +pub use ::schemars as __schemars; + +/// Re-export of the [`schemars`](::schemars) crate with rovo's `JsonSchema` derive. +/// +/// The `JsonSchema` derive exported here automatically resolves rovo's crate path, +/// so `#[derive(JsonSchema)]` works without a direct `schemars` dependency or any +/// helper attributes. +/// +/// ```rust,ignore +/// use rovo::schemars::JsonSchema; +/// +/// #[derive(JsonSchema)] +/// struct MyStruct { +/// name: String, +/// } +/// ``` +pub mod schemars { + pub use ::schemars::*; + + /// Derive macro for `JsonSchema` that automatically resolves rovo's crate path. + pub use rovo_macros::JsonSchema; +} // Re-export axum modules for convenience, so users don't need axum as a direct dependency /// Re-export of axum's extract module for request data extraction. diff --git a/tests/schema_macro.rs b/tests/schema_macro.rs index 3b1b2af..e44dd12 100644 --- a/tests/schema_macro.rs +++ b/tests/schema_macro.rs @@ -1,51 +1,60 @@ #![allow(dead_code)] use rovo::schemars::JsonSchema; -use rovo::{schema, schemars}; -/// Verifies that `#[schema]` correctly injects `#[schemars(crate = "::rovo::schemars")]` -/// and works alongside `#[derive(JsonSchema)]`. -#[schema] +/// Verifies `#[derive(JsonSchema)]` works without any helper attributes. #[derive(Debug, JsonSchema)] struct BasicStruct { name: String, count: u32, } -/// Verifies `#[schema]` works with enums. -#[schema] +/// Verifies the derive works with enums. #[derive(Debug, JsonSchema)] enum MyEnum { A, B(String), } -/// Verifies `#[schema]` is a no-op when `#[schemars(crate = "...")]` is already present. -#[schema] +/// Verifies explicit `#[schemars(crate = "...")]` is respected. #[derive(Debug, JsonSchema)] #[schemars(crate = "::rovo::schemars")] -struct AlreadyAnnotated { +struct ExplicitCratePath { value: i64, } +/// Verifies the derive works with generic types. +#[derive(Debug, JsonSchema)] +struct Wrapper { + inner: T, +} + #[test] -fn schema_macro_produces_valid_json_schema() { - // If this compiles and runs, the derive succeeded through rovo's re-export path - let schema = schemars::SchemaGenerator::default().into_root_schema_for::(); +fn derive_produces_valid_json_schema() { + let schema = rovo::schemars::SchemaGenerator::default().into_root_schema_for::(); let json = serde_json::to_string(&schema).unwrap(); assert!(json.contains("BasicStruct")); } #[test] -fn schema_macro_works_for_enums() { - let schema = schemars::SchemaGenerator::default().into_root_schema_for::(); +fn derive_works_for_enums() { + let schema = rovo::schemars::SchemaGenerator::default().into_root_schema_for::(); let json = serde_json::to_string(&schema).unwrap(); assert!(json.contains("MyEnum")); } #[test] -fn schema_macro_noop_when_crate_already_set() { - let schema = schemars::SchemaGenerator::default().into_root_schema_for::(); +fn derive_respects_explicit_crate_path() { + let schema = + rovo::schemars::SchemaGenerator::default().into_root_schema_for::(); + let json = serde_json::to_string(&schema).unwrap(); + assert!(json.contains("ExplicitCratePath")); +} + +#[test] +fn derive_works_with_generics() { + let schema = + rovo::schemars::SchemaGenerator::default().into_root_schema_for::>(); let json = serde_json::to_string(&schema).unwrap(); - assert!(json.contains("AlreadyAnnotated")); + assert!(json.contains("Wrapper")); }