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..d7cc3eb4 --- /dev/null +++ b/docs/src/getting-started/attributes.md @@ -0,0 +1,86 @@ +# 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 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** + +```rust,ignore +#[derive(serde::Deserialize, reflectapi::Input)] +pub struct MyHeaders { + /// Visible to clients — they must provide this + pub authorization: String, + + /// 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, +} +``` + +### `#[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 | +|-----------|-------------| +| `#[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. | diff --git a/reflectapi-demo/src/tests/basic.rs b/reflectapi-demo/src/tests/basic.rs index 1aab06f5..6ed34247 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)] @@ -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] @@ -585,6 +587,7 @@ fn test_reflectapi_struct_with_additional_derives() { Hash, Default, )] + #[allow(clippy::duplicated_attributes)] #[reflectapi(derive( Clone, PartialOrd, @@ -643,3 +646,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_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:: 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..f3819118 --- /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..73087c8f --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_struct_with_hidden_header_field-4.snap @@ -0,0 +1,62 @@ +--- +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" + }, + { + "name": "_x_internal_trace_id", + "in": "header", + "required": false, + "schema": { + "$ref": "#/components/schemas/std.string.String" + }, + "description": "Internal tracking header, hidden from clients" + } + ] + } + } + }, + "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..fcf6bb9a --- /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/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/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-demo/tests/success/hidden_field_without_default.rs b/reflectapi-demo/tests/success/hidden_field_without_default.rs new file mode 100644 index 00000000..37d646c6 --- /dev/null +++ b/reflectapi-demo/tests/success/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/success/input_skip_field_without_default.rs b/reflectapi-demo/tests/success/input_skip_field_without_default.rs new file mode 100644 index 00000000..4e143a97 --- /dev/null +++ b/reflectapi-demo/tests/success/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/success/skip_field_without_default.rs b/reflectapi-demo/tests/success/skip_field_without_default.rs new file mode 100644 index 00000000..961890bc --- /dev/null +++ b/reflectapi-demo/tests/success/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-derive/src/derive.rs b/reflectapi-derive/src/derive.rs index bace5ff3..1aef78fe 100644 --- a/reflectapi-derive/src/derive.rs +++ b/reflectapi-derive/src/derive.rs @@ -397,12 +397,30 @@ 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; + } + // 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, + "`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() { ReflectType::Input => (attrs.input_type, attrs.input_transform), ReflectType::Output => (attrs.output_type, attrs.output_transform), @@ -423,6 +441,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..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)] @@ -1105,6 +1126,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 +1149,7 @@ impl PartialEq for Field { type_ref, required, flattened, + hidden, transform_callback, transform_callback_fn: _, }: &Self, @@ -1133,6 +1161,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 +1175,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 +1190,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 +1229,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..d5948405 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 { diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 9100294d..61ee34ee 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -1577,6 +1577,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 0797cc83..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() diff --git a/reflectapi/src/codegen/typescript.rs b/reflectapi/src/codegen/typescript.rs index 9959f217..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(); 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"]