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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,20 @@ jobs:
- run: cargo test --workspace
- name: Test generated Rust enum compatibility
run: cargo test --manifest-path reflectapi-demo/tests/rust/enum_compatibility/Cargo.toml
- name: Test generated Rust field presence
run: cargo test --manifest-path reflectapi-demo/tests/rust/field_presence/Cargo.toml
- uses: actions/upload-artifact@v7
with:
name: enum-compatibility-typescript
path: |
reflectapi-demo/target/enum-compatibility/typescript/*.ts
reflectapi-demo/target/enum-compatibility/fallback_samples.json

- uses: actions/upload-artifact@v7
with:
name: field-presence-typescript
path: reflectapi-demo/target/field-presence/typescript/*.ts

typescript-runtime:
name: TypeScript runtime tests
needs: rust
Expand All @@ -59,6 +66,13 @@ jobs:
- name: Test generated TypeScript enum compatibility
run: REFLECTAPI_ENUM_SKIP_GENERATION=1 sh ../../tests/typescript/run.sh

- uses: actions/download-artifact@v8
with:
name: field-presence-typescript
path: reflectapi-demo/target/field-presence/typescript
- name: Check generated TypeScript field presence
run: REFLECTAPI_PRESENCE_SKIP_GENERATION=1 sh ../../tests/typescript/run-field-presence.sh

python-runtime:
name: Python runtime tests (${{ matrix.python-version }})
runs-on: ubuntu-latest
Expand Down Expand Up @@ -179,3 +193,8 @@ jobs:
- name: Test generated Python enum compatibility
working-directory: reflectapi-demo/clients/python
run: uv run --locked $UV_RUN_ARGS pytest ../../tests/python/test_enum_compatibility.py -q -p no:cacheprovider
- name: Generate field-presence clients
run: cargo test -p reflectapi-demo --test field_presence
- name: Test generated Python field presence
working-directory: reflectapi-demo/clients/python
run: uv run --locked $UV_RUN_ARGS pytest ../../tests/python/test_field_presence.py -q -p no:cacheprovider
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Fixed

- Field presence now follows Serde in each direction. Plain named `Option<T>` and `reflectapi::Option<T>` input fields accept omission without `#[serde(default)]`; response fields remain required unless serialization may skip them. Custom deserializers without a default keep input fields required. Reflection resolves presence before schema consolidation and transforms, so Rust, TypeScript, Python and OpenAPI use the same contract. A type used in both directions may now generate separate input and output models; regenerate SDKs and update affected type imports.
- Generated Rust named optional nullable request fields use `reflectapi::Option<T>` so `Undefined`, `None` and `Some(value)` serialize as omission, null and a value respectively. This changes affected Rust SDK field types from `std::option::Option<T>`; use `reflectapi::Option::Undefined` to omit a key and `reflectapi::Option::None` to send null.
- Python request serialization now omits unset model fields and preserves explicitly supplied `None` as JSON `null`, recursively, including aliases and models inside lists or dictionaries. Generated Python fields with known non-nullable primitive or container types reject explicit null while allowing omission. Unset non-null Pydantic defaults are also omitted; explicitly supply a default value to send it. Upgrade the Python runtime alongside regenerated clients.
- Schema format: `Field` records additive `serialize_with` and `deserialize_with` flags (omitted when false). Exhaustive Rust `Field` literals need these fields. Existing JSON schemas retain their declared requiredness; re-export the schema to obtain corrected directional presence.

## 0.20.1

Two code generation fixes. Regenerate SDKs and OpenAPI specs to pick them up; the Python runtime changes only its version.
Expand Down
25 changes: 25 additions & 0 deletions docs/src/clients/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ The generators do not all emit the same file layout:

The demo repository includes extra project scaffolding around some generated clients, but that scaffolding is not produced by `reflectapi codegen` itself.

## Missing fields and explicit null

Input and output types follow the server's directional Serde contract. A plain
named `Option<T>` input field may be omitted, while an output field that is
always serialized remains required and nullable. Types used in both directions
may therefore generate separate input and output models. See
[Missing fields and null](../getting-started/attributes.md#missing-fields-and-null)
for defaults, custom codecs and transforms.

Generated Python models with omittable fields preserve omission in direct
`model_dump()` / `model_dump_json()` calls using `ReflectapiPartialModel`.
Python requests omit fields the caller has not set (`exclude_unset=True`) and
send explicitly supplied `None` as JSON `null`. This applies recursively to
nested models, including models inside lists and dictionaries, and uses wire
aliases. Unset non-null Pydantic defaults are omitted too; pass the value
explicitly to send it. For example, an optional nullable field can be left out
with `Request()`, or sent as null with `Request(value=None)`. Generated fields
with known non-nullable primitive or container types reject explicit null.
Opaque aliases and custom codecs may require server-side validation.

Rust named optional nullable request fields use `reflectapi::Option<T>`:
`Undefined` omits the key, `None` sends null, and `Some(value)` sends the value.
After regenerating, update affected field initializers that previously used
`std::option::Option<T>`. Output-only nullable fields keep their existing types.

## Language Behavior

### TypeScript
Expand Down
28 changes: 28 additions & 0 deletions docs/src/getting-started/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,34 @@ policy widens generated request types.
| `#[reflectapi(input_type = "T")]` | Override the reflected type for the input schema only. |
| `#[reflectapi(output_type = "T")]` | Override the reflected type for the output schema only. |

### Missing fields and null

Field presence is directional. For a named field `value: Option<String>` without
other Serde attributes, the server accepts a missing request key as `None`, but
serializes a response with `"value": null`. Generated request types therefore
allow omission; generated response types require the key. When these contracts
differ, a Rust type used in both directions gets separate input and output
models. Tuple elements retain their required positions. Presence inference applies to
resolved option wrappers, including `Option<T>` and standard pointer wrappers; an unresolved generic field
`value: T` keeps its declared contract even if one use substitutes `Option<U>`.

`#[serde(default)]` allows a missing input field. A custom deserializer
(`deserialize_with` or `with`) requires the key unless a default is declared;
a custom serializer alone does not change input presence.
`#[serde(skip_serializing_if = "...")]` allows omission on output.

For `reflectapi::Option<T>`, a missing input becomes `None` without a default;
`#[serde(default)]` instead supplies `Undefined`. A custom default function
supplies whatever that function returns. To preserve all three states on the
server, use `#[serde(default, skip_serializing_if = "reflectapi::Option::is_undefined")]`.
Client-side omission and explicit null remain separate wire states.

These rules are resolved before field transforms, so an explicit
`make_required` override remains authoritative. Arbitrary custom codecs may
change the wire type; use a reflected type override to describe that type.
Previously exported schema JSON keeps its recorded `required` flags: re-export
the schema to obtain the corrected input contract.

### Transform

| Attribute | Description |
Expand Down
1 change: 1 addition & 0 deletions reflectapi-demo/clients/python/api_client/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ConfigDict,
Field,
RootModel,
field_validator,
model_serializer,
model_validator,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigDict,
Field,
RootModel,
field_validator,
model_serializer,
model_validator,
)
Expand Down
25 changes: 23 additions & 2 deletions reflectapi-demo/clients/python/api_client/myapi/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigDict,
Field,
RootModel,
field_validator,
model_serializer,
model_validator,
)
Expand Down Expand Up @@ -60,18 +61,29 @@ class MyapiModelBehaviorAggressiveVariant(BaseModel):
field_1: str = Field(description="some notes")


class MyapiModelBehaviorOtherVariant(BaseModel):
class MyapiModelBehaviorOtherVariant(ReflectapiPartialModel):
"""Other variant"""

model_config = ConfigDict(
extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
extra="ignore",
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
defer_build=True,
)

description: str = Field(description="Custom provided description of a behavior")
notes: str | None = Field(
default=None, description="Additional notes\nUp to a user to put free text here"
)

@field_validator("notes", mode="before")
@classmethod
def _reflectapi_reject_null(cls, value):
if value is None:
raise ValueError("Field may be omitted but cannot be null")
return value


# Externally tagged enum using RootModel
MyapiModelBehaviorVariants = Union[
Expand Down Expand Up @@ -131,6 +143,9 @@ class MyapiModelKindDog(BaseModel):
type: Literal["dog"] = Field(default="dog", description="Discriminator field")
breed: str = Field(description="breed of the dog")

def model_post_init(self, _context: Any) -> None:
self.__pydantic_fields_set__.update({"type"})


class MyapiModelKindCat(BaseModel):
"""A cat"""
Expand All @@ -142,6 +157,9 @@ class MyapiModelKindCat(BaseModel):
type: Literal["cat"] = Field(default="cat", description="Discriminator field")
lives: int = Field(description="lives left")

def model_post_init(self, _context: Any) -> None:
self.__pydantic_fields_set__.update({"type"})


class MyapiModelKindBird(BaseModel):
"""Test for unit variants in internally tagged enums"""
Expand All @@ -152,6 +170,9 @@ class MyapiModelKindBird(BaseModel):

type: Literal["bird"] = Field(default="bird", description="Discriminator field")

def model_post_init(self, _context: Any) -> None:
self.__pydantic_fields_set__.update({"type"})


class MyapiModelKind(RootModel):
root: Annotated[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigDict,
Field,
RootModel,
field_validator,
model_serializer,
model_validator,
)
Expand Down Expand Up @@ -55,9 +56,13 @@
StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"]


class MyapiModelInputPet(BaseModel):
class MyapiModelInputPet(ReflectapiPartialModel):
model_config = ConfigDict(
extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
extra="ignore",
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
defer_build=True,
)

name: str = Field(description="identity")
Expand All @@ -70,6 +75,13 @@ class MyapiModelInputPet(BaseModel):
default=None, description="behaviors of the pet"
)

@field_validator("behaviors", mode="before")
@classmethod
def _reflectapi_reject_null(cls, value):
if value is None:
raise ValueError("Field may be omitted but cannot be null")
return value


# Public aliases for this module
Pet = MyapiModelInputPet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigDict,
Field,
RootModel,
field_validator,
model_serializer,
model_validator,
)
Expand Down Expand Up @@ -55,9 +56,13 @@
StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"]


class MyapiModelOutputPet(BaseModel):
class MyapiModelOutputPet(ReflectapiPartialModel):
model_config = ConfigDict(
extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
extra="ignore",
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
defer_build=True,
)

name: str = Field(description="identity")
Expand All @@ -70,6 +75,13 @@ class MyapiModelOutputPet(BaseModel):
default=None, description="behaviors of the pet"
)

@field_validator("behaviors", mode="before")
@classmethod
def _reflectapi_reject_null(cls, value):
if value is None:
raise ValueError("Field may be omitted but cannot be null")
return value


# Public aliases for this module
Pet = MyapiModelOutputPet
Expand Down
28 changes: 22 additions & 6 deletions reflectapi-demo/clients/python/api_client/myapi/proto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigDict,
Field,
RootModel,
field_validator,
model_serializer,
model_validator,
)
Expand Down Expand Up @@ -82,18 +83,26 @@ class MyapiProtoInternalError(BaseModel):
message: str


class MyapiProtoPaginated(BaseModel, Generic[T]):
class MyapiProtoPaginated(ReflectapiPartialModel, Generic[T]):
model_config = ConfigDict(
extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
extra="ignore",
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
defer_build=True,
)

items: list[T] = Field(description="slice of a collection")
cursor: str | None = Field(default=None, description="cursor for getting next page")


class MyapiProtoPetsListRequest(BaseModel):
class MyapiProtoPetsListRequest(ReflectapiPartialModel):
model_config = ConfigDict(
extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
extra="ignore",
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
defer_build=True,
)

limit: int | None = None
Expand Down Expand Up @@ -138,13 +147,11 @@ class MyapiProtoResponseHeaders(BaseModel):
)

x_request_id: str | None = Field(
default=None,
serialization_alias="x-request-id",
validation_alias="x-request-id",
description="Request ID to quote when reporting a problem",
)
retry_after: str | None = Field(
default=None,
serialization_alias="retry-after",
validation_alias="retry-after",
description="Seconds, or an HTTP date, after which to retry",
Expand Down Expand Up @@ -226,6 +233,9 @@ class MyapiProtoPetsListErrorInvalidCursor(BaseModel):
default="InvalidCursor", description="Discriminator field"
)

def model_post_init(self, _context: Any) -> None:
self.__pydantic_fields_set__.update({"kind"})


class MyapiProtoPetsListErrorUnauthorized(BaseModel):
model_config = ConfigDict(
Expand All @@ -236,6 +246,9 @@ class MyapiProtoPetsListErrorUnauthorized(BaseModel):
default="Unauthorized", description="Discriminator field"
)

def model_post_init(self, _context: Any) -> None:
self.__pydantic_fields_set__.update({"kind"})


class MyapiProtoPetsListErrorInternal(BaseModel):
model_config = ConfigDict(
Expand All @@ -247,6 +260,9 @@ class MyapiProtoPetsListErrorInternal(BaseModel):
)
message: str

def model_post_init(self, _context: Any) -> None:
self.__pydantic_fields_set__.update({"kind"})


class MyapiProtoPetsListError(RootModel):
root: Annotated[
Expand Down
3 changes: 3 additions & 0 deletions reflectapi-demo/clients/python/tests/model_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ def aggressive_behavior(level: float, description: str) -> Behavior:


def other_behavior(description: str, notes: str | None = None) -> Behavior:
if notes is None:
# The Rust field defaults to an empty string; omission is valid, null is not.
return Behavior(root=BehaviorOtherVariant(description=description))
return Behavior(root=BehaviorOtherVariant(description=description, notes=notes))


Expand Down
Loading
Loading