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
58 changes: 55 additions & 3 deletions docs/src/getting-started/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,61 @@ 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>` → `T`)
- `reflectapi::transforms::make_required` — makes a field required: `Option<T>` → required `T`, `reflectapi::Option<T>` → required `Option<T>` (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>` → `T`, `reflectapi::Option<T>` → `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<T>` → required `T`, `reflectapi::Option<T>` → required `T`. For non-Option types, marks the field as required without changing the type.

> **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<T>`, required) — must be present, can be null
> - Optional + nullable (`reflectapi::Option<T>`) — 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.

**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)]
struct MyRequest {
/// In Rust this is `Option<String>` (populated by middleware),
/// but clients see it as a required `String` field.
#[serde(default)]
#[reflectapi(input_transform = "reflectapi::transforms::make_required")]
Comment thread
JosiahBull marked this conversation as resolved.
pub tenant_id: Option<String>,
}

#[derive(serde::Deserialize, reflectapi::Input)]
struct PatchRequest {
/// In Rust this is `reflectapi::Option<String>` (populated by middleware),
/// but clients see it as a required `Option<String>` (nullable, not omittable).
Comment on lines +61 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle difference based on reflectapi::Option vs std::Option. As a developer I'd typically expect make_required to do the same regardless of what Option variant it's on - would be a bit of a footgun imo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making std option required means removing Undefined and None variants for the non-nullable field.

making reflectapi required means removing Undefined variant, as None is expected possible null value.

the difference is on purpose as the semantics of both types required.

however, the client can provide their own transform functions.

@JosiahBull JosiahBull Jun 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I use a function called make_required on something named Option I would expect the generated client to disallow all None-alike values (None, null, etc).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to rename something here to make this clearer

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know that from reflectapi (and our API standard) required is not equal to not-nullable? These are two distinct things. It is possible to have non-required but nullable and required but nullable and 2 other combos. So the transform function does exactly that: it removes the declaration for the field to be required, which is for non nullable field means no None of the standard Option and for nullable field it means no Undefined of the reflectapi Option.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can add make_nonnullable, and super set of both make_required_and_nonnullable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be happy with this outcome, will mark as approved.

#[serde(default)]
#[reflectapi(input_transform = "reflectapi::transforms::make_required")]
pub correlation_id: reflectapi::Option<String>,

/// In Rust this is `reflectapi::Option<String>`,
/// 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<String>,

/// In Rust this is `reflectapi::Option<String>`,
/// 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<String>,
}
```

### Visibility

Expand Down
18 changes: 9 additions & 9 deletions reflectapi-demo/src/tests/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
}
Expand All @@ -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<u8>,
}
#[test]
Expand All @@ -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<u8>,
}
#[test]
Expand All @@ -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<u8>,
}
#[test]
Expand All @@ -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<std::sync::Arc<u8>>,
Expand All @@ -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]
Expand Down Expand Up @@ -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<std::collections::HashMap<String, i32>>,
}

Expand Down
2 changes: 1 addition & 1 deletion reflectapi-schema/src/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
5 changes: 3 additions & 2 deletions reflectapi-schema/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod codegen;
mod internal;
mod rename;
mod subst;
pub mod transforms;
mod visit;

pub use self::codegen::*;
Expand Down Expand Up @@ -1135,7 +1136,7 @@ pub struct Field {
#[serde(skip, default)]
pub transform_callback: String,
#[serde(skip, default)]
pub transform_callback_fn: Option<fn(&mut TypeReference, &Typespace) -> ()>,
pub transform_callback_fn: Option<fn(&mut Field, &Typespace) -> ()>,
}

impl PartialEq for Field {
Expand Down Expand Up @@ -1249,7 +1250,7 @@ impl Field {
self.transform_callback.as_str()
}

pub fn transform_callback_fn(&self) -> Option<fn(&mut TypeReference, &Typespace)> {
pub fn transform_callback_fn(&self) -> Option<fn(&mut Field, &Typespace)> {
self.transform_callback_fn
}
}
Expand Down
51 changes: 51 additions & 0 deletions reflectapi-schema/src/transforms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use crate::{Field, TypeReference, Typespace};

/// Unwraps transparent wrappers (e.g. `Arc<T>` → `T`) on the field's type reference.
pub fn fallback_recursively(field: &mut Field, schema: &Typespace) {
field.type_ref.fallback_recursively(schema);
}

/// Makes a field required in the schema.
///
/// - `std::option::Option<T>` → required `T`
/// - `reflectapi::Option<T>` → required `std::option::Option<T>`
/// (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() {
field.type_ref = inner;
}
} else if field.type_ref.name() == "reflectapi::Option" {
let arguments = field.type_ref.arguments().cloned().collect::<Vec<_>>();
field.type_ref = TypeReference::new("std::option::Option", arguments);
}
field.required = true;
}

/// Makes a field non-nullable in the schema.
///
/// - `std::option::Option<T>` → `T` (required unchanged)
/// - `reflectapi::Option<T>` → `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"
|| 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<T>` → required `T`
/// - `reflectapi::Option<T>` → 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);
}
Loading