From e9e0f94167fe171b56c193da2f37a0c903c6731e Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Wed, 10 Jun 2026 06:13:11 +0000 Subject: [PATCH 1/8] feat: add #[reflectapi(hidden)] attribute for fields Fields marked with `hidden` remain in the schema (with `"hidden": true`) and are functional at runtime (e.g. header extraction by the axum adapter), but are excluded from generated clients, docs, and OpenAPI specs. This is distinct from `#[reflectapi(skip)]` which fully excludes the field from the schema (for fields whose types don't implement reflectapi traits). --- reflectapi-demo/src/tests/basic.rs | 20 +++++ ...api_struct_with_hidden_header_field-2.snap | 68 ++++++++++++++++ ...api_struct_with_hidden_header_field-3.snap | 80 +++++++++++++++++++ ...api_struct_with_hidden_header_field-4.snap | 53 ++++++++++++ ...ctapi_struct_with_hidden_header_field.snap | 76 ++++++++++++++++++ .../invalid_reflect_field_type_generic.stderr | 14 +++- .../invalid_reflect_on_nested_struct.stderr | 14 +++- ...alid_reflect_on_nested_struct_twice.stderr | 14 +++- reflectapi-derive/src/derive.rs | 14 +++- reflectapi-derive/src/parser.rs | 4 + reflectapi-derive/src/symbol.rs | 2 + reflectapi-derive/src/tokenizable_schema.rs | 2 + reflectapi-schema/src/lib.rs | 14 ++++ reflectapi/src/codegen/openapi.rs | 5 +- reflectapi/src/codegen/python.rs | 26 ++++-- reflectapi/src/codegen/rust.rs | 2 + reflectapi/src/codegen/typescript.rs | 6 +- rust-toolchain.toml | 2 +- 18 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap diff --git a/reflectapi-demo/src/tests/basic.rs b/reflectapi-demo/src/tests/basic.rs index 1aab06f5..a739760e 100644 --- a/reflectapi-demo/src/tests/basic.rs +++ b/reflectapi-demo/src/tests/basic.rs @@ -643,3 +643,23 @@ struct TestStructWithExternalGenericTypeFallback { fn test_reflectapi_struct_with_external_generic_type_fallback() { assert_snapshot!(TestStructWithExternalGenericTypeFallback); } + +#[test] +fn test_reflectapi_struct_with_hidden_header_field() { + #[derive(serde::Deserialize, reflectapi::Input)] + struct HeadersWithHidden { + /// Authorization header + authorization: String, + /// Internal tracking header, hidden from clients + #[reflectapi(hidden)] + #[serde(default)] + x_internal_trace_id: String, + } + + assert_builder_snapshot!(reflectapi::Builder::<()>::new() + .name("hidden_header_test") + .route( + |_: (), _: reflectapi::Empty, _h: HeadersWithHidden| async { reflectapi::Empty {} }, + |b| b.name("test.endpoint") + )) +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap new file mode 100644 index 00000000..6d0adbbf --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap @@ -0,0 +1,68 @@ +--- +source: reflectapi-demo/src/tests/basic.rs +expression: typescript +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: hidden_header_test + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + test: TestInterface; + } + + export interface TestInterface { + endpoint: ( + input: {}, + headers: reflectapi_demo.tests.basic.HeadersWithHidden, + options?: RequestOptions, + ) => AsyncResult<{}, {}>; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace basic { + export interface HeadersWithHidden { + /** + * Authorization header + */ + authorization: string; + } + } + } +} + +namespace __implementation { + + function test__endpoint(client: Client) { + return ( + input: {}, + headers: reflectapi_demo.tests.basic.HeadersWithHidden, + options?: RequestOptions, + ) => + __request<{}, reflectapi_demo.tests.basic.HeadersWithHidden, {}, {}>( + client, + "/test.endpoint", + input, + headers, + options, + ); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap new file mode 100644 index 00000000..3eeb80f1 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap @@ -0,0 +1,80 @@ +--- +source: reflectapi-demo/src/tests/basic.rs +expression: rust +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: hidden_header_test + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + pub test: TestInterface, + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { + test: TestInterface::new(client.clone()), + client, + } + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } + + #[derive(Debug)] + pub struct TestInterface { + client: C, + } + + impl TestInterface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn endpoint( + &self, + input: reflectapi::Empty, + headers: super::types::reflectapi_demo::tests::basic::HeadersWithHidden, + ) -> Result> { + reflectapi::rt::__request_impl(&self.client, "/test.endpoint", input, headers).await + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod basic { + + #[derive(Debug, serde::Serialize)] + pub struct HeadersWithHidden { + /// Authorization header + pub authorization: std::string::String, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap new file mode 100644 index 00000000..9553baf3 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap @@ -0,0 +1,53 @@ +--- +source: reflectapi-demo/src/tests/basic.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "hidden_header_test", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/test.endpoint": { + "description": "", + "post": { + "operationId": "test.endpoint", + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "description": "empty object", + "type": "object", + "properties": {} + } + } + } + } + }, + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": true, + "schema": { + "$ref": "#/components/schemas/std.string.String" + }, + "description": "Authorization header" + } + ] + } + } + }, + "components": { + "schemas": { + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap new file mode 100644 index 00000000..012d4d2e --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap @@ -0,0 +1,76 @@ +--- +source: reflectapi-demo/src/tests/basic.rs +expression: schema +--- +{ + "name": "hidden_header_test", + "functions": [ + { + "name": "test.endpoint", + "path": "", + "input_headers": { + "name": "reflectapi_demo::tests::basic::HeadersWithHidden" + }, + "output_kind": "complete", + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::basic::HeadersWithHidden", + "fields": { + "named": [ + { + "name": "authorization", + "description": "Authorization header", + "type": { + "name": "std::string::String" + }, + "required": true + }, + { + "name": "x_internal_trace_id", + "description": "Internal tracking header, hidden from clients", + "type": { + "name": "std::string::String" + }, + "hidden": true + } + ] + } + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + } + ] + } +} diff --git a/reflectapi-demo/tests/errors/invalid_reflect_field_type_generic.stderr b/reflectapi-demo/tests/errors/invalid_reflect_field_type_generic.stderr index 243a9cc1..2d33050f 100644 --- a/reflectapi-demo/tests/errors/invalid_reflect_field_type_generic.stderr +++ b/reflectapi-demo/tests/errors/invalid_reflect_field_type_generic.stderr @@ -2,8 +2,13 @@ error[E0277]: the trait bound `NotReflectable: Input` is not satisfied --> tests/errors/invalid_reflect_field_type_generic.rs:3:12 | 3 | field: Vec, - | ^^^^^^^^^^^^^^^^^^^ the trait `Input` is not implemented for `NotReflectable` + | ^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound | +help: the trait `Input` is not implemented for `NotReflectable` + --> tests/errors/invalid_reflect_field_type_generic.rs:6:1 + | +6 | struct NotReflectable; + | ^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `Input`: () (A, B) @@ -20,8 +25,13 @@ error[E0277]: the trait bound `NotReflectable: reflectapi::Output` is not satisf --> tests/errors/invalid_reflect_field_type_generic.rs:3:12 | 3 | field: Vec, - | ^^^^^^^^^^^^^^^^^^^ the trait `reflectapi::Output` is not implemented for `NotReflectable` + | ^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `reflectapi::Output` is not implemented for `NotReflectable` + --> tests/errors/invalid_reflect_field_type_generic.rs:6:1 | +6 | struct NotReflectable; + | ^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `reflectapi::Output`: &'static str () diff --git a/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct.stderr b/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct.stderr index 10b8c297..685814bc 100644 --- a/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct.stderr +++ b/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct.stderr @@ -2,8 +2,13 @@ error[E0277]: the trait bound `TestStructNested: Input` is not satisfied --> tests/errors/invalid_reflect_on_nested_struct.rs:3:9 | 3 | _f: TestStructNested, - | ^^^^^^^^^^^^^^^^ the trait `Input` is not implemented for `TestStructNested` + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound | +help: the trait `Input` is not implemented for `TestStructNested` + --> tests/errors/invalid_reflect_on_nested_struct.rs:6:1 + | +6 | struct TestStructNested { + | ^^^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `Input`: () (A, B) @@ -19,8 +24,13 @@ error[E0277]: the trait bound `TestStructNested: reflectapi::Output` is not sati --> tests/errors/invalid_reflect_on_nested_struct.rs:3:9 | 3 | _f: TestStructNested, - | ^^^^^^^^^^^^^^^^ the trait `reflectapi::Output` is not implemented for `TestStructNested` + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `reflectapi::Output` is not implemented for `TestStructNested` + --> tests/errors/invalid_reflect_on_nested_struct.rs:6:1 | +6 | struct TestStructNested { + | ^^^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `reflectapi::Output`: &'static str () diff --git a/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct_twice.stderr b/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct_twice.stderr index f6f7c4b9..f709453f 100644 --- a/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct_twice.stderr +++ b/reflectapi-demo/tests/errors/invalid_reflect_on_nested_struct_twice.stderr @@ -2,8 +2,13 @@ error[E0277]: the trait bound `TestStructNested: Input` is not satisfied --> tests/errors/invalid_reflect_on_nested_struct_twice.rs:3:9 | 3 | _f: TestStructNested, - | ^^^^^^^^^^^^^^^^ the trait `Input` is not implemented for `TestStructNested` + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound | +help: the trait `Input` is not implemented for `TestStructNested` + --> tests/errors/invalid_reflect_on_nested_struct_twice.rs:6:1 + | +6 | struct TestStructNested { + | ^^^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `Input`: () (A, B) @@ -19,8 +24,13 @@ error[E0277]: the trait bound `TestStructNested: Input` is not satisfied --> tests/errors/invalid_reflect_on_nested_struct_twice.rs:4:10 | 4 | _f2: TestStructNested, - | ^^^^^^^^^^^^^^^^ the trait `Input` is not implemented for `TestStructNested` + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `Input` is not implemented for `TestStructNested` + --> tests/errors/invalid_reflect_on_nested_struct_twice.rs:6:1 | +6 | struct TestStructNested { + | ^^^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `Input`: () (A, B) diff --git a/reflectapi-derive/src/derive.rs b/reflectapi-derive/src/derive.rs index bace5ff3..ad96eac0 100644 --- a/reflectapi-derive/src/derive.rs +++ b/reflectapi-derive/src/derive.rs @@ -397,9 +397,18 @@ fn visit_field(cx: &Context, field: &ast::Field<'_>) -> Option attrs.input_skip || field.attrs.skip_deserializing(), - ReflectType::Output => attrs.output_skip || field.attrs.skip_serializing(), + ReflectType::Input => field.attrs.skip_deserializing(), + ReflectType::Output => field.attrs.skip_serializing(), + } { + return None; + } + // If reflectapi(skip) is set, exclude the field from the schema entirely + // (allows fields whose types don't implement reflectapi traits) + if match cx.reflectapi_type() { + ReflectType::Input => attrs.input_skip, + ReflectType::Output => attrs.output_skip, } { return None; } @@ -423,6 +432,7 @@ fn visit_field(cx: &Context, field: &ast::Field<'_>) -> Option field.attrs.skip_serializing_if().is_none(), }; field_def.flattened = field.attrs.flatten(); + field_def.hidden = attrs.hidden; Some(field_def) } diff --git a/reflectapi-derive/src/parser.rs b/reflectapi-derive/src/parser.rs index 9b7922fd..a0a7f7d5 100644 --- a/reflectapi-derive/src/parser.rs +++ b/reflectapi-derive/src/parser.rs @@ -131,6 +131,7 @@ pub(crate) struct ParsedFieldAttributes { pub output_transform: String, pub input_skip: bool, pub output_skip: bool, + pub hidden: bool, } #[derive(Debug, Default)] @@ -390,6 +391,9 @@ pub(crate) fn parse_field_attributes( // #[reflectapi(skip)] result.input_skip = true; result.output_skip = true; + } else if meta.path == HIDDEN { + // #[reflectapi(hidden)] + result.hidden = true; } else { let path = meta.path.to_token_stream().to_string(); return Err(meta.error(format_args!("unknown reflect field attribute `{path}`"))); diff --git a/reflectapi-derive/src/symbol.rs b/reflectapi-derive/src/symbol.rs index 5c6600aa..48dfef82 100644 --- a/reflectapi-derive/src/symbol.rs +++ b/reflectapi-derive/src/symbol.rs @@ -20,6 +20,8 @@ pub const SKIP: Symbol = Symbol("skip"); pub const INPUT_SKIP: Symbol = Symbol("input_skip"); pub const OUTPUT_SKIP: Symbol = Symbol("output_skip"); +pub const HIDDEN: Symbol = Symbol("hidden"); + pub const DERIVE: Symbol = Symbol("derive"); pub const DISCRIMINANT: Symbol = Symbol("discriminant"); diff --git a/reflectapi-derive/src/tokenizable_schema.rs b/reflectapi-derive/src/tokenizable_schema.rs index 691f1463..4fbd13ef 100644 --- a/reflectapi-derive/src/tokenizable_schema.rs +++ b/reflectapi-derive/src/tokenizable_schema.rs @@ -104,6 +104,7 @@ impl ToTokens for TokenizableField<'_> { let type_ref = TokenizableTypeReference::new(&self.inner.type_ref); let required = self.inner.required; let flattened = self.inner.flattened; + let hidden = self.inner.hidden; let transform_callback = self.inner.transform_callback.as_str(); let mut transform_callback_fn = quote::quote! { None @@ -124,6 +125,7 @@ impl ToTokens for TokenizableField<'_> { type_ref: #type_ref, required: #required, flattened: #flattened, + hidden: #hidden, transform_callback: String::new(), transform_callback_fn: #transform_callback_fn, } diff --git a/reflectapi-schema/src/lib.rs b/reflectapi-schema/src/lib.rs index d1d60f17..dfd789fd 100644 --- a/reflectapi-schema/src/lib.rs +++ b/reflectapi-schema/src/lib.rs @@ -1105,6 +1105,12 @@ pub struct Field { #[serde(skip_serializing_if = "is_false", default)] pub flattened: bool, + /// If true, the field is excluded from generated clients and documentation + /// but is still functional at runtime (e.g. for header extraction). + /// Default is false + #[serde(skip_serializing_if = "is_false", default)] + pub hidden: bool, + #[serde(skip, default)] pub transform_callback: String, #[serde(skip, default)] @@ -1122,6 +1128,7 @@ impl PartialEq for Field { type_ref, required, flattened, + hidden, transform_callback, transform_callback_fn: _, }: &Self, @@ -1133,6 +1140,7 @@ impl PartialEq for Field { && self.type_ref == *type_ref && self.required == *required && self.flattened == *flattened + && self.hidden == *hidden && self.transform_callback == *transform_callback } } @@ -1146,6 +1154,7 @@ impl std::hash::Hash for Field { self.type_ref.hash(state); self.required.hash(state); self.flattened.hash(state); + self.hidden.hash(state); self.transform_callback.hash(state); } } @@ -1160,6 +1169,7 @@ impl Field { deprecation_note: Default::default(), required: Default::default(), flattened: Default::default(), + hidden: Default::default(), transform_callback: Default::default(), transform_callback_fn: Default::default(), } @@ -1198,6 +1208,10 @@ impl Field { self.deprecation_note.is_some() } + pub fn hidden(&self) -> bool { + self.hidden + } + pub fn type_ref(&self) -> &TypeReference { &self.type_ref } diff --git a/reflectapi/src/codegen/openapi.rs b/reflectapi/src/codegen/openapi.rs index e5d708d9..ed5d7c35 100644 --- a/reflectapi/src/codegen/openapi.rs +++ b/reflectapi/src/codegen/openapi.rs @@ -1226,7 +1226,10 @@ impl Converter<'_> { return self.convert_type_ref(schema, kind, strukt.fields[0].type_ref()); } - let (flattened_fields, fields) = strukt.fields().partition::, _>(|f| f.flattened); + let (flattened_fields, fields) = strukt + .fields() + .filter(|f| !f.hidden) + .partition::, _>(|f| f.flattened); let ty = if fields.iter().all(|f| f.is_named()) { Type::Object { diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 9100294d..ce6c34d7 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -743,7 +743,11 @@ fn render_struct_with_flattened_internal_enum( // Collect the parent struct's non-flattened fields + any flattened struct fields let mut base_fields: Vec = Vec::new(); - for field in struct_def.fields.iter().filter(|f| !f.flattened()) { + for field in struct_def + .fields + .iter() + .filter(|f| !f.flattened() && !f.hidden()) + { let (python_name, alias) = sanitize_field_name_with_alias(field.name(), field.serde_name()); let field_type = type_ref_to_python_type( &field.type_ref, @@ -775,7 +779,11 @@ fn render_struct_with_flattened_internal_enum( } // Also expand any flattened struct fields (non-enum) into base_fields - for field in struct_def.fields.iter().filter(|f| f.flattened()) { + for field in struct_def + .fields + .iter() + .filter(|f| f.flattened() && !f.hidden()) + { let type_name = resolve_flattened_type_name(&field.type_ref); if let Some(reflectapi_schema::Type::Struct(_)) = schema.get_type(type_name) { let flattened = collect_flattened_fields( @@ -899,7 +907,7 @@ fn render_struct_with_flattened_internal_enum( if let Some(reflectapi_schema::Type::Struct(inner_struct)) = schema.get_type(inner_name) { - for sf in inner_struct.fields.iter() { + for sf in inner_struct.fields.iter().filter(|f| !f.hidden()) { if sf.flattened() { // The inner struct has its own flatten — expand // those fields inline so the wire shape is @@ -1007,7 +1015,11 @@ fn render_struct_with_flatten_standard( let mut all_fields = Vec::new(); // Add regular fields - for field in struct_def.fields.iter().filter(|f| !f.flattened()) { + for field in struct_def + .fields + .iter() + .filter(|f| !f.flattened() && !f.hidden()) + { let (python_name, alias) = sanitize_field_name_with_alias(field.name(), field.serde_name()); let field_type = type_ref_to_python_type( &field.type_ref, @@ -1040,7 +1052,11 @@ fn render_struct_with_flatten_standard( } // Add flattened fields (expanded directly) - for field in struct_def.fields.iter().filter(|f| f.flattened()) { + for field in struct_def + .fields + .iter() + .filter(|f| f.flattened() && !f.hidden()) + { let flattened_fields = collect_flattened_fields( &field.type_ref, schema, diff --git a/reflectapi/src/codegen/rust.rs b/reflectapi/src/codegen/rust.rs index 0797cc83..b467bfd1 100644 --- a/reflectapi/src/codegen/rust.rs +++ b/reflectapi/src/codegen/rust.rs @@ -1317,6 +1317,7 @@ fn __render_type( fields: struct_def .fields .iter() + .filter(|field| !field.hidden) .map(|field| templates::__Field { name: __field_name_to_snake_case(field.name()), serde_name: field.serde_name().into(), @@ -1358,6 +1359,7 @@ fn __render_type( fields: variant .fields .iter() + .filter(|field| !field.hidden) .map(|field| templates::__Field { name: __field_name_to_snake_case(field.name()), serde_name: field.serde_name().into(), diff --git a/reflectapi/src/codegen/typescript.rs b/reflectapi/src/codegen/typescript.rs index 9959f217..823eb415 100644 --- a/reflectapi/src/codegen/typescript.rs +++ b/reflectapi/src/codegen/typescript.rs @@ -1002,13 +1002,13 @@ fn render_type( fields: struct_def .fields .iter() - .filter(|f| !f.flattened) + .filter(|f| !f.flattened && !f.hidden) .map(|f| field_to_ts_field(f, schema, implemented_types)) .collect::>(), flattened_types: struct_def .fields .iter() - .filter(|f| f.flattened) + .filter(|f| f.flattened && !f.hidden) .map(|field| { let type_ref = type_ref_to_ts_ref(&field.type_ref, schema, implemented_types); @@ -1079,12 +1079,14 @@ fn fields_to_ts_fields( reflectapi_schema::Fields::Named(fields) => templates::Fields::Named( fields .iter() + .filter(|f| !f.hidden) .map(|f| field_to_ts_field(f, schema, implemented_types)) .collect::>(), ), reflectapi_schema::Fields::Unnamed(fields) => templates::Fields::Unnamed( fields .iter() + .filter(|f| !f.hidden) .map(|f| field_to_ts_field(f, schema, implemented_types)) .collect(), ), diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7855e6d5..1a216558 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.92.0" components = ["rustfmt", "clippy"] From 92699ef1c7b6428640f4ffe7dea7dea4123743fc Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Wed, 10 Jun 2026 06:23:31 +0000 Subject: [PATCH 2/8] add docs for the feature --- docs/src/SUMMARY.md | 1 + docs/src/getting-started/attributes.md | 67 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 docs/src/getting-started/attributes.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 8532f05e..1c7e7508 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,4 +7,5 @@ - [Quick Start](./getting-started/quick-start.md) - [Installation](./getting-started/installation.md) +- [Attributes Reference](./getting-started/attributes.md) - [Client Libraries](./clients/README.md) diff --git a/docs/src/getting-started/attributes.md b/docs/src/getting-started/attributes.md new file mode 100644 index 00000000..92059aff --- /dev/null +++ b/docs/src/getting-started/attributes.md @@ -0,0 +1,67 @@ +# Attributes Reference + +ReflectAPI provides `#[reflectapi(...)]` attributes that control how Rust types are reflected into the schema and generated clients. + +## Struct / Enum Level + +| Attribute | Description | +|-----------|-------------| +| `#[reflectapi(derive(...))]` | Forward additional derive traits to the generated Rust client type. | + +## Field Level + +### Type Override + +| Attribute | Description | +|-----------|-------------| +| `#[reflectapi(type = "T")]` | Override the reflected type for both input and output schemas. | +| `#[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. | + +### Transform + +| 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. | + +### Visibility + +| Attribute | Description | +|-----------|-------------| +| `#[reflectapi(skip)]` | Exclude the field entirely from the schema. The field's type does not need to implement `Input`/`Output`. Equivalent to setting both `input_skip` and `output_skip`. | +| `#[reflectapi(input_skip)]` | Exclude the field from the input schema only. | +| `#[reflectapi(output_skip)]` | Exclude the field from the output schema only. | +| `#[reflectapi(hidden)]` | Keep the field in the schema (marked `"hidden": true`) but exclude it from generated clients, documentation, and OpenAPI specs. Useful for header fields that the server needs at runtime but clients should not see. | + +### `skip` vs `hidden` + +Both attributes remove a field from generated clients. The key difference: + +- **`skip`** removes the field from the schema entirely. The field's type is never reflected, so it does not need to implement `Input` or `Output`. Use this for internal bookkeeping fields whose types are not part of your API. + +- **`hidden`** keeps the field in the schema with a `hidden` flag. The type must still implement the relevant trait. At runtime, the axum adapter will still extract and deserialize the field (e.g. for headers). Use this for fields that are functionally required on the server side but should not appear in client interfaces or documentation. + +**Example: hidden header field** + +```rust,ignore +#[derive(serde::Deserialize, reflectapi::Input)] +pub struct MyHeaders { + /// Visible to clients — they must provide this + pub authorization: String, + + /// Server-internal: extracted by axum but not exposed to clients + #[reflectapi(hidden)] + #[serde(default)] + pub x_internal_request_id: String, +} +``` + +## Enum Variant Level + +| Attribute | Description | +|-----------|-------------| +| `#[reflectapi(skip)]` | Exclude the variant from the schema entirely. | +| `#[reflectapi(input_skip)]` | Exclude the variant from the input schema only. | +| `#[reflectapi(output_skip)]` | Exclude the variant from the output schema only. | From c98c25f045f8159f1d4b269513422f679b484c7a Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Wed, 10 Jun 2026 06:57:20 +0000 Subject: [PATCH 3/8] remove compiler warnings --- reflectapi-demo/src/tests/basic.rs | 4 ++-- ...__basic__reflectapi_struct_with_hidden_header_field-2.snap | 2 +- ...__basic__reflectapi_struct_with_hidden_header_field-3.snap | 2 +- ...__basic__reflectapi_struct_with_hidden_header_field-4.snap | 2 +- ...ts__basic__reflectapi_struct_with_hidden_header_field.snap | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/reflectapi-demo/src/tests/basic.rs b/reflectapi-demo/src/tests/basic.rs index a739760e..34caa001 100644 --- a/reflectapi-demo/src/tests/basic.rs +++ b/reflectapi-demo/src/tests/basic.rs @@ -649,11 +649,11 @@ fn test_reflectapi_struct_with_hidden_header_field() { #[derive(serde::Deserialize, reflectapi::Input)] struct HeadersWithHidden { /// Authorization header - authorization: String, + _authorization: String, /// Internal tracking header, hidden from clients #[reflectapi(hidden)] #[serde(default)] - x_internal_trace_id: String, + _x_internal_trace_id: String, } assert_builder_snapshot!(reflectapi::Builder::<()>::new() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap index 6d0adbbf..f06061f7 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-2.snap @@ -43,7 +43,7 @@ export namespace reflectapi_demo { /** * Authorization header */ - authorization: string; + _authorization: string; } } } diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap index 3eeb80f1..f3819118 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-3.snap @@ -72,7 +72,7 @@ pub mod types { #[derive(Debug, serde::Serialize)] pub struct HeadersWithHidden { /// Authorization header - pub authorization: std::string::String, + pub _authorization: std::string::String, } } } diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap index 9553baf3..3e837af7 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap @@ -30,7 +30,7 @@ expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" }, "parameters": [ { - "name": "authorization", + "name": "_authorization", "in": "header", "required": true, "schema": { diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap index 012d4d2e..fcf6bb9a 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field.snap @@ -32,7 +32,7 @@ expression: schema "fields": { "named": [ { - "name": "authorization", + "name": "_authorization", "description": "Authorization header", "type": { "name": "std::string::String" @@ -40,7 +40,7 @@ expression: schema "required": true }, { - "name": "x_internal_trace_id", + "name": "_x_internal_trace_id", "description": "Internal tracking header, hidden from clients", "type": { "name": "std::string::String" From 933b7f35f44f19d15b17684eb79439f63cedd4c2 Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Wed, 10 Jun 2026 07:19:22 +0000 Subject: [PATCH 4/8] fix clippy errors --- reflectapi-demo/src/tests/basic.rs | 3 ++- ...ic_field_string_reflectapi_both_equally2-2.snap | 10 +++++----- ...ic_field_string_reflectapi_both_equally2-3.snap | 6 +++--- ...ic_field_string_reflectapi_both_equally2-4.snap | 14 +++++++------- ...asic_field_string_reflectapi_both_equally2.snap | 4 ++-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/reflectapi-demo/src/tests/basic.rs b/reflectapi-demo/src/tests/basic.rs index 34caa001..454472bb 100644 --- a/reflectapi-demo/src/tests/basic.rs +++ b/reflectapi-demo/src/tests/basic.rs @@ -66,7 +66,7 @@ struct TestStructOneBasicFieldStringReflectBothEqually2 { } #[test] fn test_reflectapi_struct_one_basic_field_string_reflectapi_both_equally2() { - assert_input_snapshot!(TestStructOneBasicFieldStringReflectBothEqually); + assert_input_snapshot!(TestStructOneBasicFieldStringReflectBothEqually2); } #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] @@ -585,6 +585,7 @@ fn test_reflectapi_struct_with_additional_derives() { Hash, Default, )] + #[allow(clippy::duplicated_attributes)] #[reflectapi(derive( Clone, PartialOrd, diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-2.snap index b7aafd4a..73105294 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-2.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-2.snap @@ -1,6 +1,6 @@ --- source: reflectapi-demo/src/tests/basic.rs -expression: "super :: into_input_typescript_code :: <\nTestStructOneBasicFieldStringReflectBothEqually > ()" +expression: "super :: into_input_typescript_code :: <\nTestStructOneBasicFieldStringReflectBothEqually2 > ()" --- // DO NOT MODIFY THIS FILE MANUALLY // This file was generated by reflectapi-cli @@ -14,7 +14,7 @@ export function client(base: string | Client): __definition.Interface { export namespace __definition { export interface Interface { input_test: ( - input: reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually, + input: reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually2, headers: {}, options?: RequestOptions, ) => AsyncResult<{}, {}>; @@ -35,7 +35,7 @@ export namespace reflectapi { export namespace reflectapi_demo { export namespace tests { export namespace basic { - export interface TestStructOneBasicFieldStringReflectBothEqually { + export interface TestStructOneBasicFieldStringReflectBothEqually2 { _f: number /* u32 */; } } @@ -46,12 +46,12 @@ namespace __implementation { function input_test(client: Client) { return ( - input: reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually, + input: reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually2, headers: {}, options?: RequestOptions, ) => __request< - reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually, + reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually2, {}, {}, {} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-3.snap index 6993e17b..ac1d32ab 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-3.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-3.snap @@ -1,6 +1,6 @@ --- source: reflectapi-demo/src/tests/basic.rs -expression: "super :: into_input_rust_code :: <\nTestStructOneBasicFieldStringReflectBothEqually > ()" +expression: "super :: into_input_rust_code :: <\nTestStructOneBasicFieldStringReflectBothEqually2 > ()" --- // DO NOT MODIFY THIS FILE MANUALLY // This file was generated by reflectapi-cli @@ -26,7 +26,7 @@ pub mod interface { } pub async fn input_test( &self, - input: super::types::reflectapi_demo::tests::basic::TestStructOneBasicFieldStringReflectBothEqually, + input: super::types::reflectapi_demo::tests::basic::TestStructOneBasicFieldStringReflectBothEqually2, headers: reflectapi::Empty, ) -> Result> { reflectapi::rt::__request_impl(&self.client, "/input_test", input, headers).await @@ -55,7 +55,7 @@ pub mod types { pub mod basic { #[derive(Debug, serde::Serialize)] - pub struct TestStructOneBasicFieldStringReflectBothEqually { + pub struct TestStructOneBasicFieldStringReflectBothEqually2 { pub _f: u32, } } diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-4.snap index f420dd4c..df426787 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-4.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2-4.snap @@ -1,6 +1,6 @@ --- source: reflectapi-demo/src/tests/basic.rs -expression: "super :: into_input_python_code :: <\nTestStructOneBasicFieldStringReflectBothEqually > ()" +expression: "super :: into_input_python_code :: <\nTestStructOneBasicFieldStringReflectBothEqually2 > ()" --- """ DO NOT MODIFY THIS FILE MANUALLY @@ -24,7 +24,7 @@ from reflectapi_runtime import ReflectapiEmpty from reflectapi_runtime import ReflectapiInfallible -class ReflectapiDemoTestsBasicTestStructOneBasicFieldStringReflectBothEqually( +class ReflectapiDemoTestsBasicTestStructOneBasicFieldStringReflectBothEqually2( BaseModel ): model_config = ConfigDict( @@ -44,8 +44,8 @@ class reflectapi_demo: class basic: """Namespace for basic types.""" - TestStructOneBasicFieldStringReflectBothEqually = ( - ReflectapiDemoTestsBasicTestStructOneBasicFieldStringReflectBothEqually + TestStructOneBasicFieldStringReflectBothEqually2 = ( + ReflectapiDemoTestsBasicTestStructOneBasicFieldStringReflectBothEqually2 ) @@ -58,7 +58,7 @@ class AsyncInputClient: async def test( self, data: Optional[ - reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually + reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually2 ] = None, ) -> ApiResponse[Any]: """ @@ -102,7 +102,7 @@ class InputClient: def test( self, data: Optional[ - reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually + reflectapi_demo.tests.basic.TestStructOneBasicFieldStringReflectBothEqually2 ] = None, ) -> ApiResponse[Any]: """ @@ -146,7 +146,7 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] # Rebuild models to resolve forward references _rebuild_errors: list[str] = [] for _model in [ - ReflectapiDemoTestsBasicTestStructOneBasicFieldStringReflectBothEqually, + ReflectapiDemoTestsBasicTestStructOneBasicFieldStringReflectBothEqually2, ]: if not hasattr(_model, "model_rebuild"): continue diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2.snap index 56a496ce..fb5b6e86 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_one_basic_field_string_reflectapi_both_equally2.snap @@ -1,6 +1,6 @@ --- source: reflectapi-demo/src/tests/basic.rs -expression: "super::into_input_schema::().input_types" +expression: "super :: into_input_schema :: <\nTestStructOneBasicFieldStringReflectBothEqually2 > ().input_types" --- { "types": [ @@ -12,7 +12,7 @@ expression: "super::into_input_schema:: Date: Wed, 10 Jun 2026 21:01:20 +0000 Subject: [PATCH 5/8] addressed review comment about leaked hidden fields --- ...api_struct_with_hidden_header_field-4.snap | 9 ++++++++ reflectapi-schema/src/lib.rs | 21 +++++++++++++++++++ reflectapi/src/codegen/openapi.rs | 5 +++-- reflectapi/src/codegen/python.rs | 11 +++++----- reflectapi/src/codegen/rust.rs | 3 +-- reflectapi/src/codegen/typescript.rs | 7 +++---- 6 files changed, 43 insertions(+), 13 deletions(-) diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap index 3e837af7..73087c8f 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap @@ -37,6 +37,15 @@ expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" "$ref": "#/components/schemas/std.string.String" }, "description": "Authorization header" + }, + { + "name": "_x_internal_trace_id", + "in": "header", + "required": false, + "schema": { + "$ref": "#/components/schemas/std.string.String" + }, + "description": "Internal tracking header, hidden from clients" } ] } diff --git a/reflectapi-schema/src/lib.rs b/reflectapi-schema/src/lib.rs index dfd789fd..056de5ae 100644 --- a/reflectapi-schema/src/lib.rs +++ b/reflectapi-schema/src/lib.rs @@ -200,6 +200,27 @@ impl Schema { } } + /// Remove fields marked as `hidden` from all struct and enum variant fields + /// in both input and output typespaces. Intended to be called at codegen + /// entry points so that no backend can accidentally leak hidden fields. + pub fn strip_hidden_fields(&mut self) { + fn strip(ts: &mut Typespace) { + for ty in ts.types.iter_mut() { + match ty { + Type::Struct(s) => s.fields.retain(|f| !f.hidden), + Type::Enum(e) => { + for v in e.variants.iter_mut() { + v.fields.retain(|f| !f.hidden); + } + } + Type::Primitive(_) => {} + } + } + } + strip(&mut self.input_types); + strip(&mut self.output_types); + } + pub fn fold_transparent_types(&mut self) { // Replace the transparent struct `strukt` with it's single field. #[derive(Debug)] diff --git a/reflectapi/src/codegen/openapi.rs b/reflectapi/src/codegen/openapi.rs index ed5d7c35..3d706feb 100644 --- a/reflectapi/src/codegen/openapi.rs +++ b/reflectapi/src/codegen/openapi.rs @@ -53,12 +53,14 @@ pub fn generate(schema: &crate::Schema, config: &Config) -> anyhow::Result Spec { + let mut schema = schema.clone(); + schema.strip_hidden_fields(); Converter { config, components: Default::default(), in_progress: Default::default(), } - .convert(schema) + .convert(&schema) } impl From<&crate::Schema> for Spec { @@ -1228,7 +1230,6 @@ impl Converter<'_> { let (flattened_fields, fields) = strukt .fields() - .filter(|f| !f.hidden) .partition::, _>(|f| f.flattened); let ty = if fields.iter().all(|f| f.is_named()) { diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index ce6c34d7..60daace5 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -746,7 +746,7 @@ fn render_struct_with_flattened_internal_enum( for field in struct_def .fields .iter() - .filter(|f| !f.flattened() && !f.hidden()) + .filter(|f| !f.flattened()) { let (python_name, alias) = sanitize_field_name_with_alias(field.name(), field.serde_name()); let field_type = type_ref_to_python_type( @@ -782,7 +782,7 @@ fn render_struct_with_flattened_internal_enum( for field in struct_def .fields .iter() - .filter(|f| f.flattened() && !f.hidden()) + .filter(|f| f.flattened()) { let type_name = resolve_flattened_type_name(&field.type_ref); if let Some(reflectapi_schema::Type::Struct(_)) = schema.get_type(type_name) { @@ -907,7 +907,7 @@ fn render_struct_with_flattened_internal_enum( if let Some(reflectapi_schema::Type::Struct(inner_struct)) = schema.get_type(inner_name) { - for sf in inner_struct.fields.iter().filter(|f| !f.hidden()) { + for sf in inner_struct.fields.iter() { if sf.flattened() { // The inner struct has its own flatten — expand // those fields inline so the wire shape is @@ -1018,7 +1018,7 @@ fn render_struct_with_flatten_standard( for field in struct_def .fields .iter() - .filter(|f| !f.flattened() && !f.hidden()) + .filter(|f| !f.flattened()) { let (python_name, alias) = sanitize_field_name_with_alias(field.name(), field.serde_name()); let field_type = type_ref_to_python_type( @@ -1055,7 +1055,7 @@ fn render_struct_with_flatten_standard( for field in struct_def .fields .iter() - .filter(|f| f.flattened() && !f.hidden()) + .filter(|f| f.flattened()) { let flattened_fields = collect_flattened_fields( &field.type_ref, @@ -1593,6 +1593,7 @@ fn build_python_generation( mut schema: Schema, config: &Config, ) -> anyhow::Result { + schema.strip_hidden_fields(); // `PhantomData` is a Rust-only type-system marker — it carries // no wire data. Strip every such field before rendering so the // Python model doesn't reference a non-existent diff --git a/reflectapi/src/codegen/rust.rs b/reflectapi/src/codegen/rust.rs index b467bfd1..64d62536 100644 --- a/reflectapi/src/codegen/rust.rs +++ b/reflectapi/src/codegen/rust.rs @@ -138,6 +138,7 @@ fn types_referenced_by( } pub fn generate(mut schema: crate::Schema, config: &Config) -> anyhow::Result { + schema.strip_hidden_fields(); let mut implemented_types = __build_implemented_types(); for type_def in schema .input_types() @@ -1317,7 +1318,6 @@ fn __render_type( fields: struct_def .fields .iter() - .filter(|field| !field.hidden) .map(|field| templates::__Field { name: __field_name_to_snake_case(field.name()), serde_name: field.serde_name().into(), @@ -1359,7 +1359,6 @@ fn __render_type( fields: variant .fields .iter() - .filter(|field| !field.hidden) .map(|field| templates::__Field { name: __field_name_to_snake_case(field.name()), serde_name: field.serde_name().into(), diff --git a/reflectapi/src/codegen/typescript.rs b/reflectapi/src/codegen/typescript.rs index 823eb415..6a7191a4 100644 --- a/reflectapi/src/codegen/typescript.rs +++ b/reflectapi/src/codegen/typescript.rs @@ -47,6 +47,7 @@ pub fn generate( mut schema: crate::Schema, config: &Config, ) -> anyhow::Result> { + schema.strip_hidden_fields(); let implemented_types = build_implemented_types(); let mut rendered_types = HashMap::new(); @@ -1002,13 +1003,13 @@ fn render_type( fields: struct_def .fields .iter() - .filter(|f| !f.flattened && !f.hidden) + .filter(|f| !f.flattened) .map(|f| field_to_ts_field(f, schema, implemented_types)) .collect::>(), flattened_types: struct_def .fields .iter() - .filter(|f| f.flattened && !f.hidden) + .filter(|f| f.flattened) .map(|field| { let type_ref = type_ref_to_ts_ref(&field.type_ref, schema, implemented_types); @@ -1079,14 +1080,12 @@ fn fields_to_ts_fields( reflectapi_schema::Fields::Named(fields) => templates::Fields::Named( fields .iter() - .filter(|f| !f.hidden) .map(|f| field_to_ts_field(f, schema, implemented_types)) .collect::>(), ), reflectapi_schema::Fields::Unnamed(fields) => templates::Fields::Unnamed( fields .iter() - .filter(|f| !f.hidden) .map(|f| field_to_ts_field(f, schema, implemented_types)) .collect(), ), From bd3916b2ab3fb68bb8ec6942b09ff2ad2edbdf74 Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Wed, 10 Jun 2026 21:18:49 +0000 Subject: [PATCH 6/8] addressed review comment: hidden and skip can only be possible if serde default is set --- reflectapi-demo/src/tests/basic.rs | 2 ++ .../errors/hidden_field_without_default.rs | 7 ++++++ .../hidden_field_without_default.stderr | 5 +++++ .../input_skip_field_without_default.rs | 7 ++++++ .../input_skip_field_without_default.stderr | 5 +++++ .../errors/skip_field_without_default.rs | 7 ++++++ .../errors/skip_field_without_default.stderr | 5 +++++ reflectapi-derive/src/derive.rs | 22 +++++++++++++++++++ 8 files changed, 60 insertions(+) create mode 100644 reflectapi-demo/tests/errors/hidden_field_without_default.rs create mode 100644 reflectapi-demo/tests/errors/hidden_field_without_default.stderr create mode 100644 reflectapi-demo/tests/errors/input_skip_field_without_default.rs create mode 100644 reflectapi-demo/tests/errors/input_skip_field_without_default.stderr create mode 100644 reflectapi-demo/tests/errors/skip_field_without_default.rs create mode 100644 reflectapi-demo/tests/errors/skip_field_without_default.stderr diff --git a/reflectapi-demo/src/tests/basic.rs b/reflectapi-demo/src/tests/basic.rs index 454472bb..6ed34247 100644 --- a/reflectapi-demo/src/tests/basic.rs +++ b/reflectapi-demo/src/tests/basic.rs @@ -510,6 +510,7 @@ fn test_reflectapi_enum_with_skip_variant() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithSkipField { #[reflectapi(skip)] + #[serde(default)] _f: u8, } @@ -521,6 +522,7 @@ fn test_reflectapi_struct_with_skip_field() { #[derive(reflectapi::Input, reflectapi::Output, serde::Deserialize, serde::Serialize)] struct TestStructWithSkipFieldInput { #[reflectapi(input_skip)] + #[serde(default)] _f: u8, } #[test] diff --git a/reflectapi-demo/tests/errors/hidden_field_without_default.rs b/reflectapi-demo/tests/errors/hidden_field_without_default.rs new file mode 100644 index 00000000..37d646c6 --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_field_without_default.rs @@ -0,0 +1,7 @@ +#[derive(serde::Deserialize, reflectapi::Input)] +struct MyStruct { + #[reflectapi(hidden)] + field: u32, +} + +fn main() {} diff --git a/reflectapi-demo/tests/errors/hidden_field_without_default.stderr b/reflectapi-demo/tests/errors/hidden_field_without_default.stderr new file mode 100644 index 00000000..fcd88046 --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_field_without_default.stderr @@ -0,0 +1,5 @@ +error: field is hidden from clients but has no default value; add `#[serde(default)]` so deserialization succeeds when clients omit it + --> tests/errors/hidden_field_without_default.rs:3:5 + | +3 | #[reflectapi(hidden)] + | ^ diff --git a/reflectapi-demo/tests/errors/input_skip_field_without_default.rs b/reflectapi-demo/tests/errors/input_skip_field_without_default.rs new file mode 100644 index 00000000..4e143a97 --- /dev/null +++ b/reflectapi-demo/tests/errors/input_skip_field_without_default.rs @@ -0,0 +1,7 @@ +#[derive(serde::Deserialize, reflectapi::Input)] +struct MyStruct { + #[reflectapi(input_skip)] + field: u32, +} + +fn main() {} diff --git a/reflectapi-demo/tests/errors/input_skip_field_without_default.stderr b/reflectapi-demo/tests/errors/input_skip_field_without_default.stderr new file mode 100644 index 00000000..ed3ce8e9 --- /dev/null +++ b/reflectapi-demo/tests/errors/input_skip_field_without_default.stderr @@ -0,0 +1,5 @@ +error: field is excluded from the input schema (via `skip` or `input_skip`) but has no default value; add `#[serde(default)]` so deserialization succeeds when clients omit it + --> tests/errors/input_skip_field_without_default.rs:3:5 + | +3 | #[reflectapi(input_skip)] + | ^ diff --git a/reflectapi-demo/tests/errors/skip_field_without_default.rs b/reflectapi-demo/tests/errors/skip_field_without_default.rs new file mode 100644 index 00000000..961890bc --- /dev/null +++ b/reflectapi-demo/tests/errors/skip_field_without_default.rs @@ -0,0 +1,7 @@ +#[derive(serde::Deserialize, reflectapi::Input)] +struct MyStruct { + #[reflectapi(skip)] + field: u32, +} + +fn main() {} diff --git a/reflectapi-demo/tests/errors/skip_field_without_default.stderr b/reflectapi-demo/tests/errors/skip_field_without_default.stderr new file mode 100644 index 00000000..ce5b045a --- /dev/null +++ b/reflectapi-demo/tests/errors/skip_field_without_default.stderr @@ -0,0 +1,5 @@ +error: field is excluded from the input schema (via `skip` or `input_skip`) but has no default value; add `#[serde(default)]` so deserialization succeeds when clients omit it + --> tests/errors/skip_field_without_default.rs:3:5 + | +3 | #[reflectapi(skip)] + | ^ diff --git a/reflectapi-derive/src/derive.rs b/reflectapi-derive/src/derive.rs index ad96eac0..17c2167f 100644 --- a/reflectapi-derive/src/derive.rs +++ b/reflectapi-derive/src/derive.rs @@ -410,8 +410,30 @@ fn visit_field(cx: &Context, field: &ast::Field<'_>) -> Option attrs.input_skip, ReflectType::Output => attrs.output_skip, } { + // Guard: a skipped Input field without a serde default is unrepresentable — + // clients won't send it, so deserialization will fail at runtime. + if matches!(cx.reflectapi_type(), ReflectType::Input) && field.attrs.default().is_none() { + cx.impl_error( + field.original, + "field is excluded from the input schema (via `skip` or `input_skip`) but has no \ + default value; add `#[serde(default)]` so deserialization succeeds when clients \ + omit it", + ); + } return None; } + // Guard: a hidden Input field without a serde default is unrepresentable — + // clients can't see it, so deserialization will fail at runtime. + if attrs.hidden + && matches!(cx.reflectapi_type(), ReflectType::Input) + && field.attrs.default().is_none() + { + cx.impl_error( + field.original, + "field is hidden from clients but has no default value; \ + add `#[serde(default)]` so deserialization succeeds when clients omit it", + ); + } let (field_type, field_transform) = match cx.reflectapi_type() { ReflectType::Input => (attrs.input_type, attrs.input_transform), ReflectType::Output => (attrs.output_type, attrs.output_transform), From f08b89eba6818a92e3306e9738069f6a5716dbe3 Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Thu, 11 Jun 2026 02:06:38 +0000 Subject: [PATCH 7/8] addressed review comment on documents --- docs/src/getting-started/attributes.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/src/getting-started/attributes.md b/docs/src/getting-started/attributes.md index 92059aff..e30fd918 100644 --- a/docs/src/getting-started/attributes.md +++ b/docs/src/getting-started/attributes.md @@ -41,7 +41,13 @@ Both attributes remove a field from generated clients. The key difference: - **`skip`** removes the field from the schema entirely. The field's type is never reflected, so it does not need to implement `Input` or `Output`. Use this for internal bookkeeping fields whose types are not part of your API. -- **`hidden`** keeps the field in the schema with a `hidden` flag. The type must still implement the relevant trait. At runtime, the axum adapter will still extract and deserialize the field (e.g. for headers). Use this for fields that are functionally required on the server side but should not appear in client interfaces or documentation. +- **`hidden`** keeps the field in the schema JSON (marked with `"hidden": true`) but excludes it from generated clients, documentation, and OpenAPI specs. The type must still implement the relevant trait. Use this for fields that are functionally required by server-side infrastructure — for example, a middleware or a proxy layer that populates the field / header before deserialization — but should not appear in client interfaces. + +**When to use `hidden` over `skip`:** The field stays in the schema JSON so that server-side tooling (the axum adapter, middleware, or custom infrastructure) can inspect the full type structure at runtime. If nothing on the server needs the field's schema metadata, prefer `skip`. + +Neither `skip` nor `hidden` affects serde serialization. For output types, serde will still serialize a hidden field onto the wire — `hidden` only controls what generated code and documentation show. If a field must never appear in responses, use `#[serde(skip_serializing)]` instead. + +Please not that neither `skip` nor `hidden` prevent a malicious client from sending the fields in a request. A middleware may overwrite it or reject or validate as needed. It is up to the specific implementation of your server. **Example: hidden header field** @@ -51,7 +57,8 @@ pub struct MyHeaders { /// Visible to clients — they must provide this pub authorization: String, - /// Server-internal: extracted by axum but not exposed to clients + /// Not visible to the generated clients and documentation + /// Expected to be populated by a proxy or server-side middleware. #[reflectapi(hidden)] #[serde(default)] pub x_internal_request_id: String, From 27a68d1942f913395a234833a8e514279e236720 Mon Sep 17 00:00:00 2001 From: Andrey Konstantinov Date: Thu, 11 Jun 2026 02:27:44 +0000 Subject: [PATCH 8/8] made serde(default) as developers choice, not enforced. blocked hidden on unnamed fields --- docs/src/getting-started/attributes.md | 12 ++++++++++ .../hidden_field_without_default.stderr | 5 ---- .../hidden_on_enum_tuple_variant_field.rs | 9 +++++++ .../hidden_on_enum_tuple_variant_field.stderr | 5 ++++ .../errors/hidden_on_newtype_struct_field.rs | 7 ++++++ .../hidden_on_newtype_struct_field.stderr | 5 ++++ .../errors/hidden_on_tuple_struct_field.rs | 7 ++++++ .../hidden_on_tuple_struct_field.stderr | 5 ++++ .../input_skip_field_without_default.stderr | 5 ---- .../errors/skip_field_without_default.stderr | 5 ---- .../hidden_field_without_default.rs | 0 .../input_skip_field_without_default.rs | 0 .../skip_field_without_default.rs | 0 reflectapi-derive/src/derive.rs | 23 ++++-------------- reflectapi/src/codegen/openapi.rs | 4 +--- reflectapi/src/codegen/python.rs | 24 ++++--------------- 16 files changed, 60 insertions(+), 56 deletions(-) delete mode 100644 reflectapi-demo/tests/errors/hidden_field_without_default.stderr create mode 100644 reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.rs create mode 100644 reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.stderr create mode 100644 reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.rs create mode 100644 reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.stderr create mode 100644 reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.rs create mode 100644 reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.stderr delete mode 100644 reflectapi-demo/tests/errors/input_skip_field_without_default.stderr delete mode 100644 reflectapi-demo/tests/errors/skip_field_without_default.stderr rename reflectapi-demo/tests/{errors => success}/hidden_field_without_default.rs (100%) rename reflectapi-demo/tests/{errors => success}/input_skip_field_without_default.rs (100%) rename reflectapi-demo/tests/{errors => success}/skip_field_without_default.rs (100%) diff --git a/docs/src/getting-started/attributes.md b/docs/src/getting-started/attributes.md index e30fd918..d7cc3eb4 100644 --- a/docs/src/getting-started/attributes.md +++ b/docs/src/getting-started/attributes.md @@ -65,6 +65,18 @@ pub struct MyHeaders { } ``` +### `#[serde(default)]` on skipped and hidden fields + +When a field is excluded from generated clients (via `skip`, `input_skip`, or `hidden`), clients will not send it. Whether you add `#[serde(default)]` is your choice and depends on your deployment: + +- **With `#[serde(default)]`:** If the field is absent, serde fills the default value. The request succeeds even if no proxy or middleware populates the field. Use this for optional metadata (trace IDs, correlation IDs) where absence is acceptable. + +- **Without `#[serde(default)]`:** If the field is absent, deserialization fails with a protocol error. Use this for fields that a proxy or middleware is expected to inject — a missing value means the infrastructure is misconfigured, and you want to reject the request loudly rather than proceed silently with a zero-value. + +### Restrictions + +`#[reflectapi(hidden)]` cannot be used on unnamed (tuple) struct or enum variant fields. Hiding a positional element would shift indices in generated clients, breaking wire compatibility. Use `hidden` only on named fields. + ## Enum Variant Level | Attribute | Description | diff --git a/reflectapi-demo/tests/errors/hidden_field_without_default.stderr b/reflectapi-demo/tests/errors/hidden_field_without_default.stderr deleted file mode 100644 index fcd88046..00000000 --- a/reflectapi-demo/tests/errors/hidden_field_without_default.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: field is hidden from clients but has no default value; add `#[serde(default)]` so deserialization succeeds when clients omit it - --> tests/errors/hidden_field_without_default.rs:3:5 - | -3 | #[reflectapi(hidden)] - | ^ diff --git a/reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.rs b/reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.rs new file mode 100644 index 00000000..4ead136b --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.rs @@ -0,0 +1,9 @@ +#[derive(serde::Deserialize, reflectapi::Input)] +enum MyEnum { + Variant( + #[reflectapi(hidden)] + u32, + ), +} + +fn main() {} diff --git a/reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.stderr b/reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.stderr new file mode 100644 index 00000000..3a78c9a2 --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_on_enum_tuple_variant_field.stderr @@ -0,0 +1,5 @@ +error: `hidden` cannot be used on unnamed (tuple) fields because removing a positional element shifts indices and breaks wire compatibility in generated clients + --> tests/errors/hidden_on_enum_tuple_variant_field.rs:4:9 + | +4 | #[reflectapi(hidden)] + | ^ diff --git a/reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.rs b/reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.rs new file mode 100644 index 00000000..d7b47a5e --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.rs @@ -0,0 +1,7 @@ +#[derive(serde::Deserialize, reflectapi::Input)] +struct MyNewtype( + #[reflectapi(hidden)] + String, +); + +fn main() {} diff --git a/reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.stderr b/reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.stderr new file mode 100644 index 00000000..5183b6d6 --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_on_newtype_struct_field.stderr @@ -0,0 +1,5 @@ +error: `hidden` cannot be used on unnamed (tuple) fields because removing a positional element shifts indices and breaks wire compatibility in generated clients + --> tests/errors/hidden_on_newtype_struct_field.rs:3:5 + | +3 | #[reflectapi(hidden)] + | ^ diff --git a/reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.rs b/reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.rs new file mode 100644 index 00000000..a034c3fe --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.rs @@ -0,0 +1,7 @@ +#[derive(serde::Deserialize, reflectapi::Input)] +struct MyStruct( + #[reflectapi(hidden)] + u32, +); + +fn main() {} diff --git a/reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.stderr b/reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.stderr new file mode 100644 index 00000000..e14c5cf2 --- /dev/null +++ b/reflectapi-demo/tests/errors/hidden_on_tuple_struct_field.stderr @@ -0,0 +1,5 @@ +error: `hidden` cannot be used on unnamed (tuple) fields because removing a positional element shifts indices and breaks wire compatibility in generated clients + --> tests/errors/hidden_on_tuple_struct_field.rs:3:5 + | +3 | #[reflectapi(hidden)] + | ^ diff --git a/reflectapi-demo/tests/errors/input_skip_field_without_default.stderr b/reflectapi-demo/tests/errors/input_skip_field_without_default.stderr deleted file mode 100644 index ed3ce8e9..00000000 --- a/reflectapi-demo/tests/errors/input_skip_field_without_default.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: field is excluded from the input schema (via `skip` or `input_skip`) but has no default value; add `#[serde(default)]` so deserialization succeeds when clients omit it - --> tests/errors/input_skip_field_without_default.rs:3:5 - | -3 | #[reflectapi(input_skip)] - | ^ diff --git a/reflectapi-demo/tests/errors/skip_field_without_default.stderr b/reflectapi-demo/tests/errors/skip_field_without_default.stderr deleted file mode 100644 index ce5b045a..00000000 --- a/reflectapi-demo/tests/errors/skip_field_without_default.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: field is excluded from the input schema (via `skip` or `input_skip`) but has no default value; add `#[serde(default)]` so deserialization succeeds when clients omit it - --> tests/errors/skip_field_without_default.rs:3:5 - | -3 | #[reflectapi(skip)] - | ^ diff --git a/reflectapi-demo/tests/errors/hidden_field_without_default.rs b/reflectapi-demo/tests/success/hidden_field_without_default.rs similarity index 100% rename from reflectapi-demo/tests/errors/hidden_field_without_default.rs rename to reflectapi-demo/tests/success/hidden_field_without_default.rs diff --git a/reflectapi-demo/tests/errors/input_skip_field_without_default.rs b/reflectapi-demo/tests/success/input_skip_field_without_default.rs similarity index 100% rename from reflectapi-demo/tests/errors/input_skip_field_without_default.rs rename to reflectapi-demo/tests/success/input_skip_field_without_default.rs diff --git a/reflectapi-demo/tests/errors/skip_field_without_default.rs b/reflectapi-demo/tests/success/skip_field_without_default.rs similarity index 100% rename from reflectapi-demo/tests/errors/skip_field_without_default.rs rename to reflectapi-demo/tests/success/skip_field_without_default.rs diff --git a/reflectapi-derive/src/derive.rs b/reflectapi-derive/src/derive.rs index 17c2167f..1aef78fe 100644 --- a/reflectapi-derive/src/derive.rs +++ b/reflectapi-derive/src/derive.rs @@ -410,28 +410,15 @@ fn visit_field(cx: &Context, field: &ast::Field<'_>) -> Option attrs.input_skip, ReflectType::Output => attrs.output_skip, } { - // Guard: a skipped Input field without a serde default is unrepresentable — - // clients won't send it, so deserialization will fail at runtime. - if matches!(cx.reflectapi_type(), ReflectType::Input) && field.attrs.default().is_none() { - cx.impl_error( - field.original, - "field is excluded from the input schema (via `skip` or `input_skip`) but has no \ - default value; add `#[serde(default)]` so deserialization succeeds when clients \ - omit it", - ); - } return None; } - // Guard: a hidden Input field without a serde default is unrepresentable — - // clients can't see it, so deserialization will fail at runtime. - if attrs.hidden - && matches!(cx.reflectapi_type(), ReflectType::Input) - && field.attrs.default().is_none() - { + // Guard: hidden on unnamed/tuple fields would shift positional indices in + // generated clients, breaking wire compatibility. + if attrs.hidden && field.original.ident.is_none() { cx.impl_error( field.original, - "field is hidden from clients but has no default value; \ - add `#[serde(default)]` so deserialization succeeds when clients omit it", + "`hidden` cannot be used on unnamed (tuple) fields because removing a positional \ + element shifts indices and breaks wire compatibility in generated clients", ); } let (field_type, field_transform) = match cx.reflectapi_type() { diff --git a/reflectapi/src/codegen/openapi.rs b/reflectapi/src/codegen/openapi.rs index 3d706feb..d5948405 100644 --- a/reflectapi/src/codegen/openapi.rs +++ b/reflectapi/src/codegen/openapi.rs @@ -1228,9 +1228,7 @@ impl Converter<'_> { return self.convert_type_ref(schema, kind, strukt.fields[0].type_ref()); } - let (flattened_fields, fields) = strukt - .fields() - .partition::, _>(|f| f.flattened); + let (flattened_fields, fields) = strukt.fields().partition::, _>(|f| f.flattened); let ty = if fields.iter().all(|f| f.is_named()) { Type::Object { diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 60daace5..61ee34ee 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -743,11 +743,7 @@ fn render_struct_with_flattened_internal_enum( // Collect the parent struct's non-flattened fields + any flattened struct fields let mut base_fields: Vec = Vec::new(); - for field in struct_def - .fields - .iter() - .filter(|f| !f.flattened()) - { + for field in struct_def.fields.iter().filter(|f| !f.flattened()) { let (python_name, alias) = sanitize_field_name_with_alias(field.name(), field.serde_name()); let field_type = type_ref_to_python_type( &field.type_ref, @@ -779,11 +775,7 @@ fn render_struct_with_flattened_internal_enum( } // Also expand any flattened struct fields (non-enum) into base_fields - for field in struct_def - .fields - .iter() - .filter(|f| f.flattened()) - { + for field in struct_def.fields.iter().filter(|f| f.flattened()) { let type_name = resolve_flattened_type_name(&field.type_ref); if let Some(reflectapi_schema::Type::Struct(_)) = schema.get_type(type_name) { let flattened = collect_flattened_fields( @@ -1015,11 +1007,7 @@ fn render_struct_with_flatten_standard( let mut all_fields = Vec::new(); // Add regular fields - for field in struct_def - .fields - .iter() - .filter(|f| !f.flattened()) - { + for field in struct_def.fields.iter().filter(|f| !f.flattened()) { let (python_name, alias) = sanitize_field_name_with_alias(field.name(), field.serde_name()); let field_type = type_ref_to_python_type( &field.type_ref, @@ -1052,11 +1040,7 @@ fn render_struct_with_flatten_standard( } // Add flattened fields (expanded directly) - for field in struct_def - .fields - .iter() - .filter(|f| f.flattened()) - { + for field in struct_def.fields.iter().filter(|f| f.flattened()) { let flattened_fields = collect_flattened_fields( &field.type_ref, schema,