Purpose: Establish the annotation infrastructure that all 4 generators will use for oneof discriminated unions and nested message flattening. This is the foundation for the rest of Phase 7. The annotations use a coupled OneofConfig message (containing both discriminator name and flatten flag) at the oneof level via google.protobuf.OneofOptions, plus per-variant oneof_value and per-field flatten/flatten_prefix via existing google.protobuf.FieldOptions.
Output: Proto extensions for oneof_config (50017), oneof_value (50018), flatten (50019), flatten_prefix (50020). Shared Go functions for parsing, detection, validation, and collision detection in internal/annotations package.
<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>
Existing annotation patterns to follow exactly
@internal/annotations/nullable.go @internal/annotations/timestamp_format.go @internal/annotations/empty_behavior.go @internal/annotations/annotations_test.go @proto/sebuf/http/annotations.proto
- OneofConfig message (place before the extend blocks, after existing enums):
// OneofConfig controls oneof serialization as a discriminated union.
// Applied to a oneof definition via (sebuf.http.oneof_config).
message OneofConfig {
// The JSON field name for the discriminator (e.g., "type", "kind").
// Required: if empty, the annotation is ignored.
string discriminator = 1;
// Whether to flatten variant message fields to the same level as the discriminator.
// When true: variant's child fields are promoted to the parent object alongside the discriminator.
// When false: variant stays nested under its field name with the discriminator alongside.
// Requires all variant fields to be message types when true.
bool flatten = 2;
}- Add a new extend block for OneofOptions (after the existing extend blocks for FieldOptions and EnumValueOptions):
// Extension for oneof-level options
extend google.protobuf.OneofOptions {
// Controls oneof serialization as a discriminated union.
// When set, adds a discriminator field to the JSON output identifying which variant is set.
optional OneofConfig oneof_config = 50017;
}- Add to the existing FieldOptions extension block (after bytes_encoding at 50016):
// Custom discriminator value for this oneof variant field.
// When set, this value is used in the discriminator field instead of the proto field name.
// Only valid on fields that are part of a oneof with oneof_config annotation.
optional string oneof_value = 50018;
// Flatten a nested message field, promoting its child fields to the parent level in JSON.
// Only valid on singular message fields (not repeated, not map, not oneof variant).
// When true: child message fields appear at the parent level (e.g., address.street becomes street).
optional bool flatten = 50019;
// Prefix to prepend to flattened field names to avoid collisions.
// Only valid when flatten=true is also set.
// Example: flatten_prefix="billing_" with child field "street" produces "billing_street" in JSON.
optional string flatten_prefix = 50020;Extension numbers continue the sequence: 50017 (oneof_config on OneofOptions), 50018-50020 (on FieldOptions, continuing from 50016).
After editing, regenerate Go code: cd proto && buf generate
buf build to verify the proto compiles without errors:
cd proto && buf buildRegenerate Go code:
cd proto && buf generateVerify the generated Go code compiles:
go build ./http/...package annotations
import (
"fmt"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
"github.com/SebastienMelki/sebuf/http"
)
// OneofDiscriminatorInfo holds parsed oneof discriminator configuration for a single oneof.
type OneofDiscriminatorInfo struct {
Oneof *protogen.Oneof
Discriminator string // JSON field name for discriminator (e.g., "type")
Flatten bool // Whether to flatten variant fields to parent level
Variants []OneofVariant // Resolved variant info
}
// OneofVariant holds information about a single oneof variant.
type OneofVariant struct {
Field *protogen.Field
DiscriminatorVal string // Value for this variant in the discriminator field
IsMessage bool // Whether variant is a message type (required for flatten)
}
// GetOneofConfig returns the OneofConfig for a oneof, or nil if not annotated.
// Returns nil if discriminator is empty (annotation is treated as absent).
func GetOneofConfig(oneof *protogen.Oneof) *http.OneofConfig {
options := oneof.Desc.Options()
if options == nil {
return nil
}
if !proto.HasExtension(options, http.E_OneofConfig) {
return nil
}
ext := proto.GetExtension(options, http.E_OneofConfig)
config, ok := ext.(*http.OneofConfig)
if !ok || config == nil {
return nil
}
if config.GetDiscriminator() == "" {
return nil // No discriminator = no config
}
return config
}
// GetOneofVariantValue returns the custom discriminator value for a oneof variant field.
// Returns empty string if not set (caller should use the proto field name as default).
func GetOneofVariantValue(field *protogen.Field) string {
options := field.Desc.Options()
if options == nil {
return ""
}
if !proto.HasExtension(options, http.E_OneofValue) {
return ""
}
ext := proto.GetExtension(options, http.E_OneofValue)
value, ok := ext.(string)
if !ok {
return ""
}
return value
}
// GetOneofDiscriminatorInfo resolves the full discriminator info for a oneof.
// Returns nil if the oneof has no oneof_config annotation.
// For each variant, uses oneof_value if set, otherwise proto field JSON name.
func GetOneofDiscriminatorInfo(oneof *protogen.Oneof) *OneofDiscriminatorInfo {
config := GetOneofConfig(oneof)
if config == nil {
return nil
}
info := &OneofDiscriminatorInfo{
Oneof: oneof,
Discriminator: config.GetDiscriminator(),
Flatten: config.GetFlatten(),
}
for _, field := range oneof.Fields {
variant := OneofVariant{
Field: field,
IsMessage: field.Message != nil,
}
// Use custom oneof_value if set, otherwise use proto field JSON name
customValue := GetOneofVariantValue(field)
if customValue != "" {
variant.DiscriminatorVal = customValue
} else {
variant.DiscriminatorVal = string(field.Desc.Name())
}
info.Variants = append(info.Variants, variant)
}
return info
}
// HasOneofDiscriminator returns true if ANY oneof in the message has a discriminator annotation.
func HasOneofDiscriminator(message *protogen.Message) bool {
for _, oneof := range message.Oneofs {
if GetOneofConfig(oneof) != nil {
return true
}
}
return false
}
// ValidateOneofDiscriminator validates a oneof with discriminator annotation.
// Checks for:
// 1. Discriminator name collisions with parent message fields
// 2. When flatten=true: all variants must be message types
// 3. When flatten=true: variant child field names must not collide with parent fields or discriminator
func ValidateOneofDiscriminator(message *protogen.Message, oneof *protogen.Oneof, config *http.OneofConfig) error {
discriminator := config.GetDiscriminator()
// 1. Check discriminator vs parent message fields (non-oneof fields)
for _, field := range message.Fields {
if field.Oneof == oneof {
continue // Skip oneof's own fields
}
if string(field.Desc.JSONName()) == discriminator {
return fmt.Errorf(
"oneof %s.%s: discriminator name %q collides with field %q (JSON: %q)",
message.Desc.Name(), oneof.Desc.Name(), discriminator,
field.Desc.Name(), field.Desc.JSONName(),
)
}
}
if config.GetFlatten() {
// 2. All variants must be message types when flatten=true
for _, field := range oneof.Fields {
if field.Message == nil {
return fmt.Errorf(
"oneof %s.%s with flatten=true: variant %q must be a message type (got scalar)",
message.Desc.Name(), oneof.Desc.Name(), field.Desc.Name(),
)
}
}
// 3. Collect parent JSON names (non-oneof fields + discriminator)
reserved := make(map[string]string) // json_name -> source description
reserved[discriminator] = "discriminator"
for _, field := range message.Fields {
if field.Oneof == oneof {
continue
}
reserved[string(field.Desc.JSONName())] = fmt.Sprintf("parent field %q", field.Desc.Name())
}
// Check each variant's child fields against reserved names
for _, variantField := range oneof.Fields {
if variantField.Message == nil {
continue // Already caught above
}
for _, childField := range variantField.Message.Fields {
childJSON := string(childField.Desc.JSONName())
if source, exists := reserved[childJSON]; exists {
return fmt.Errorf(
"oneof %s.%s with flatten=true: variant %q child field %q (JSON: %q) collides with %s",
message.Desc.Name(), oneof.Desc.Name(),
variantField.Desc.Name(), childField.Desc.Name(), childJSON, source,
)
}
}
}
}
return nil
}Create internal/annotations/flatten.go:
package annotations
import (
"fmt"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/SebastienMelki/sebuf/http"
)
// IsFlattenField returns true if the field has flatten=true annotation.
func IsFlattenField(field *protogen.Field) bool {
options := field.Desc.Options()
if options == nil {
return false
}
if !proto.HasExtension(options, http.E_Flatten) {
return false
}
ext := proto.GetExtension(options, http.E_Flatten)
flatten, ok := ext.(bool)
return ok && flatten
}
// GetFlattenPrefix returns the flatten prefix for a field, or empty string if not set.
func GetFlattenPrefix(field *protogen.Field) string {
options := field.Desc.Options()
if options == nil {
return ""
}
if !proto.HasExtension(options, http.E_FlattenPrefix) {
return ""
}
ext := proto.GetExtension(options, http.E_FlattenPrefix)
prefix, ok := ext.(string)
if !ok {
return ""
}
return prefix
}
// HasFlattenFields returns true if any field in the message has a flatten annotation.
func HasFlattenFields(message *protogen.Message) bool {
for _, field := range message.Fields {
if IsFlattenField(field) {
return true
}
}
return false
}
// ValidateFlattenField validates that flatten is used correctly on a field.
// Returns error if:
// - flatten is on a repeated field
// - flatten is on a map field
// - flatten is on a non-message field
// - flatten is on a oneof variant field (oneof_flatten handles that)
// - flatten_prefix is set without flatten=true
func ValidateFlattenField(field *protogen.Field, messageName string) error {
isFlatten := IsFlattenField(field)
prefix := GetFlattenPrefix(field)
// flatten_prefix without flatten=true is an error
if !isFlatten && prefix != "" {
return fmt.Errorf(
"field %s.%s: flatten_prefix=%q set without flatten=true",
messageName, field.Desc.Name(), prefix,
)
}
if !isFlatten {
return nil // No annotation, nothing to validate
}
// flatten on repeated fields
if field.Desc.IsList() {
return fmt.Errorf(
"field %s.%s: flatten is not valid on repeated fields",
messageName, field.Desc.Name(),
)
}
// flatten on map fields
if field.Desc.IsMap() {
return fmt.Errorf(
"field %s.%s: flatten is not valid on map fields",
messageName, field.Desc.Name(),
)
}
// flatten on non-message fields
if field.Desc.Kind() != protoreflect.MessageKind {
return fmt.Errorf(
"field %s.%s: flatten is only valid on message fields (got %s)",
messageName, field.Desc.Name(), field.Desc.Kind(),
)
}
// flatten on oneof variant fields
if field.Oneof != nil {
return fmt.Errorf(
"field %s.%s: flatten is not valid on oneof variant fields (use oneof_config.flatten instead)",
messageName, field.Desc.Name(),
)
}
return nil
}
// ValidateFlattenCollisions checks for field name collisions when multiple fields
// are flattened at the same level. Also checks flattened child fields against
// non-flattened parent fields.
// Returns error on first collision found.
func ValidateFlattenCollisions(message *protogen.Message) error {
// Collect all JSON names from non-flattened parent fields
usedNames := make(map[string]string) // json_name -> source description
for _, field := range message.Fields {
if IsFlattenField(field) {
continue // We'll check flattened fields below
}
usedNames[string(field.Desc.JSONName())] = fmt.Sprintf("parent field %q", field.Desc.Name())
}
// Now check each flattened field's children against used names
for _, field := range message.Fields {
if !IsFlattenField(field) || field.Message == nil {
continue
}
prefix := GetFlattenPrefix(field)
for _, childField := range field.Message.Fields {
flattenedName := prefix + string(childField.Desc.JSONName())
if source, exists := usedNames[flattenedName]; exists {
return fmt.Errorf(
"field %s.%s: flattened child %q (JSON: %q) collides with %s",
string(message.Desc.Name()), field.Desc.Name(),
childField.Desc.Name(), flattenedName, source,
)
}
usedNames[flattenedName] = fmt.Sprintf("flattened from %s.%s", field.Desc.Name(), childField.Desc.Name())
}
}
return nil
}Add unit tests to annotations_test.go for:
- Extension descriptor availability (E_OneofConfig, E_OneofValue, E_Flatten, E_FlattenPrefix)
- OneofConfig message fields are correctly defined
- IsFlattenField returns false for nil options
- GetFlattenPrefix returns empty string for nil options
- GetOneofVariantValue returns empty string for nil options
- Error message format for validation errors (use descriptive test names)
Follow the established pattern in the existing annotations_test.go file (look at existing tests for E_Int64Encoding, E_Nullable, etc.).
go build ./internal/annotations/...
go test -v ./internal/annotations/...
make lint-fixAll tests pass, lint clean.
- GetOneofConfig returns the OneofConfig for annotated oneofs, nil for unannotated
- GetOneofVariantValue returns custom discriminator string or empty
- GetOneofDiscriminatorInfo resolves full variant info with defaults
- HasOneofDiscriminator detects annotated oneofs in a message
- ValidateOneofDiscriminator catches discriminator-vs-parent collisions
- ValidateOneofDiscriminator catches scalar variants with flatten=true
- ValidateOneofDiscriminator catches variant-child-vs-parent collisions when flatten=true
- IsFlattenField correctly detects flatten=true
- GetFlattenPrefix returns configured prefix
- HasFlattenFields detects any flatten annotations in a message
- ValidateFlattenField rejects repeated, map, non-message, and oneof variant fields
- ValidateFlattenField rejects flatten_prefix without flatten=true
- ValidateFlattenCollisions detects sibling flattened field name collisions
- All functions follow existing annotation package patterns
- Unit tests cover extension descriptors and error scenarios
- Lint passes
<success_criteria>
- Proto files can import and use oneof_config, oneof_value, flatten, and flatten_prefix annotations
- GetOneofConfig(oneof) returns the configured OneofConfig with discriminator and flatten
- GetOneofVariantValue(field) returns custom variant discriminator value
- GetOneofDiscriminatorInfo(oneof) resolves all variant info with defaults
- IsFlattenField(field) detects flatten=true on message fields
- GetFlattenPrefix(field) returns the configured prefix string
- Validation functions detect all collision types at generation time
- Validation rejects invalid annotation usage (wrong field types, invalid combinations)
- All existing tests continue to pass (zero regression) </success_criteria>
