Implement oneof discriminated union serialization across all 4 generators (go-http, go-client, ts-client, openapiv3).

Purpose: Enable developers to represent proto oneof fields as discriminated unions in JSON with an explicit type discriminator field. Supports both flattened (variant fields promoted to parent level) and non-flattened (variant nested under field name) modes. Custom discriminator values per variant are supported via oneof_value annotation.

Output: Go generators produce custom MarshalJSON/UnmarshalJSON for messages with oneof_config annotations. TypeScript client generates discriminated union types. OpenAPI uses oneOf with discriminator keyword. Golden tests cover all combinations.

<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-json-structural-transforms/07-RESEARCH.md @.planning/phases/07-json-structural-transforms/07-CONTEXT.md @.planning/phases/07-json-structural-transforms/07-01-SUMMARY.md

Existing encoding patterns to follow (established in Phase 4-6)

@internal/httpgen/encoding.go @internal/httpgen/nullable.go @internal/httpgen/empty_behavior.go @internal/httpgen/timestamp_format.go

Annotation functions from Plan 01#

@internal/annotations/oneof_discriminator.go

Generator entry points to modify#

@internal/httpgen/generator.go @internal/clientgen/generator.go @internal/tsclientgen/generator.go @internal/tsclientgen/types.go @internal/openapiv3/generator.go @internal/openapiv3/types.go

Task 1: Implement oneof discriminator in Go generators (go-http and go-client) internal/httpgen/oneof_discriminator.go internal/httpgen/generator.go internal/clientgen/oneof_discriminator.go internal/clientgen/generator.go Create internal/httpgen/oneof_discriminator.go following the established encoding pattern (encoding.go, nullable.go, timestamp_format.go):
  1. Define context structs:
type OneofDiscriminatorContext struct {
    Message  *protogen.Message
    Oneofs   []*annotations.OneofDiscriminatorInfo
}
  1. Implement detection and collection functions:
  • hasOneofDiscriminatorFields(message) bool -- checks if any oneof has discriminator annotation
  • collectOneofDiscriminatorContexts(file) []*OneofDiscriminatorContext -- collects annotated messages
  • validateOneofDiscriminatorAnnotations(file) error -- validates all oneofs in file
    • For each message, for each oneof with config, call annotations.ValidateOneofDiscriminator
    • Also check for MarshalJSON conflict: if message has BOTH oneof_config AND any of (int64 NUMBER encoding, nullable, empty_behavior, timestamp_format non-default, bytes_encoding non-default), emit error: "message X has both oneof_config and [other feature] on the same message -- this combination is not yet supported"
  1. Implement generateOneofDiscriminatorFile(file) error method on Generator:
  • File naming: file.GeneratedFilenamePrefix + "_oneof_discriminator.pb.go"
  • Only generate if there are messages with oneof discriminator annotations
  • Run validation first; return error if validation fails
  1. Generated MarshalJSON for FLATTENED mode:
func (x *MessageName) MarshalJSON() ([]byte, error) {
    if x == nil {
        return []byte("null"), nil
    }
    data, err := protojson.Marshal(x)
    if err != nil {
        return nil, err
    }
    var raw map[string]json.RawMessage
    if err := json.Unmarshal(data, &raw); err != nil {
        return nil, err
    }
    // Delete standard oneof field keys added by protojson
    delete(raw, "variantFieldName1")
    delete(raw, "variantFieldName2")
    // ... for each variant field
 
    // Add discriminator and flatten variant fields based on which is set
    switch v := x.OneofGoName.(type) {
    case *MessageName_VariantGoName:
        raw["discriminatorName"], _ = json.Marshal("discriminatorValue")
        if v.VariantGoName != nil {
            // Marshal variant message to get its fields
            variantData, err := json.Marshal(v.VariantGoName)
            if err != nil {
                return nil, err
            }
            var variantRaw map[string]json.RawMessage
            if err := json.Unmarshal(variantData, &variantRaw); err != nil {
                return nil, err
            }
            // Merge variant fields into parent raw map
            for k, val := range variantRaw {
                raw[k] = val
            }
        }
    // ... case for each variant
    case nil:
        // Unset oneof: omit discriminator entirely (proto3 convention)
    }
    return json.Marshal(raw)
}

IMPORTANT: When marshaling the variant message, use json.Marshal(v.VariantGoName) NOT protojson.Marshal(v.VariantGoName). This ensures that if the variant message has its own MarshalJSON (e.g., for int64 encoding), it will be used. The variant's MarshalJSON will call protojson internally. This is the key to annotation composability.

  1. Generated MarshalJSON for NON-FLATTENED mode:
// Same as above but instead of merging variant fields:
switch v := x.OneofGoName.(type) {
case *MessageName_VariantGoName:
    raw["discriminatorName"], _ = json.Marshal("discriminatorValue")
    // Variant stays nested under its original field name
    // protojson already put it there, so we just add the discriminator
case nil:
    // Unset: omit discriminator
}

For non-flattened, protojson already serializes the oneof correctly (nested under field name). We only need to add the discriminator field and NOT delete the variant fields.

  1. Generated UnmarshalJSON for FLATTENED mode:
func (x *MessageName) UnmarshalJSON(data []byte) error {
    var raw map[string]json.RawMessage
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
 
    // Read discriminator
    var discriminatorValue string
    if discRaw, ok := raw["discriminatorName"]; ok {
        if err := json.Unmarshal(discRaw, &discriminatorValue); err != nil {
            return err
        }
    }
 
    // Route to correct variant
    switch discriminatorValue {
    case "variantValue1":
        variant := &VariantMessage1{}
        // For flattened: the variant's fields are at the top level
        // Marshal raw back and unmarshal into variant
        variantData, err := json.Marshal(raw)
        if err != nil {
            return err
        }
        if err := json.Unmarshal(variantData, variant); err != nil {
            return err
        }
        x.OneofGoName = &MessageName_VariantGoName{VariantGoName: variant}
    // ... for each variant
    case "":
        // No discriminator = unset oneof
    }
 
    // Remove discriminator and variant fields from raw, then unmarshal non-oneof fields
    delete(raw, "discriminatorName")
    // Delete variant child field names too (to avoid protojson unknown field errors)
    // ... delete each variant message's field JSON names
 
    // Re-marshal remaining and unmarshal via protojson for non-oneof fields
    remaining, err := json.Marshal(raw)
    if err != nil {
        return err
    }
    return protojson.Unmarshal(remaining, x)
}

IMPORTANT for UnmarshalJSON: Use json.Unmarshal(variantData, variant) to unmarshal the variant, which will invoke the variant's own UnmarshalJSON if it has one (for annotation composability). Then use protojson.Unmarshal only for the remaining non-oneof fields of the parent.

  1. Generated UnmarshalJSON for NON-FLATTENED mode:
// Read discriminator, then extract the nested variant
switch discriminatorValue {
case "variantValue1":
    variant := &VariantMessage1{}
    if variantRaw, ok := raw["variantFieldName"]; ok {
        if err := json.Unmarshal(variantRaw, variant); err != nil {
            return err
        }
    }
    x.OneofGoName = &MessageName_VariantGoName{VariantGoName: variant}
}
// Remove discriminator from raw, leave variant nested
delete(raw, "discriminatorName")
remaining, _ := json.Marshal(raw)
return protojson.Unmarshal(remaining, x)
  1. Required imports for generated code: encoding/json, google.golang.org/protobuf/encoding/protojson

  2. In generator.go, add call to g.generateOneofDiscriminatorFile(file) after the existing encoding file calls. Order: encoding, enum_encoding, nullable, empty_behavior, timestamp_format, bytes_encoding, oneof_discriminator.

  3. Copy internal/httpgen/oneof_discriminator.go to internal/clientgen/oneof_discriminator.go (change package name only). This follows the established identical-file pattern (D-04-02-03, D-05-02-01, D-06-02-01).

  4. In clientgen/generator.go, add the same call.

```bash go build ./internal/httpgen/... ./internal/clientgen/... go test -v ./internal/httpgen/... ./internal/clientgen/... make lint-fix ``` - internal/httpgen/oneof_discriminator.go exists with OneofDiscriminatorContext and generation logic - internal/clientgen/oneof_discriminator.go is identical (except package name) - generator.go in both packages calls generateOneofDiscriminatorFile - Validation detects discriminator collisions and scalar variants with flatten - Validation detects MarshalJSON conflicts with other encoding features - Generated MarshalJSON handles flattened and non-flattened modes - Generated UnmarshalJSON reads discriminator, routes to correct variant type - Unset oneof omits discriminator entirely - Custom oneof_value overrides discriminator string per variant - All existing golden tests still pass (backward compatible)
Task 2: Implement oneof discriminator in ts-client and openapiv3 with golden tests internal/tsclientgen/types.go internal/tsclientgen/generator.go internal/openapiv3/types.go internal/openapiv3/generator.go internal/httpgen/testdata/proto/oneof_discriminator.proto internal/clientgen/testdata/proto/oneof_discriminator.proto internal/tsclientgen/testdata/proto/oneof_discriminator.proto internal/openapiv3/testdata/proto/oneof_discriminator.proto **TypeScript client (internal/tsclientgen/types.go and generator.go):**

For a message with a discriminated oneof (flattened), generate a TypeScript discriminated union type:

// Flattened discriminated union
export type EventContent =
  | { type: "text"; body: string }
  | { type: "img"; url: string; width: number; height: number };
 
// The parent message uses the discriminated union inline
// When flatten=true, the message itself becomes the union:
export type Event = {
  id: string;
} & EventContent;
// Or more precisely, the message is a union of objects each containing
// the common fields + discriminator + variant fields

For non-flattened mode:

export type EventContent =
  | { type: "text"; text: TextContent }
  | { type: "img"; image: ImageContent };
 
export interface Event {
  id: string;
  content?: EventContent;
}

Implementation approach for tsclientgen:

  1. In the interface generation function, detect oneofs with discriminator annotation via annotations.GetOneofConfig(oneof)
  2. For annotated oneofs, skip emitting the standard oneof fields in the interface
  3. Instead, generate a discriminated union type (TypeScript union of object literal types)
  4. For flattened mode: the parent message type becomes a union that includes common fields in each branch
  5. For non-flattened mode: generate a union type for the oneof, reference it as an optional property
  6. Handle unset oneof: the discriminated union property is optional (?)

The exact approach for TypeScript type generation depends on the existing tsclientgen patterns. Study internal/tsclientgen/types.go and internal/tsclientgen/generator.go to understand how interfaces are generated, and extend that pattern.

OpenAPI (internal/openapiv3/types.go and generator.go):

For messages with discriminated oneofs, modify the schema generation:

  1. In the schema builder, detect oneofs with annotations.GetOneofConfig(oneof)
  2. For flattened discriminated unions, generate per-variant schemas and use oneOf + discriminator:
Event:
  oneOf:
    - $ref: '#/components/schemas/Event_text'
    - $ref: '#/components/schemas/Event_img'
  discriminator:
    propertyName: type
    mapping:
      text: '#/components/schemas/Event_text'
      img: '#/components/schemas/Event_img'
 
Event_text:
  type: object
  required: [type]
  properties:
    id:
      type: string
    type:
      type: string
      enum: [text]
    body:
      type: string
 
Event_img:
  type: object
  required: [type]
  properties:
    id:
      type: string
    type:
      type: string
      enum: [img]
    url:
      type: string
    width:
      type: integer
    height:
      type: integer
  1. For non-flattened, the variant stays nested:
Event:
  type: object
  properties:
    id:
      type: string
    type:
      type: string
      enum: [text, img]
  oneOf:
    - type: object
      properties:
        text:
          $ref: '#/components/schemas/TextContent'
    - type: object
      properties:
        image:
          $ref: '#/components/schemas/ImageContent'
  discriminator:
    propertyName: type
    mapping:
      text: '#/components/schemas/TextContent'
      img: '#/components/schemas/ImageContent'

Use libopenapi's base.Discriminator struct with PropertyName and Mapping fields. Use orderedmap.New[string, string]() for the mapping.

Golden test proto (oneof_discriminator.proto):

Create the test proto in httpgen/testdata/proto/ and symlink to other generators:

syntax = "proto3";
package oneof_discriminator.test;
 
import "sebuf/http/annotations.proto";
 
option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/oneof_discriminator";
 
// Variant message types
message TextContent {
  string body = 1;
}
 
message ImageContent {
  string url = 1;
  int32 width = 2;
  int32 height = 3;
}
 
message VideoContent {
  string url = 1;
  int32 duration = 2;
}
 
// Flattened discriminated union
message FlattenedEvent {
  string id = 1;
  oneof content {
    option (sebuf.http.oneof_config) = {
      discriminator: "type"
      flatten: true
    };
    TextContent text = 2;
    ImageContent image = 3 [(sebuf.http.oneof_value) = "img"];
  }
}
 
// Non-flattened discriminated union (discriminator alongside nested variant)
message NestedEvent {
  string id = 1;
  oneof content {
    option (sebuf.http.oneof_config) = {
      discriminator: "kind"
      flatten: false
    };
    TextContent text = 2;
    ImageContent image = 3;
    VideoContent video = 4 [(sebuf.http.oneof_value) = "vid"];
  }
}
 
// Message with no oneof annotation (backward compatible)
message PlainEvent {
  string id = 1;
  oneof content {
    TextContent text = 2;
    ImageContent image = 3;
  }
}
 
service OneofDiscriminatorService {
  option (sebuf.http.service_config) = {
    base_path: "/api/v1"
  };
 
  rpc TestFlattenedEvent(FlattenedEvent) returns (FlattenedEvent) {
    option (sebuf.http.config) = {
      path: "/events/flattened"
      method: HTTP_METHOD_POST
    };
  }
 
  rpc TestNestedEvent(NestedEvent) returns (NestedEvent) {
    option (sebuf.http.config) = {
      path: "/events/nested"
      method: HTTP_METHOD_POST
    };
  }
 
  rpc TestPlainEvent(PlainEvent) returns (PlainEvent) {
    option (sebuf.http.config) = {
      path: "/events/plain"
      method: HTTP_METHOD_POST
    };
  }
}

Create symlinks from clientgen, tsclientgen, openapiv3 testdata/proto/ directories to the httpgen source proto.

Run UPDATE_GOLDEN=1 tests to generate golden files, then verify they pass without the flag:

UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/httpgen/...
UPDATE_GOLDEN=1 go test -run TestGoldenFiles ./internal/clientgen/...
UPDATE_GOLDEN=1 go test -run TestGoldenFiles ./internal/tsclientgen/...
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/openapiv3/...
```bash go build ./internal/tsclientgen/... ./internal/openapiv3/... go test -v ./internal/httpgen/... ./internal/clientgen/... ./internal/tsclientgen/... ./internal/openapiv3/... make lint-fix ``` All golden tests pass without UPDATE_GOLDEN flag. Lint clean. - ts-client generates discriminated union types for flattened oneofs - ts-client generates union types with nested variants for non-flattened oneofs - OpenAPI uses oneOf with discriminator keyword and mapping - Flattened OpenAPI generates per-variant schemas with common + discriminator + variant properties - PlainEvent (no annotation) generates standard output (backward compatible) - Custom oneof_value reflected in discriminator mapping values - Golden test protos created and symlinked - All golden tests pass for all 4 generators - All existing tests pass (backward compatible)
1. Build passes: `go build ./...` 2. All tests pass: `go test ./...` 3. Lint clean: `make lint-fix` 4. Golden files updated and committed for all 4 generators 5. Go server and client produce identical MarshalJSON/UnmarshalJSON for oneof discriminator 6. TypeScript generates correct discriminated union types 7. OpenAPI uses proper discriminator keyword with mapping 8. Backward compatible: protos without oneof_config produce identical output to before 9. Unset oneof omits discriminator from JSON (proto3 convention) 10. Custom oneof_value overrides discriminator string per variant

<success_criteria>

  • Flattened discriminated union produces flat JSON with discriminator field alongside variant fields
  • Non-flattened discriminated union produces discriminator alongside nested variant
  • Unset oneof omits discriminator entirely (no "type": "" or "type": null)
  • Custom oneof_value overrides per-variant discriminator string
  • go-http and go-client produce identical code (byte-level after normalization)
  • TypeScript discriminated union types correctly model the JSON shape
  • OpenAPI discriminator keyword with mapping accurately represents the wire format
  • Generation-time error on MarshalJSON conflicts (oneof + other encoding on same message)
  • All golden tests pass, all existing tests pass </success_criteria>
After completion, create `.planning/phases/07-json-structural-transforms/07-02-SUMMARY.md`