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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
- A generated Python module with an omittable field of a unit type, `()` or a unit struct, imports again. The field was annotated `None | None`, which Python can't evaluate, so importing the module raised `TypeError`. It is now `None`.
- A generated Rust client compiles when an optional field's type is a generated struct or enum without `Default`, such as a `#[serde(default)] memo: Memo` whose `Memo` implements `Default` only on the server. The field got `#[serde(default)]`, which needs `Default` on the generated type, and generated types derive it only when configured to. Such a field is now `Option<Memo>`: `None` leaves the key out and the server applies its default. Fields whose generated type derives `Default`, through `base_derives` or the type's `additional_derives`, are unchanged.

### Changed

- Generated clients decode responses after the server removes a deprecated field. Every generator now treats a deprecated field of a response-only type as optional: Python reads it as `None` and types a non-`Option` field `T | None`, TypeScript marks it `?:`, the Rust client types it `Option<T>`, unless it already is one, and OpenAPI leaves it out of `required`. Before, a client generated while the field was deprecated failed to decode every response once the server dropped it, although the client never read it. TypeScript and Rust code that reads such a field must now handle its absence. Types also used in requests keep deprecated fields required, because the server may still require them until it removes them, and so do types the client decodes by trying them in turn: untagged enums and untagged variants, the type behind a flattened `Option`, and every type these reach. There, an optional field could change what a payload decodes as.

## 0.23.0

Keep idle stream routes open through proxies and load balancers: a server can now write a Server-Sent Events comment whenever a stream has sent nothing for an interval, set for the whole server or per route. See [Keep idle streams open](https://thepartly.github.io/reflectapi/serving/axum.html#keep-idle-streams-open). Generated Python models also describe their wire shape in their JSON schemas. `reflectapi::axum::into_router` takes a new settings argument, and Python clients need the matching runtime; see Breaking.
Expand Down

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

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

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

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

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

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

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

67 changes: 67 additions & 0 deletions reflectapi-demo/tests/field_presence.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Directional field-presence fixtures shared by the generated SDK tests.

#[allow(deprecated)]
pub mod fixtures {
fn deserialize_nullable<'de, D: serde::Deserializer<'de>>(
deserializer: D,
Expand Down Expand Up @@ -155,6 +156,41 @@ pub mod fixtures {
pub text: String,
}

#[derive(serde::Serialize, serde::Deserialize, reflectapi::Output)]
pub struct Config {
pub level: u32,
}

// Response-only: a newer server may have removed the deprecated fields.
#[derive(serde::Serialize, serde::Deserialize, reflectapi::Output)]
pub struct Profile {
pub id: u32,
#[deprecated(note = "use id")]
pub legacy_tags: Option<std::collections::BTreeMap<String, u32>>,
#[deprecated(note = "use id")]
pub legacy_code: String,
#[deprecated(note = "use id")]
pub legacy_config: Config,
#[deprecated(note = "use id")]
pub legacy_rank: std::num::NonZeroU32,
}

// A deprecated field typed by a type parameter, which may lack `Default`.
#[derive(serde::Serialize, serde::Deserialize, reflectapi::Output)]
pub struct Envelope<T> {
pub data: T,
#[deprecated(note = "use data")]
pub result: T,
}

// Used in both directions, so a deprecated field stays required.
#[derive(serde::Serialize, serde::Deserialize, reflectapi::Input, reflectapi::Output)]
pub struct Shared {
pub id: u32,
#[deprecated(note = "use id")]
pub old: String,
}

// Both directions agree when the serializer also omits absent values.
#[derive(serde::Serialize, serde::Deserialize, reflectapi::Input, reflectapi::Output)]
pub struct Equal {
Expand All @@ -163,6 +199,14 @@ pub mod fixtures {
}
}

async fn profile(_: (), _: reflectapi::Empty, _: reflectapi::Empty) -> fixtures::Profile {
unimplemented!("schema-only route")
}

async fn envelope(_: (), _: reflectapi::Empty, _: reflectapi::Empty) -> fixtures::Envelope<u32> {
unimplemented!("schema-only route")
}

fn schema() -> reflectapi::Schema {
reflectapi::Builder::<()>::new()
.name("Field presence")
Expand Down Expand Up @@ -206,6 +250,12 @@ fn schema() -> reflectapi::Schema {
|_: (), input: fixtures::Defaulted, _: reflectapi::Empty| async { input },
|b| b.name("fields.defaulted"),
)
.route(profile, |b| b.name("fields.profile"))
.route(envelope, |b| b.name("fields.envelope"))
.route(
|_: (), input: fixtures::Shared, _: reflectapi::Empty| async { input },
|b| b.name("fields.shared"),
)
.build()
.unwrap()
.0
Expand Down Expand Up @@ -413,3 +463,20 @@ fn openapi_uses_the_same_directional_required_keys() {
&& !keys.contains(&serde_json::json!("skipped"))
}));
}

#[test]
fn openapi_lets_responses_omit_deprecated_fields() {
let spec = reflectapi::codegen::openapi::generate(&schema(), &Default::default()).unwrap();
let spec: serde_json::Value = serde_json::from_str(&spec).unwrap();
let required = |suffix: &str| {
let (_, definition) = spec["components"]["schemas"]
.as_object()
.unwrap()
.iter()
.find(|(name, _)| name.ends_with(suffix))
.unwrap();
definition["required"].as_array().unwrap().clone()
};
assert_eq!(required(".Profile"), vec![serde_json::json!("id")]);
assert!(required(".Shared").contains(&serde_json::json!("old")));
}
38 changes: 38 additions & 0 deletions reflectapi-demo/tests/python/test_field_presence.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from null_contract_client.field_presence.fixtures import Context, Create # noqa: E402
from presence_client.field_presence.fixtures import ( # noqa: E402
Equal,
Profile,
Shared,
Tuple,
input,
output,
Expand Down Expand Up @@ -258,3 +260,39 @@ def test_omittable_field_of_a_nullable_type_accepts_null(field: str) -> None:
# an Option, JSON values and custom deserializers may all read null.
create = Create.model_validate({field: None})
assert create.model_dump(by_alias=True, exclude_unset=True) == {field: None}


def test_response_may_omit_deprecated_fields() -> None:
# A server that removed its deprecated fields still decodes in a client
# generated before the removal.
profile = Profile.model_validate({"id": 1})
with pytest.warns(DeprecationWarning):
assert profile.legacy_tags is None
with pytest.warns(DeprecationWarning):
assert profile.legacy_code is None
with pytest.warns(DeprecationWarning):
assert profile.legacy_config is None
# Re-serializing keeps the server's shape: what it omitted stays omitted.
assert profile.model_dump(by_alias=True) == {"id": 1}
full = Profile.model_validate(
{
"id": 1,
"legacy_tags": {"a": 1},
"legacy_code": "x",
"legacy_config": {"level": 2},
}
)
assert full.model_dump(by_alias=True) == {
"id": 1,
"legacy_tags": {"a": 1},
"legacy_code": "x",
"legacy_config": {"level": 2},
}


def test_request_still_requires_deprecated_fields() -> None:
# The server may still require the field, so a model sent as a request
# keeps it required.
with pytest.raises(ValidationError):
Shared.model_validate({"id": 1})
Shared.model_validate({"id": 1, "old": "x"})
17 changes: 16 additions & 1 deletion reflectapi-demo/tests/rust/field_presence/tests/presence.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use reflectapi_field_presence_rust::types::field_presence::fixtures::{input, output, Tuple};
use reflectapi_field_presence_rust::types::field_presence::fixtures::{
input, output, Envelope, Profile, Shared, Tuple,
};
use serde_json::json;

#[test]
Expand Down Expand Up @@ -60,3 +62,16 @@ fn defaulted_fields_of_generated_types_may_be_omitted() {
assert!(empty.memo.is_none() && empty.boxed.is_none());
assert_eq!(serde_json::to_value(empty).unwrap(), json!({}));
}

#[test]
#[allow(deprecated)]
fn generated_responses_may_omit_deprecated_fields() {
let profile: Profile = serde_json::from_value(json!({"id": 1})).unwrap();
assert_eq!(profile.legacy_tags, None);
assert_eq!(profile.legacy_code, None);
assert!(profile.legacy_config.is_none());
assert!(profile.legacy_rank.is_none());
let envelope: Envelope<u32> = serde_json::from_value(json!({"data": 1})).unwrap();
assert_eq!(envelope.result, None);
assert!(serde_json::from_value::<Shared>(json!({"id": 1})).is_err());
}
Loading
Loading