Skip to content

[BUG] Can't combine enum_value with empty_behavior #251

Description

@SteakFisher

Describe the Bug

A message can use at most one JSON-customizing feature. Each feature's Go generator emits its own top-level MarshalJSON/MarshalJSONSebuf on the message, and two would be a duplicate method declaration, so the generators fail fast via pairwise conflict guards instead of composing. This makes a common, legitimate shape impossible: a message with string-mapped enums (enum_value) and nullable scalars (nullable or empty_behavior). The same exclusion applies between any two of: enum_value, int64_encoding=NUMBER, bytes_encoding, timestamp_format, nullable, empty_behavior, flatten, oneof_config.

To Reproduce

  1. Create a proto with an enum that carries (sebuf.http.enum_value) mappings, and a message that uses that enum and a nullable (or empty_behavior) field (see Protobuf Definition below).
  2. Run buf generate.
  3. See the error under Actual Behavior.

Note: this is not fixable by changing the field's enum_encoding. The enum marshaler is triggered by the enum type carrying enum_value mappings, not by the field annotation:

  • Dropping ENUM_ENCODING_STRING leaves the default (still not NUMBER) → enum marshaler still active → same conflict.
  • Setting ENUM_ENCODING_NUMBER → rejected by a separate validation: "field ... has both enum_encoding=NUMBER and enum_value annotations - this is not allowed" (internal/httpgen/enum_encoding.go:19).

Expected Behavior

Both features coexist on one message — type serializes as "call" and delta serializes as null when unset:

{ "type": "call", "delta": null }

Actual Behavior

buf generate aborts:

Failure: plugin protoc-gen-go-http: message OptionsDetails: enum_value requires MarshalJSON
but conflicts with nullable (also requires MarshalJSON) -- only one MarshalJSON-generating
feature is supported per message

(Using empty_behavior instead of nullable yields the identical error with empty_behavior named. protoc-gen-go-client has a mirror guard, internal/clientgen/enum_validation.go:15.)

Environment (please complete the following information):

  • OS: macOS 26.6.1 (Apple Silicon / arm64)
  • Go version: go1.26.5
  • sebuf version: generator built from main @ d3bcc2a; annotations module buf.build/sebmelki/sebuf commit b5f679ca6c5f4f148c3414adfea268d3
  • protoc version: n/a (generating via buf)
  • buf version: 1.72.0

Protobuf Definition

syntax = "proto3";

import "sebuf/http/annotations.proto";

enum OptionType {
  OPTION_TYPE_UNSPECIFIED = 0;
  OPTION_TYPE_CALL = 1 [(sebuf.http.enum_value) = "call"];
  OPTION_TYPE_PUT  = 2 [(sebuf.http.enum_value) = "put"];
}

message OptionsDetails {
  OptionType type = 1;                                       // string-mapped enum
  optional double delta = 2 [(sebuf.http.nullable) = true];  // nullable scalar
}

Generated Code (if applicable)

None — generation aborts before emitting. The conflict is that both features would emit the same method on OptionsDetails:

// from the enum_value marshaler (enum_field_encoding.go)
func (x *OptionsDetails) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { /* ... */ }
func (x *OptionsDetails) MarshalJSON() ([]byte, error) { /* ... */ }

// AND from the nullable marshaler (nullable.go) — duplicate declaration on the same type
func (x *OptionsDetails) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { /* ... */ }
func (x *OptionsDetails) MarshalJSON() ([]byte, error) { /* ... */ }

Command Used

buf generate

Additional Context

Root cause (paths at main @ d3bcc2a):

  • Each feature emits its own MarshalJSON/MarshalJSONSebuf: internal/httpgen/empty_behavior.go:131, plus nullable.go, enum_field_encoding.go, encoding.go (int64), bytes_encoding.go, timestamp_format.go, flatten.go, oneof_discriminator.go.
  • Pairwise fail-fast guards: enum_field_encoding.go:261 checkEnumMarshalJSONConflict, mirrored in encoding.go:211, flatten.go, oneof_discriminator.go.
  • Enum trigger is the mapping on the enum type: enum_field_encoding.go:49 customEnumForField.

Workaround: for a message that needs string-mapped enums, keep nullable scalars as google.protobuf.DoubleValue/Int32Value wrappers — protojson renders unset wrappers as null natively without a message-level MarshalJSON, so they don't hit the guard.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions