From a4d98df87cb6a9b48ebfd23d41aa0ee2eb4fc853 Mon Sep 17 00:00:00 2001 From: Tejas Dharani Date: Fri, 8 May 2026 14:26:19 +0530 Subject: [PATCH] feat(buffa-codegen): impl Serialize for generated view types (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `generate_json` is enabled, each generated `*View<'a>` struct now gets a manual `impl<'__a> serde::Serialize for FooView<'__a>` that follows proto3-JSON semantics: proto3 defaults omitted, bytes base64-encoded, int64/uint64 quoted, NaN/Inf as string tokens, enum values as proto names, NullValue oneof variants as JSON null. `OwnedView` gains a blanket `impl Serialize for OwnedView` (cfg-gated on `feature = "json"`) so that `serde_json::to_string(&owned_view)` works without an explicit deref. Known limitations (documented in CHANGELOG and generated impl doc): - Messages whose views nest a WKT view (TimestampView, DurationView, etc.) fail to compile — WKT view Serialize is a planned follow-up. - Extension fields are not included in view JSON output. Fixes: #83 --- CHANGELOG.md | 13 + buffa-build/src/lib.rs | 19 +- buffa-codegen/src/view.rs | 593 ++++++++++++++++++++- buffa-codegen/tests/codegen_integration.rs | 94 ++++ buffa-test/build.rs | 10 + buffa-test/protos/view_json.proto | 63 +++ buffa-test/src/lib.rs | 10 + buffa-test/src/tests/json.rs | 295 ++++++++++ buffa/src/view.rs | 14 + 9 files changed, 1105 insertions(+), 6 deletions(-) create mode 100644 buffa-test/protos/view_json.proto diff --git a/CHANGELOG.md b/CHANGELOG.md index 09eb9b1..1676752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- `serde::Serialize` is now implemented for generated view types when `generate_json` is + enabled, allowing zero-copy JSON serialization without `.to_owned_message()`. + `OwnedView` also gains a blanket `Serialize` impl so `serde_json::to_string(&owned_view)` + works directly. **Known limitations:** (1) Messages whose view types nest a WKT view + (`TimestampView`, `DurationView`, `AnyView`, etc.) will fail to compile because WKT view + types do not yet implement `Serialize` — use `view.to_owned_message()` and serialize the + owned form as a workaround, or disable views (`generate_views(false)`) for those messages. + Hand-written WKT view `Serialize` impls are a planned follow-up. (2) Extension fields are + not included in view JSON output; serialize the owned form to include extensions. + ([#83](https://github.com/anthropics/buffa/issues/83)) + ## [0.5.2] - 2026-05-07 ### Fixed diff --git a/buffa-build/src/lib.rs b/buffa-build/src/lib.rs index d42d801..2f8846d 100644 --- a/buffa-build/src/lib.rs +++ b/buffa-build/src/lib.rs @@ -103,11 +103,22 @@ impl Config { self } - /// Enable or disable serde Serialize/Deserialize derive generation - /// for generated message structs and enum types (default: false). + /// Enable or disable serde JSON generation (default: false). /// - /// When enabled, the downstream crate must depend on `serde` and enable - /// the `buffa/json` feature for the runtime helpers. + /// When enabled: + /// - Generated message structs get `Serialize`/`Deserialize` derives. + /// - Generated enum types get `Serialize`/`Deserialize` derives. + /// - Generated view types (when `generate_views` is also enabled) get a + /// manual `impl Serialize` for zero-copy JSON serialization. + /// + /// The downstream crate must depend on `serde` and enable the `buffa/json` + /// feature for the runtime helpers. + /// + /// **Limitation:** view types for messages that contain WKT fields + /// (`Timestamp`, `Duration`, `Any`, etc.) will fail to compile because WKT + /// view types do not yet implement `Serialize`. Call + /// `view.to_owned_message()` and serialize the owned form as a workaround, + /// or set `generate_views(false)` for affected files. #[must_use] pub fn generate_json(mut self, enabled: bool) -> Self { self.codegen_config.generate_json = enabled; diff --git a/buffa-codegen/src/view.rs b/buffa-codegen/src/view.rs index 1de6526..49b61a8 100644 --- a/buffa-codegen/src/view.rs +++ b/buffa-codegen/src/view.rs @@ -19,10 +19,12 @@ use crate::features::ResolvedFeatures; use crate::impl_message::{ closed_enum_decode, closed_enum_decode_with_unknown, decode_fn_token, effective_type, effective_type_in_map_entry, field_uses_bytes, find_map_entry_fields, - is_explicit_presence_scalar, is_packed_type, is_real_oneof_member, is_supported_field_type, - validated_field_number, wire_type_byte, wire_type_check, wire_type_token, + is_explicit_presence_scalar, is_packed_type, is_real_oneof_member, is_required_field, + is_supported_field_type, validated_field_number, wire_type_byte, wire_type_check, + wire_type_token, }; use crate::message::{is_closed_enum, is_map_field, make_field_ident, rust_path_to_tokens}; +use crate::oneof::{is_null_value_field, serde_helper_path}; use crate::CodeGenError; /// Token stream that pushes a closed-enum unknown value's raw wire span to @@ -164,6 +166,18 @@ pub(crate) fn generate_view_with_nesting( } }; + let serialize_impl = if ctx.config.generate_json { + generate_view_serialize( + view_scope, + msg, + &view_ident, + &view_oneof_prefix, + &oneof_idents, + )? + } else { + quote! {} + }; + // When preserving unknowns we capture `before_tag` so we can compute the // raw byte span after `skip_field` advances the cursor. let before_tag_capture = if ctx.config.preserve_unknown_fields { @@ -318,6 +332,8 @@ pub(crate) fn generate_view_with_nesting( #view_encode_impl + #serialize_impl + impl<'v> ::buffa::DefaultViewInstance for #view_ident<'v> { fn default_view_instance<'a>() -> &'a Self where @@ -1478,6 +1494,579 @@ fn oneof_variant_to_owned(scope: MessageScope<'_>, ty: Type, field_name: &str) - } } +// --------------------------------------------------------------------------- +// Serialize impl generation +// --------------------------------------------------------------------------- + +/// Emit `impl serde::Serialize for FooView<'_>` when `generate_json` is true. +fn generate_view_serialize( + scope: MessageScope<'_>, + msg: &DescriptorProto, + view_ident: &proc_macro2::Ident, + view_oneof_prefix: &TokenStream, + oneof_idents: &std::collections::HashMap, +) -> Result { + let mut stmts: Vec = Vec::new(); + + for field in &msg.field { + if is_real_oneof_member(field) { + continue; + } + if !is_supported_field_type(field.r#type.unwrap_or_default()) { + continue; + } + stmts.push(view_field_serialize_stmt(scope, msg, field)?); + } + + for (idx, oneof) in msg.oneof_decl.iter().enumerate() { + let base_ident = match oneof_idents.get(&idx) { + Some(id) => id, + None => continue, + }; + let oneof_name = oneof + .name + .as_deref() + .ok_or(CodeGenError::MissingField("oneof.name"))?; + let field_ident = make_field_ident(oneof_name); + let view_enum = quote! { #view_oneof_prefix #base_ident }; + let fields: Vec<_> = msg + .field + .iter() + .filter(|f| is_real_oneof_member(f) && f.oneof_index == Some(idx as i32)) + .collect(); + if fields.is_empty() { + continue; + } + let arms = fields + .iter() + .map(|f| view_oneof_serialize_arm(scope, f, &view_enum)) + .collect::, _>>()?; + stmts.push(quote! { + if let ::core::option::Option::Some(ref __ov) = self.#field_ident { + match __ov { #(#arms)* } + } + }); + } + + Ok(quote! { + /// Serializes this view as proto3 JSON: proto3 defaults are omitted, + /// bytes fields are base64-encoded, and enum values are their proto name strings. + /// + /// **Limitation:** if any field of this message is a WKT view type + /// (`TimestampView`, `DurationView`, etc.), this impl will fail to + /// compile because WKT view types do not yet implement `Serialize`. + /// Workaround: call `view.to_owned_message()` and serialize the owned form. + impl<'__a> ::serde::Serialize for #view_ident<'__a> { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + let mut __map = __s.serialize_map(::core::option::Option::None)?; + #(#stmts)* + __map.end() + } + } + }) +} + +/// Generate a single serialize statement for one direct (non-oneof) view field. +fn view_field_serialize_stmt( + scope: MessageScope<'_>, + msg: &DescriptorProto, + field: &FieldDescriptorProto, +) -> Result { + let MessageScope { + ctx, + features: parent_features, + .. + } = scope; + let f_features = crate::features::resolve_field(ctx, field, parent_features); + + let field_name = field + .name + .as_deref() + .ok_or(CodeGenError::MissingField("field.name"))?; + let json_name = field.json_name.as_deref().unwrap_or(field_name); + let ident = make_field_ident(field_name); + let label = field.label.unwrap_or_default(); + let is_repeated = label == Label::LABEL_REPEATED; + let is_required = is_required_field(field, parent_features); + let ty = effective_type(ctx, field, parent_features); + + // ── Map field ───────────────────────────────────────────────────────────── + if is_repeated && is_map_field(msg, field) { + let (key_fd, val_fd) = find_map_entry_fields(msg, field)?; + let key_raw = effective_type_in_map_entry(ctx, key_fd, parent_features); + let val_raw = effective_type_in_map_entry(ctx, val_fd, parent_features); + let val_f = crate::features::resolve_field(ctx, val_fd, parent_features); + + let key_ty = match key_raw { + Type::TYPE_STRING => quote! { &'__a str }, + Type::TYPE_BYTES => quote! { &'__a [u8] }, + kt => scalar_ty(kt), + }; + let val_ty = match val_raw { + Type::TYPE_STRING => quote! { &'__a str }, + Type::TYPE_BYTES => quote! { &'__a [u8] }, + Type::TYPE_MESSAGE => { + let path = resolve_view_path(scope, val_fd)?; + quote! { #path <'__a> } + } + Type::TYPE_ENUM => { + let et = resolve_enum_ty(scope, val_fd)?; + if is_closed_enum(&val_f) { + quote! { #et } + } else { + quote! { ::buffa::EnumValue<#et> } + } + } + vt => scalar_ty(vt), + }; + + // Key wrapper struct (only for bytes keys). + let key_wrapper = match key_raw { + Type::TYPE_BYTES => quote! { + struct _WK<'__x>(&'__x [u8]); + impl ::serde::Serialize for _WK<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::bytes::serialize(self.0, __s) + } + } + }, + _ => quote! {}, + }; + let key_expr = match key_raw { + Type::TYPE_BYTES => quote! { &_WK(k) }, + Type::TYPE_STRING => quote! { k }, + _ => quote! { k }, + }; + + // Value wrapper struct (for types needing special encoding). + let (val_wrapper, val_expr) = match val_raw { + Type::TYPE_BYTES => ( + quote! { + struct _WV<'__x>(&'__x [u8]); + impl ::serde::Serialize for _WV<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::bytes::serialize(self.0, __s) + } + } + }, + // v: &&[u8] — deref coercion from &&[u8] to &[u8] at _WV struct init. + quote! { &_WV(v) }, + ), + Type::TYPE_ENUM if is_closed_enum(&val_f) => { + let et = resolve_enum_ty(scope, val_fd)?; + ( + quote! { + struct _WV(#et); + impl ::serde::Serialize for _WV { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::closed_enum::serialize(&self.0, __s) + } + } + }, + // v: &E (Copy) — explicit deref needed for scalar struct field. + quote! { &_WV(*v) }, + ) + } + vt if serde_helper_path(vt).is_some() => { + let helper = serde_helper_path(vt).unwrap(); + ( + quote! { + struct _WV(#val_ty); + impl ::serde::Serialize for _WV { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + #helper::serialize(&self.0, __s) + } + } + }, + // v: &scalar — explicit deref needed for scalar struct field. + quote! { &_WV(*v) }, + ) + } + _ => (quote! {}, quote! { v }), + }; + + // Include '__a only when key/val types actually reference the view lifetime. + let key_uses_a = matches!(key_raw, Type::TYPE_STRING | Type::TYPE_BYTES); + let val_uses_a = matches!( + val_raw, + Type::TYPE_STRING | Type::TYPE_BYTES | Type::TYPE_MESSAGE + ); + let (wm_struct_decl, wm_impl_hdr, wm_impl_ty) = if key_uses_a || val_uses_a { + ( + quote! { struct _WM<'__a, '__x>(&'__x ::buffa::MapView<'__x, #key_ty, #val_ty>); }, + quote! { impl<'__a> }, + quote! { _WM<'__a, '_> }, + ) + } else { + ( + quote! { struct _WM<'__x>(&'__x ::buffa::MapView<'__x, #key_ty, #val_ty>); }, + quote! { impl }, + quote! { _WM<'_> }, + ) + }; + return Ok(quote! { + if !self.#ident.is_empty() { + #wm_struct_decl + #wm_impl_hdr ::serde::Serialize for #wm_impl_ty { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + #key_wrapper + #val_wrapper + let mut __m = __s.serialize_map(::core::option::Option::Some(self.0.len()))?; + for (k, v) in self.0.iter() { + __m.serialize_entry(#key_expr, #val_expr)?; + } + __m.end() + } + } + __map.serialize_entry(#json_name, &_WM(&self.#ident))?; + } + }); + } + + // ── Repeated field ──────────────────────────────────────────────────────── + if is_repeated { + let seq_wrapper = match ty { + Type::TYPE_BYTES => quote! { + struct _WSeq<'__x>(&'__x [&'__x [u8]]); + impl ::serde::Serialize for _WSeq<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeSeq as _; + let mut __seq = __s.serialize_seq(::core::option::Option::Some(self.0.len()))?; + for v in self.0 { + struct _WE<'__x>(&'__x [u8]); + impl ::serde::Serialize for _WE<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::bytes::serialize(self.0, __s) + } + } + __seq.serialize_element(&_WE(v))?; + } + __seq.end() + } + } + }, + Type::TYPE_ENUM if is_closed_enum(&f_features) => { + let et = resolve_enum_ty(scope, field)?; + quote! { + struct _WSeq<'__x>(&'__x [#et]); + impl ::serde::Serialize for _WSeq<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::repeated_closed_enum::serialize(self.0, __s) + } + } + } + } + Type::TYPE_ENUM => { + let et = resolve_enum_ty(scope, field)?; + quote! { + struct _WSeq<'__x>(&'__x [::buffa::EnumValue<#et>]); + impl ::serde::Serialize for _WSeq<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::repeated_enum::serialize(self.0, __s) + } + } + } + } + scalar_ty_val if serde_helper_path(scalar_ty_val).is_some() => { + let elem_ty = scalar_ty(scalar_ty_val); + quote! { + struct _WSeq<'__x>(&'__x [#elem_ty]); + impl ::serde::Serialize for _WSeq<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::proto_seq::serialize(self.0, __s) + } + } + } + } + _ => quote! {}, + }; + + let seq_val = if seq_wrapper.is_empty() { + // Explicit deref: RepeatedView doesn't impl Serialize, but &[T] does. + quote! { &*self.#ident } + } else { + // Deref coercion from &RepeatedView<'a,T> to &[T] at struct-init site. + quote! { &_WSeq(&self.#ident) } + }; + + return Ok(quote! { + if !self.#ident.is_empty() { + #seq_wrapper + __map.serialize_entry(#json_name, #seq_val)?; + } + }); + } + + // ── Explicit-presence (proto3 optional) scalar ──────────────────────────── + if is_explicit_presence_scalar(field, ty, &f_features) { + // opt_* helpers from json_helpers take &Option, not &Option. + // Handle each case inline so the view's Option<&str>/Option<&[u8]> work. + let entry = match ty { + Type::TYPE_STRING => quote! { + if let ::core::option::Option::Some(__v) = self.#ident { + __map.serialize_entry(#json_name, __v)?; + } + }, + Type::TYPE_BYTES => quote! { + if let ::core::option::Option::Some(__v) = self.#ident { + struct _W<'__x>(&'__x [u8]); + impl ::serde::Serialize for _W<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::bytes::serialize(self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(__v))?; + } + }, + Type::TYPE_ENUM if is_closed_enum(&f_features) => { + let et = resolve_enum_ty(scope, field)?; + quote! { + if let ::core::option::Option::Some(__v) = self.#ident { + struct _W(#et); + impl ::serde::Serialize for _W { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::closed_enum::serialize(&self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(__v))?; + } + } + } + Type::TYPE_ENUM => quote! { + if let ::core::option::Option::Some(ref __v) = self.#ident { + __map.serialize_entry(#json_name, __v)?; + } + }, + scalar if serde_helper_path(scalar).is_some() => { + let helper = serde_helper_path(scalar).unwrap(); + let elem_ty = scalar_ty(scalar); + quote! { + if let ::core::option::Option::Some(__v) = self.#ident { + struct _W(#elem_ty); + impl ::serde::Serialize for _W { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + #helper::serialize(&self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(__v))?; + } + } + } + _ => quote! { + if let ::core::option::Option::Some(__v) = self.#ident { + __map.serialize_entry(#json_name, &__v)?; + } + }, + }; + return Ok(entry); + } + + // ── Singular fields ─────────────────────────────────────────────────────── + let (skip_cond, serialize_stmt) = match ty { + Type::TYPE_STRING => ( + quote! { !::buffa::json_helpers::skip_if::is_empty_str(self.#ident) }, + quote! { __map.serialize_entry(#json_name, self.#ident)?; }, + ), + Type::TYPE_BYTES => ( + quote! { !::buffa::json_helpers::skip_if::is_empty_bytes(self.#ident) }, + quote! { + struct _W<'__x>(&'__x [u8]); + impl ::serde::Serialize for _W<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::bytes::serialize(self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(self.#ident))?; + }, + ), + Type::TYPE_MESSAGE | Type::TYPE_GROUP => ( + quote! { self.#ident.is_set() }, + quote! { + if let ::core::option::Option::Some(__v) = self.#ident.as_option() { + __map.serialize_entry(#json_name, __v)?; + } + }, + ), + Type::TYPE_ENUM if is_closed_enum(&f_features) => { + let et = resolve_enum_ty(scope, field)?; + let skip_fn = quote! { ::buffa::json_helpers::skip_if::is_default_closed_enum }; + ( + quote! { !#skip_fn(&self.#ident) }, + quote! { + struct _W(#et); + impl ::serde::Serialize for _W { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::closed_enum::serialize(&self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(self.#ident))?; + }, + ) + } + Type::TYPE_ENUM => ( + quote! { !::buffa::json_helpers::skip_if::is_default_enum_value(&self.#ident) }, + quote! { __map.serialize_entry(#json_name, &self.#ident)?; }, + ), + scalar if serde_helper_path(scalar).is_some() => { + let helper = serde_helper_path(scalar).unwrap(); + let skip_path: syn::Path = + syn::parse_str(scalar_skip_predicate(scalar)).expect("valid path"); + let elem_ty = scalar_ty(scalar); + ( + quote! { !#skip_path(&self.#ident) }, + quote! { + struct _W(#elem_ty); + impl ::serde::Serialize for _W { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + #helper::serialize(&self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(self.#ident))?; + }, + ) + } + Type::TYPE_BOOL => ( + quote! { self.#ident }, + quote! { __map.serialize_entry(#json_name, &self.#ident)?; }, + ), + _ => ( + quote! { self.#ident != ::core::default::Default::default() }, + quote! { __map.serialize_entry(#json_name, &self.#ident)?; }, + ), + }; + + // Message fields handle skip internally via the if-let. Required fields + // are always serialized. Both are wrapped in a block so that any local + // `struct _W` declarations inside `serialize_stmt` do not collide when + // multiple such fields appear in the same message. + if matches!(ty, Type::TYPE_MESSAGE | Type::TYPE_GROUP) || is_required { + return Ok(quote! { { #serialize_stmt } }); + } + + Ok(quote! { + if #skip_cond { + #serialize_stmt + } + }) +} + +/// Return the `skip_if` predicate path string for a scalar type that has a helper. +fn scalar_skip_predicate(ty: Type) -> &'static str { + match ty { + Type::TYPE_BOOL => "::buffa::json_helpers::skip_if::is_false", + Type::TYPE_INT32 | Type::TYPE_SINT32 | Type::TYPE_SFIXED32 => { + "::buffa::json_helpers::skip_if::is_zero_i32" + } + Type::TYPE_UINT32 | Type::TYPE_FIXED32 => "::buffa::json_helpers::skip_if::is_zero_u32", + Type::TYPE_INT64 | Type::TYPE_SINT64 | Type::TYPE_SFIXED64 => { + "::buffa::json_helpers::skip_if::is_zero_i64" + } + Type::TYPE_UINT64 | Type::TYPE_FIXED64 => "::buffa::json_helpers::skip_if::is_zero_u64", + Type::TYPE_FLOAT => "::buffa::json_helpers::skip_if::is_zero_f32", + Type::TYPE_DOUBLE => "::buffa::json_helpers::skip_if::is_zero_f64", + _ => unreachable!("scalar_skip_predicate called for non-scalar"), + } +} + +/// Generate a single `match` arm for one oneof variant in the inlined oneof serialization. +/// +/// The arm is used inside: +/// ```text +/// if let Some(ref __ov) = self.field { match __ov { , ... } } +/// ``` +fn view_oneof_serialize_arm( + scope: MessageScope<'_>, + field: &FieldDescriptorProto, + view_enum: &TokenStream, +) -> Result { + let MessageScope { ctx, features, .. } = scope; + let f_features = crate::features::resolve_field(ctx, field, features); + + let name = field + .name + .as_deref() + .ok_or(CodeGenError::MissingField("field.name"))?; + let json_name = field.json_name.as_deref().unwrap_or(name); + let variant = crate::oneof::oneof_variant_ident(name); + let ty = effective_type(ctx, field, features); + + // NullValue must serialize as JSON `null`, not "NULL_VALUE". + if is_null_value_field(field) { + return Ok(quote! { + #view_enum::#variant(_) => { + __map.serialize_entry(#json_name, &())?; + } + }); + } + + // Pattern match on &ViewEnum gives v: &inner_type (with match ergonomics). + let arm_body = match ty { + Type::TYPE_STRING => { + // v: &&str — deref coercion from &&str to &str at the serialize_entry call. + quote! { __map.serialize_entry(#json_name, v)?; } + } + Type::TYPE_BYTES => { + // v: &&[u8] — deref coercion from &&[u8] to &[u8] in _W constructor. + quote! { + struct _W<'__x>(&'__x [u8]); + impl ::serde::Serialize for _W<'_> { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::bytes::serialize(self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(v))?; + } + } + Type::TYPE_MESSAGE | Type::TYPE_GROUP => { + // v: &Box → Box: Serialize when FooView: Serialize. + quote! { __map.serialize_entry(#json_name, v)?; } + } + Type::TYPE_ENUM if is_closed_enum(&f_features) => { + let et = resolve_enum_ty(scope, field)?; + quote! { + struct _W(#et); + impl ::serde::Serialize for _W { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + ::buffa::json_helpers::closed_enum::serialize(&self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(*v))?; + } + } + Type::TYPE_ENUM => { + // v: &EnumValue — has its own Serialize impl. + quote! { __map.serialize_entry(#json_name, v)?; } + } + scalar if serde_helper_path(scalar).is_some() => { + let helper = serde_helper_path(scalar).unwrap(); + let sty = scalar_ty(scalar); + // v: &scalar_type → copy and wrap. + quote! { + struct _W(#sty); + impl ::serde::Serialize for _W { + fn serialize<__S: ::serde::Serializer>(&self, __s: __S) -> ::core::result::Result<__S::Ok, __S::Error> { + #helper::serialize(&self.0, __s) + } + } + __map.serialize_entry(#json_name, &_W(*v))?; + } + } + _ => { + // bool, i32, u32: direct serialize. + quote! { __map.serialize_entry(#json_name, v)?; } + } + }; + + Ok(quote! { + #view_enum::#variant(v) => { #arm_body } + }) +} + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- diff --git a/buffa-codegen/tests/codegen_integration.rs b/buffa-codegen/tests/codegen_integration.rs index 098df12..e058699 100644 --- a/buffa-codegen/tests/codegen_integration.rs +++ b/buffa-codegen/tests/codegen_integration.rs @@ -118,6 +118,12 @@ fn json_no_views() -> CodeGenConfig { c } +fn json_with_views() -> CodeGenConfig { + let mut c = CodeGenConfig::default(); + c.generate_json = true; + c +} + // ── Tests using shared proto files from buffa-test/protos/ ────────────── #[test] @@ -853,3 +859,91 @@ fn inline_empty_message_no_unknown_fields() { assert!(content.contains("pub struct Empty")); assert!(!content.contains("__buffa_unknown_fields")); } + +// ── View Serialize codegen tests ──────────────────────────────────────── + +#[test] +fn test_view_serialize_impl_emitted_when_json_enabled() { + let files = generate_for("json_types.proto", &json_with_views()); + let combined = files + .iter() + .map(|f| f.content.as_str()) + .collect::>() + .join("\n"); + assert!( + combined.contains("impl<'__a> ::serde::Serialize for"), + "view Serialize impl must be emitted when generate_json=true: {combined}" + ); + for file in &files { + syn::parse_file(&file.content).unwrap_or_else(|e| { + panic!( + "generated file must parse ({}): {e}\n---\n{}", + file.package, file.content + ) + }); + } +} + +#[test] +fn test_view_serialize_not_emitted_when_json_disabled() { + // CodeGenConfig::default() has generate_views=true, generate_json=false. + let files = generate_for("json_types.proto", &CodeGenConfig::default()); + let combined = files + .iter() + .map(|f| f.content.as_str()) + .collect::>() + .join("\n"); + assert!( + !combined.contains("impl<'__a> ::serde::Serialize for"), + "view Serialize impl must NOT be emitted when generate_json=false" + ); +} + +#[test] +fn test_view_serialize_json_helpers_used() { + // Verify that int64 and bytes fields in a view use json_helpers paths. + let content = generate_proto( + r#" + syntax = "proto3"; + package test; + message Item { + int64 id = 1; + bytes data = 2; + double score = 3; + } + "#, + &json_with_views(), + ); + assert!( + content.contains("json_helpers"), + "view Serialize must use json_helpers for int64/bytes/double: {content}" + ); + syn::parse_file(&content).expect("generated content must parse"); +} + +#[test] +fn test_view_serialize_proto2_required_multi_field_parses() { + // Regression: proto2 required fields with helper-typed scalars previously + // emitted `struct _W` at fn scope without a wrapping block, causing E0428 + // (duplicate definition) when two or more such fields appeared in one message. + // This test asserts the generated output parses (syntax-level regression guard). + let content = generate_proto( + r#" + syntax = "proto2"; + package test; + message TwoRequired { + required int64 a = 1; + required bytes b = 2; + required int64 c = 3; + } + "#, + &json_with_views(), + ); + syn::parse_file(&content) + .expect("proto2 required multi-field view Serialize must produce parseable output"); + // Each required field must be serialized unconditionally (no skip_if). + assert!( + content.contains("impl<'__a> ::serde::Serialize for TwoRequiredView"), + "view Serialize impl must be emitted: {content}" + ); +} diff --git a/buffa-test/build.rs b/buffa-test/build.rs index 17259e0..08abd0d 100644 --- a/buffa-test/build.rs +++ b/buffa-test/build.rs @@ -93,6 +93,16 @@ fn main() { .compile() .expect("buffa_build failed for json_types.proto"); + // View + JSON round-trip tests (issue #83): views and JSON both enabled, + // no WKT imports to avoid compile errors from missing WKT view Serialize. + buffa_build::Config::new() + .files(&["protos/view_json.proto"]) + .includes(&["protos/"]) + .generate_views(true) + .generate_json(true) + .compile() + .expect("buffa_build failed for view_json.proto"); + // Proto2 + JSON — closed-enum JSON helpers (map_closed_enum, // repeated_closed_enum, closed_enum). Proto2 enums are always closed. buffa_build::Config::new() diff --git a/buffa-test/protos/view_json.proto b/buffa-test/protos/view_json.proto new file mode 100644 index 0000000..08d6217 --- /dev/null +++ b/buffa-test/protos/view_json.proto @@ -0,0 +1,63 @@ +// Proto for view JSON round-trip tests (issue #83). +// Intentionally avoids most WKT imports — WKT view Serialize is a separate +// follow-up; see CHANGELOG.md. NullValue is imported only because it is a +// proto enum (not a message), so its view variant is an i32, not a view struct, +// and does not trigger the WKT-view-Serialize compile gap. + +syntax = "proto3"; + +package test.viewjson; + +import "google/protobuf/struct.proto"; + +enum Color { + COLOR_UNSPECIFIED = 0; + RED = 1; + GREEN = 2; + BLUE = 3; +} + +message Scalars { + int32 i32 = 1; + int64 i64 = 2; + uint32 u32 = 3; + uint64 u64 = 4; + float f32 = 5; + double f64 = 6; + bool b = 7; + string s = 8; + bytes by = 9; +} + +message WithEnum { + Color color = 1; + repeated Color colors = 2; +} + +message WithOneof { + oneof value { + string text = 1; + int64 number = 2; + bytes data = 3; + Color color = 4; + google.protobuf.NullValue null_val = 5; + } +} + +message WithMaps { + map labels = 1; + map by_id = 2; + map counts = 3; + map by_color = 4; +} + +message Inner { + int32 x = 1; + string name = 2; +} + +message Outer { + Inner inner = 1; + repeated Inner items = 2; + int64 id = 3; +} diff --git a/buffa-test/src/lib.rs b/buffa-test/src/lib.rs index 7c5afc8..50d6836 100644 --- a/buffa-test/src/lib.rs +++ b/buffa-test/src/lib.rs @@ -97,6 +97,16 @@ pub mod json_types { buffa::include_proto!("test.json"); } +#[allow( + clippy::derivable_impls, + clippy::match_single_binding, + non_camel_case_types, + dead_code +)] +pub mod view_json { + buffa::include_proto!("test.viewjson"); +} + #[allow( clippy::derivable_impls, clippy::match_single_binding, diff --git a/buffa-test/src/tests/json.rs b/buffa-test/src/tests/json.rs index 3c26956..2ba316c 100644 --- a/buffa-test/src/tests/json.rs +++ b/buffa-test/src/tests/json.rs @@ -506,3 +506,298 @@ fn test_json_mixed_value_field_null_forwarding() { assert!(!decoded.dynamic.is_set()); let _ = NullValue::NULL_VALUE; // silence unused import if not needed } + +// ── View JSON round-trip (issue #83) ──────────────────────────────────────── +// Each test encodes an owned message → decodes as a view → serializes both to +// JSON → asserts they produce identical output. +// +// Uses `crate::view_json` (view_json.proto, built with generate_views=true + +// generate_json=true) to avoid WKT-view Serialize gaps — WKT view Serialize +// is a separate follow-up. + +#[test] +fn test_view_json_scalar_matches_owned() { + use crate::view_json::__buffa::view::ScalarsView; + use crate::view_json::Scalars; + use buffa::MessageView; + + let owned = Scalars { + i32: -42, + i64: 9007199254740993, // > 2^53 — must be quoted string + u32: u32::MAX, + u64: u64::MAX, + f32: 1.5, + f64: std::f64::consts::PI, + b: true, + s: "hello world".into(), + by: vec![0xDE, 0xAD, 0xBE, 0xEF], + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = ScalarsView::decode_view(&bytes).expect("decode_view"); + + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!(json_view, json_owned, "view JSON must match owned JSON"); + + // int64 >2^53 must be a quoted string, not a raw number. + assert!( + json_view.contains(r#""i64":"9007199254740993""#), + "int64 >2^53 must be quoted: {json_view}" + ); + // bytes must be base64-encoded. + assert!( + json_view.contains(r#""by":"3q2+7w==""#), + "bytes must be base64: {json_view}" + ); +} + +#[test] +fn test_view_json_double_special_values() { + use crate::view_json::__buffa::view::ScalarsView; + use crate::view_json::Scalars; + use buffa::MessageView; + + let cases: &[(f64, &str)] = &[ + (f64::NAN, r#""f64":"NaN""#), + (f64::INFINITY, r#""f64":"Infinity""#), + (f64::NEG_INFINITY, r#""f64":"-Infinity""#), + ]; + for (val, expected_fragment) in cases { + let owned = Scalars { + f64: *val, + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = ScalarsView::decode_view(&bytes).expect("decode_view"); + + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert!( + json_view.contains(expected_fragment), + "double {val:?} must serialize as {expected_fragment}: {json_view}" + ); + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + assert_eq!(json_view, json_owned, "view must match owned for {val:?}"); + } +} + +#[test] +fn test_view_json_proto3_defaults_omitted() { + use crate::view_json::__buffa::view::ScalarsView; + use crate::view_json::Scalars; + use buffa::MessageView; + + let owned = Scalars::default(); + let bytes = buffa::Message::encode_to_vec(&owned); + let view = ScalarsView::decode_view(&bytes).expect("decode_view"); + + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!( + json_view, "{}", + "default view must serialize as empty object" + ); + assert_eq!(json_view, json_owned); +} + +#[test] +fn test_view_json_enum_matches_owned() { + use crate::view_json::__buffa::view::WithEnumView; + use crate::view_json::{Color, WithEnum}; + use buffa::MessageView; + + let owned = WithEnum { + color: buffa::EnumValue::Known(Color::RED), + colors: vec![ + buffa::EnumValue::Known(Color::GREEN), + buffa::EnumValue::Known(Color::BLUE), + ], + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = WithEnumView::decode_view(&bytes).expect("decode_view"); + + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!(json_view, json_owned, "view JSON must match owned JSON"); + assert!( + json_view.contains(r#""color":"RED""#), + "enum as name: {json_view}" + ); + assert!( + json_view.contains(r#""GREEN""#), + "repeated enum: {json_view}" + ); +} + +#[test] +fn test_view_json_oneof_matches_owned() { + use crate::view_json::__buffa::oneof::with_oneof::Value as ValueOneof; + use crate::view_json::__buffa::view::WithOneofView; + use crate::view_json::{Color, WithOneof}; + use buffa::MessageView; + + let cases: &[ValueOneof] = &[ + ValueOneof::Text("hello".into()), + ValueOneof::Number(i64::MAX), + ValueOneof::Data(vec![0xAB, 0xCD]), + ValueOneof::Color(buffa::EnumValue::Known(Color::GREEN)), + ]; + for variant in cases { + let owned = WithOneof { + value: Some(variant.clone()), + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = WithOneofView::decode_view(&bytes).expect("decode_view"); + + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!( + json_view, json_owned, + "view must match owned for {variant:?}" + ); + } + + // Unset oneof → empty object. + let owned = WithOneof::default(); + let bytes = buffa::Message::encode_to_vec(&owned); + let view = WithOneofView::decode_view(&bytes).expect("decode_view"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!(json_view, "{}"); +} + +#[test] +fn test_view_json_map_matches_owned() { + use crate::view_json::__buffa::view::WithMapsView; + use crate::view_json::{Color, WithMaps}; + use buffa::MessageView; + + let owned = WithMaps { + labels: [ + ("env".into(), "prod".into()), + ("region".into(), "us-east".into()), + ] + .into_iter() + .collect(), + by_id: [(1, "one".into()), (2, "two".into())].into_iter().collect(), + counts: [("hits".into(), 9007199254740993i64)].into_iter().collect(), + by_color: [("bg".into(), buffa::EnumValue::Known(Color::BLUE))] + .into_iter() + .collect(), + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = WithMapsView::decode_view(&bytes).expect("decode_view"); + + // Parse as serde_json::Value so key ordering doesn't matter. + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + let v_owned: serde_json::Value = serde_json::from_str(&json_owned).unwrap(); + let v_view: serde_json::Value = serde_json::from_str(&json_view).unwrap(); + assert_eq!(v_view, v_owned, "view map JSON must match owned map JSON"); + + // int64 map value must be a quoted string. + assert!( + json_view.contains(r#""9007199254740993""#), + "int64 map value must be quoted: {json_view}" + ); +} + +#[test] +fn test_view_json_nested_matches_owned() { + use crate::view_json::__buffa::view::OuterView; + use crate::view_json::{Inner, Outer}; + use buffa::MessageView; + + let owned = Outer { + inner: buffa::MessageField::some(Inner { + x: 7, + name: "root".into(), + ..Default::default() + }), + items: vec![ + Inner { + x: 1, + name: "a".into(), + ..Default::default() + }, + Inner { + x: 2, + name: "b".into(), + ..Default::default() + }, + ], + id: i64::MAX, + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = OuterView::decode_view(&bytes).expect("decode_view"); + + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!( + json_view, json_owned, + "nested view JSON must match owned JSON" + ); +} + +#[test] +fn test_view_json_owned_view_blanket_impl() { + // OwnedView must implement Serialize via the blanket impl so that + // `serde_json::to_string(&owned_view)` works without an explicit deref. + use crate::view_json::__buffa::view::ScalarsView; + use crate::view_json::Scalars; + use buffa::view::OwnedView; + + let owned = Scalars { + i32: 99, + s: "owned_view".into(), + by: vec![0x01, 0x02], + ..Default::default() + }; + let bytes = bytes::Bytes::from(buffa::Message::encode_to_vec(&owned)); + let owned_view = OwnedView::>::decode(bytes).expect("decode OwnedView"); + + let json_owned_view = serde_json::to_string(&owned_view).expect("serialize OwnedView"); + let json_view = serde_json::to_string(&*owned_view).expect("serialize &view"); + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + + assert_eq!( + json_owned_view, json_owned, + "OwnedView blanket impl must match owned" + ); + assert_eq!( + json_owned_view, json_view, + "OwnedView blanket impl must match &*view" + ); +} + +#[test] +fn test_view_json_null_value_oneof_serializes_as_null() { + // NullValue oneof variants must serialize as JSON `null`, not "NULL_VALUE". + // Regression guard for the view path — the owned path is covered by + // test_json_oneof_null_value. + use crate::view_json::__buffa::oneof::with_oneof::Value as ValueOneof; + use crate::view_json::__buffa::view::WithOneofView; + use crate::view_json::WithOneof; + use buffa::MessageView; + use buffa_types::google::protobuf::NullValue; + + let owned = WithOneof { + value: Some(ValueOneof::NullVal(NullValue::NULL_VALUE.into())), + ..Default::default() + }; + let bytes = buffa::Message::encode_to_vec(&owned); + let view = WithOneofView::decode_view(&bytes).expect("decode_view"); + + let json_view = serde_json::to_string(&view).expect("serialize view"); + assert_eq!( + json_view, r#"{"nullVal":null}"#, + "NullValue oneof variant must serialize as JSON null: {json_view}" + ); + + // Also verify view matches owned. + let json_owned = serde_json::to_string(&owned).expect("serialize owned"); + assert_eq!(json_view, json_owned, "view must match owned for NullValue"); +} diff --git a/buffa/src/view.rs b/buffa/src/view.rs index e53a978..4ed6821 100644 --- a/buffa/src/view.rs +++ b/buffa/src/view.rs @@ -1185,6 +1185,20 @@ where impl Eq for OwnedView where V: Eq {} +/// Serialize an `OwnedView` as proto3 JSON by delegating to the inner +/// view's `Serialize` impl. +/// +/// Equivalent to serializing `&*owned_view` directly, so +/// `serde_json::to_string(&owned_view)` works without an explicit deref. +/// +/// Only available when the `json` feature is enabled. +#[cfg(feature = "json")] +impl ::serde::Serialize for OwnedView { + fn serialize(&self, s: S) -> Result { + ::serde::Serialize::serialize(&**self, s) + } +} + // `OwnedView` is auto-`Send`/`Sync` when `V` is — `ManuallyDrop` and // `Bytes` both forward auto-traits. No manual `unsafe impl` is needed, and // adding one with a `V: 'static` bound is actively harmful: it is precisely