Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/hello_world/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions examples/hello_world/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 75 additions & 18 deletions rovo-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,32 +457,43 @@ 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,
/// state: String,
/// }
/// ```
///
/// 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;
}
Expand All @@ -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()
}
30 changes: 27 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 26 additions & 17 deletions tests/schema_macro.rs
Original file line number Diff line number Diff line change
@@ -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<T> {
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::<BasicStruct>();
fn derive_produces_valid_json_schema() {
let schema = rovo::schemars::SchemaGenerator::default().into_root_schema_for::<BasicStruct>();
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::<MyEnum>();
fn derive_works_for_enums() {
let schema = rovo::schemars::SchemaGenerator::default().into_root_schema_for::<MyEnum>();
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::<AlreadyAnnotated>();
fn derive_respects_explicit_crate_path() {
let schema =
rovo::schemars::SchemaGenerator::default().into_root_schema_for::<ExplicitCratePath>();
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::<Wrapper<String>>();
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("AlreadyAnnotated"));
assert!(json.contains("Wrapper"));
}
Loading