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>
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
- Define context structs:
type OneofDiscriminatorContext struct {
Message *protogen.Message
Oneofs []*annotations.OneofDiscriminatorInfo
}- Implement detection and collection functions:
hasOneofDiscriminatorFields(message) bool-- checks if any oneof has discriminator annotationcollectOneofDiscriminatorContexts(file) []*OneofDiscriminatorContext-- collects annotated messagesvalidateOneofDiscriminatorAnnotations(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"
- For each message, for each oneof with config, call
- Implement
generateOneofDiscriminatorFile(file) errormethod 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
- 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.
- 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.
- 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.
- 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)-
Required imports for generated code:
encoding/json,google.golang.org/protobuf/encoding/protojson -
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. -
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).
-
In clientgen/generator.go, add the same call.
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 fieldsFor 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:
- In the interface generation function, detect oneofs with discriminator annotation via
annotations.GetOneofConfig(oneof) - For annotated oneofs, skip emitting the standard oneof fields in the interface
- Instead, generate a discriminated union type (TypeScript union of object literal types)
- For flattened mode: the parent message type becomes a union that includes common fields in each branch
- For non-flattened mode: generate a union type for the oneof, reference it as an optional property
- 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:
- In the schema builder, detect oneofs with
annotations.GetOneofConfig(oneof) - 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- 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/...<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>
