From 2a6a0bedd05ffc6014e3b81a7a7b67340887a9cc Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Fri, 19 Jun 2026 11:46:25 +0000 Subject: [PATCH 1/3] advance transform attribute to access full field metadata --- docs/src/getting-started/attributes.md | 36 +++++++++++++++++++++++--- reflectapi-demo/src/tests/basic.rs | 18 ++++++------- reflectapi-schema/src/internal.rs | 2 +- reflectapi-schema/src/lib.rs | 5 ++-- reflectapi-schema/src/transforms.rs | 23 ++++++++++++++++ 5 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 reflectapi-schema/src/transforms.rs diff --git a/docs/src/getting-started/attributes.md b/docs/src/getting-started/attributes.md index d7cc3eb4..a2fac374 100644 --- a/docs/src/getting-started/attributes.md +++ b/docs/src/getting-started/attributes.md @@ -22,9 +22,39 @@ ReflectAPI provides `#[reflectapi(...)]` attributes that control how Rust types | Attribute | Description | |-----------|-------------| -| `#[reflectapi(transform = "path::to::fn")]` | Apply a type transformation callback for both schemas. | -| `#[reflectapi(input_transform = "path::to::fn")]` | Apply a type transformation callback for input only. | -| `#[reflectapi(output_transform = "path::to::fn")]` | Apply a type transformation callback for output only. | +| `#[reflectapi(transform = "path::to::fn")]` | Apply a field transformation callback for both schemas. Signature: `fn(&mut Field, &Typespace)`. | +| `#[reflectapi(input_transform = "path::to::fn")]` | Apply a field transformation callback for input only. | +| `#[reflectapi(output_transform = "path::to::fn")]` | Apply a field transformation callback for output only. | + +The transform callback receives `&mut Field` and can modify any field metadata — type, `required`, `hidden`, etc. It runs after type references are resolved. + +ReflectAPI ships with built-in transforms in the `reflectapi::transforms` module: +- `reflectapi::transforms::fallback_recursively` — unwraps transparent wrappers (e.g. `Arc` → `T`) +- `reflectapi::transforms::make_required` — makes an optional field required: `Option` → required `T`, `reflectapi::Option` → required `Option` + +**Example: Making an optional field appear as required in the schema** + +A field may use an Option wrapper at the Rust level (e.g., because a middleware populates it before your handler runs) but you want generated clients to see the unwrapped, required type. + +```rust,ignore +#[derive(serde::Deserialize, reflectapi::Input)] +struct MyRequest { + /// In Rust this is `Option` (populated by middleware), + /// but clients see it as a required `String` field. + #[serde(default)] + #[reflectapi(input_transform = "reflectapi::transforms::make_required")] + pub tenant_id: Option, +} + +#[derive(serde::Deserialize, reflectapi::Input)] +struct PatchRequest { + /// In Rust this is `reflectapi::Option` (populated by middleware), + /// but clients see it as a required `Option` (nullable, not omittable). + #[serde(default)] + #[reflectapi(input_transform = "reflectapi::transforms::make_required")] + pub correlation_id: reflectapi::Option, +} +``` ### Visibility diff --git a/reflectapi-demo/src/tests/basic.rs b/reflectapi-demo/src/tests/basic.rs index 6ed34247..b04bb6ba 100644 --- a/reflectapi-demo/src/tests/basic.rs +++ b/reflectapi-demo/src/tests/basic.rs @@ -268,8 +268,8 @@ fn test_reflectapi_struct_with_attributes_type_only() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithTransformFallback { #[reflectapi( - input_transform = "reflectapi::TypeReference::fallback_recursively", - output_transform = "reflectapi::TypeReference::fallback_recursively" + input_transform = "reflectapi::transforms::fallback_recursively", + output_transform = "reflectapi::transforms::fallback_recursively" )] _f: std::sync::Arc, } @@ -280,7 +280,7 @@ fn test_reflectapi_struct_with_transform_fallback() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithTransformBoth { - #[reflectapi(transform = "reflectapi::TypeReference::fallback_recursively")] + #[reflectapi(transform = "reflectapi::transforms::fallback_recursively")] _f: std::sync::Arc, } #[test] @@ -290,7 +290,7 @@ fn test_reflectapi_struct_with_transform_both() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithTransformInput { - #[reflectapi(input_transform = "reflectapi::TypeReference::fallback_recursively")] + #[reflectapi(input_transform = "reflectapi::transforms::fallback_recursively")] _f: std::sync::Arc, } #[test] @@ -300,7 +300,7 @@ fn test_reflectapi_struct_with_transform_input() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithTransformOutput { - #[reflectapi(output_transform = "reflectapi::TypeReference::fallback_recursively")] + #[reflectapi(output_transform = "reflectapi::transforms::fallback_recursively")] _f: std::sync::Arc, } #[test] @@ -311,8 +311,8 @@ fn test_reflectapi_struct_with_transform_output() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithTransformFallbackNested { #[reflectapi( - input_transform = "reflectapi::TypeReference::fallback_recursively", - output_transform = "reflectapi::TypeReference::fallback_recursively" + input_transform = "reflectapi::transforms::fallback_recursively", + output_transform = "reflectapi::transforms::fallback_recursively" )] #[allow(clippy::redundant_allocation)] _f: std::sync::Arc>, @@ -324,7 +324,7 @@ fn test_reflectapi_struct_with_transform_fallback_nested() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithTransformArray { - #[reflectapi(transform = "reflectapi::TypeReference::fallback_recursively")] + #[reflectapi(transform = "reflectapi::transforms::fallback_recursively")] _f: [u8; 8], } #[test] @@ -638,7 +638,7 @@ fn test_reflectapi_deprecated() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithExternalGenericTypeFallback { - #[reflectapi(transform = "reflectapi::TypeReference::fallback_recursively")] + #[reflectapi(transform = "reflectapi::transforms::fallback_recursively")] data: std::sync::Arc>, } diff --git a/reflectapi-schema/src/internal.rs b/reflectapi-schema/src/internal.rs index 04075b32..e8129485 100644 --- a/reflectapi-schema/src/internal.rs +++ b/reflectapi-schema/src/internal.rs @@ -80,7 +80,7 @@ fn replace_type_references_for_field( ); } if let Some(transform_callback_fn) = this.transform_callback_fn { - transform_callback_fn(&mut this.type_ref, schema); + transform_callback_fn(this, schema); } } diff --git a/reflectapi-schema/src/lib.rs b/reflectapi-schema/src/lib.rs index 056de5ae..70afe1f9 100644 --- a/reflectapi-schema/src/lib.rs +++ b/reflectapi-schema/src/lib.rs @@ -2,6 +2,7 @@ mod codegen; mod internal; mod rename; mod subst; +pub mod transforms; mod visit; pub use self::codegen::*; @@ -1135,7 +1136,7 @@ pub struct Field { #[serde(skip, default)] pub transform_callback: String, #[serde(skip, default)] - pub transform_callback_fn: Option ()>, + pub transform_callback_fn: Option ()>, } impl PartialEq for Field { @@ -1249,7 +1250,7 @@ impl Field { self.transform_callback.as_str() } - pub fn transform_callback_fn(&self) -> Option { + pub fn transform_callback_fn(&self) -> Option { self.transform_callback_fn } } diff --git a/reflectapi-schema/src/transforms.rs b/reflectapi-schema/src/transforms.rs new file mode 100644 index 00000000..892aea7e --- /dev/null +++ b/reflectapi-schema/src/transforms.rs @@ -0,0 +1,23 @@ +use crate::{Field, TypeReference, Typespace}; + +/// Unwraps transparent wrappers (e.g. `Arc` → `T`) on the field's type reference. +pub fn fallback_recursively(field: &mut Field, schema: &Typespace) { + field.type_ref.fallback_recursively(schema); +} + +/// Makes an optional field required in the schema. +/// +/// - `std::option::Option` → required `T` +/// - `reflectapi::Option` → required `std::option::Option` +/// (removes the "undefined" state, keeps nullable) +pub fn make_required(field: &mut Field, _schema: &Typespace) { + if field.type_ref.name() == "std::option::Option" { + if let Some(inner) = field.type_ref.arguments().next().cloned() { + field.type_ref = inner; + } + } else if field.type_ref.name() == "reflectapi::Option" { + let arguments = field.type_ref.arguments().cloned().collect::>(); + field.type_ref = TypeReference::new("std::option::Option", arguments); + } + field.required = true; +} From 6d401538ed2912c7c66cb4276197a748eaea269a Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Tue, 23 Jun 2026 00:43:34 +0000 Subject: [PATCH 2/3] enhance transforms: add make_nonnullable and make_required_and_nonnullable functions, and clarify documentation --- docs/src/getting-started/attributes.md | 28 +++++++++++++++++++--- reflectapi-schema/src/transforms.rs | 32 +++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/src/getting-started/attributes.md b/docs/src/getting-started/attributes.md index a2fac374..b6bf6448 100644 --- a/docs/src/getting-started/attributes.md +++ b/docs/src/getting-started/attributes.md @@ -30,11 +30,21 @@ The transform callback receives `&mut Field` and can modify any field metadata ReflectAPI ships with built-in transforms in the `reflectapi::transforms` module: - `reflectapi::transforms::fallback_recursively` — unwraps transparent wrappers (e.g. `Arc` → `T`) -- `reflectapi::transforms::make_required` — makes an optional field required: `Option` → required `T`, `reflectapi::Option` → required `Option` +- `reflectapi::transforms::make_required` — makes a field required: `Option` → required `T`, `reflectapi::Option` → required `Option` (nullable but not omittable). For non-Option types, marks the field as required without changing the type. +- `reflectapi::transforms::make_nonnullable` — makes a field non-nullable: `Option` → `T`, `reflectapi::Option` → `T` (keeps optionality, removes nullability). No-op for non-Option types. +- `reflectapi::transforms::make_required_and_nonnullable` — makes a field both required and non-nullable: `Option` → required `T`, `reflectapi::Option` → required `T`. For non-Option types, marks the field as required without changing the type. -**Example: Making an optional field appear as required in the schema** +> **Note:** In reflectapi, "required" and "non-nullable" are distinct concepts. A field can be: +> - Required + non-nullable (`T`, required) — must be present, cannot be null +> - Required + nullable (`Option`, required) — must be present, can be null +> - Optional + nullable (`reflectapi::Option`) — can be omitted or null +> - Optional + non-nullable (`T`, not required) — can be omitted, but if present cannot be null +> +> `make_required` removes the "can be omitted" (undefined) state. `make_nonnullable` removes the "can be null" state. `make_required_and_nonnullable` removes both. -A field may use an Option wrapper at the Rust level (e.g., because a middleware populates it before your handler runs) but you want generated clients to see the unwrapped, required type. +**Example: Controlling requiredness and nullability in the schema** + +A field may use an Option wrapper at the Rust level (e.g., because a middleware populates it before your handler runs) but you want generated clients to see a different contract. ```rust,ignore #[derive(serde::Deserialize, reflectapi::Input)] @@ -53,6 +63,18 @@ struct PatchRequest { #[serde(default)] #[reflectapi(input_transform = "reflectapi::transforms::make_required")] pub correlation_id: reflectapi::Option, + + /// In Rust this is `reflectapi::Option`, + /// but clients see it as an optional `String` (can be omitted, but not null). + #[serde(default)] + #[reflectapi(input_transform = "reflectapi::transforms::make_nonnullable")] + pub display_name: reflectapi::Option, + + /// In Rust this is `reflectapi::Option`, + /// but clients see it as a required `String` (must be present, cannot be null). + #[serde(default)] + #[reflectapi(input_transform = "reflectapi::transforms::make_required_and_nonnullable")] + pub user_id: reflectapi::Option, } ``` diff --git a/reflectapi-schema/src/transforms.rs b/reflectapi-schema/src/transforms.rs index 892aea7e..3caf9356 100644 --- a/reflectapi-schema/src/transforms.rs +++ b/reflectapi-schema/src/transforms.rs @@ -5,11 +5,12 @@ pub fn fallback_recursively(field: &mut Field, schema: &Typespace) { field.type_ref.fallback_recursively(schema); } -/// Makes an optional field required in the schema. +/// Makes a field required in the schema. /// /// - `std::option::Option` → required `T` /// - `reflectapi::Option` → required `std::option::Option` /// (removes the "undefined" state, keeps nullable) +/// - Other types → marks the field as required without changing the type pub fn make_required(field: &mut Field, _schema: &Typespace) { if field.type_ref.name() == "std::option::Option" { if let Some(inner) = field.type_ref.arguments().next().cloned() { @@ -21,3 +22,32 @@ pub fn make_required(field: &mut Field, _schema: &Typespace) { } field.required = true; } + +/// Makes a field non-nullable in the schema. +/// +/// - `std::option::Option` → `T` (required unchanged) +/// - `reflectapi::Option` → `T` (required unchanged) +/// (removes the "null" state, keeps optionality) +/// - Other types → no-op (already non-nullable) +pub fn make_nonnullable(field: &mut Field, _schema: &Typespace) { + if field.type_ref.name() == "std::option::Option" { + if let Some(inner) = field.type_ref.arguments().next().cloned() { + field.type_ref = inner; + } + } else if field.type_ref.name() == "reflectapi::Option" { + if let Some(inner) = field.type_ref.arguments().next().cloned() { + field.type_ref = inner; + } + } +} + +/// Makes a field both required and non-nullable in the schema. +/// +/// - `std::option::Option` → required `T` +/// - `reflectapi::Option` → required `T` +/// (removes both "undefined" and "null" states) +/// - Other types → marks the field as required without changing the type +pub fn make_required_and_nonnullable(field: &mut Field, schema: &Typespace) { + make_nonnullable(field, schema); + make_required(field, schema); +} From 365a5a909a93398d98d38f0eca4955958d67a64f Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Tue, 23 Jun 2026 00:53:09 +0000 Subject: [PATCH 3/3] fix clippy --- reflectapi-schema/src/transforms.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/reflectapi-schema/src/transforms.rs b/reflectapi-schema/src/transforms.rs index 3caf9356..97da3464 100644 --- a/reflectapi-schema/src/transforms.rs +++ b/reflectapi-schema/src/transforms.rs @@ -30,11 +30,9 @@ pub fn make_required(field: &mut Field, _schema: &Typespace) { /// (removes the "null" state, keeps optionality) /// - Other types → no-op (already non-nullable) pub fn make_nonnullable(field: &mut Field, _schema: &Typespace) { - if field.type_ref.name() == "std::option::Option" { - if let Some(inner) = field.type_ref.arguments().next().cloned() { - field.type_ref = inner; - } - } else if field.type_ref.name() == "reflectapi::Option" { + if field.type_ref.name() == "std::option::Option" + || field.type_ref.name() == "reflectapi::Option" + { if let Some(inner) = field.type_ref.arguments().next().cloned() { field.type_ref = inner; }