Implement enum encoding support across all 4 generators: enum_encoding for NUMBER/STRING control and enum_value for custom JSON value mapping.

Purpose: Enable developers to use REST-friendly enum values (e.g., "active" instead of "STATUS_ACTIVE") and choose between string names or numeric values for enum serialization.

Output: All generators respect enum_encoding and enum_value annotations consistently. Custom enum values appear in TypeScript union types and OpenAPI schemas.

<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/phases/04-json-primitive-encoding/04-CONTEXT.md @.planning/phases/04-json-primitive-encoding/04-RESEARCH.md @.planning/phases/04-json-primitive-encoding/04-01-SUMMARY.md

Generator code to modify

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

Annotation package from Plan 01#

@internal/annotations/enum_encoding.go

Task 1: Implement enum encoding in Go generators (go-http and go-client) internal/httpgen/encoding.go internal/httpgen/generator.go internal/clientgen/encoding.go internal/clientgen/generator.go Add enum encoding support to internal/httpgen/encoding.go:
  1. Add conflict detection at generation time:
func validateEnumAnnotations(field *protogen.Field) error {
    if annotations.HasConflictingEnumAnnotations(field) {
        return fmt.Errorf("field %s has both enum_encoding=NUMBER and enum_value annotations - this is not allowed", field.Desc.Name())
    }
    return nil
}
  1. Generate enum lookup maps when enum has custom values:
func generateEnumMarshalCode(gf *protogen.GeneratedFile, enum *protogen.Enum) {
    // Generate toJSON and fromJSON maps
    // var statusToJSON = map[Status]string{...}
    // var statusFromJSON = map[string]Status{...}
}
 
func generateEnumMarshalJSON(gf *protogen.GeneratedFile, enum *protogen.Enum) {
    // func (s Status) MarshalJSON() ([]byte, error) {...}
}
 
func generateEnumUnmarshalJSON(gf *protogen.GeneratedFile, enum *protogen.Enum) {
    // func (s *Status) UnmarshalJSON(data []byte) error {...}
}
  1. For enum_encoding=NUMBER, generate simpler marshal:
func (s Status) MarshalJSON() ([]byte, error) {
    return json.Marshal(int32(s))
}
  1. For custom enum_value annotations, generate lookup maps per RESEARCH.md:
var statusToJSON = map[Status]string{
    Status_STATUS_UNSPECIFIED: "unknown",  // from enum_value annotation
    Status_STATUS_ACTIVE:      "active",   // from enum_value annotation
    Status_STATUS_INACTIVE:    "inactive", // from enum_value annotation
}
  1. In generator.go, call validation before generation:
// Before generating, validate no conflicting annotations
for _, field := range message.Fields {
    if field.Desc.Kind() == protoreflect.EnumKind {
        if err := validateEnumAnnotations(field); err != nil {
            return err // Fail generation with clear error
        }
    }
}
  1. Mirror all logic in clientgen/encoding.go for identical behavior.

Handle the UnmarshalJSON carefully:

  • Accept both string and number input
  • For string: check fromJSON map first, then proto name
  • For number: cast to enum type directly
go build ./internal/httpgen/... ./internal/clientgen/...
go test -v ./internal/httpgen/... ./internal/clientgen/...

Test that conflicting annotations cause generation error. Test that custom enum values serialize correctly.

  • Conflict detection fails generation with clear error message
  • enum_encoding=NUMBER generates int32 marshal
  • enum_value generates lookup maps and MarshalJSON/UnmarshalJSON
  • Server and client produce identical enum marshaling
  • All existing tests pass
Task 2: Implement enum encoding in ts-client and OpenAPI generators internal/tsclientgen/types.go internal/tsclientgen/generator.go internal/openapiv3/types.go Update internal/tsclientgen/types.go generateEnumType function:
  1. Check for custom enum_value annotations:
func generateEnumType(p printer, enum *protogen.Enum) {
    name := string(enum.Desc.Name())
    values := enum.Values
 
    if len(values) == 0 {
        p("export type %s = string;", name)
        p("")
        return
    }
 
    var parts []string
    for _, v := range values {
        // Check for custom enum_value
        customValue := annotations.GetEnumValueMapping(v)
        if customValue != "" {
            parts = append(parts, fmt.Sprintf(`"%s"`, customValue))
        } else {
            parts = append(parts, fmt.Sprintf(`"%s"`, string(v.Desc.Name())))
        }
    }
 
    p("export type %s = %s;", name, strings.Join(parts, " | "))
    p("")
}
  1. For enum_encoding=NUMBER on a field, the TypeScript type should be number:
// In tsFieldType or similar, check enum_encoding on the field
if field.Desc.Kind() == protoreflect.EnumKind {
    encoding := annotations.GetEnumEncoding(field)
    if encoding == http.EnumEncoding_ENUM_ENCODING_NUMBER {
        return tsNumber // "number"
    }
    // Otherwise return enum type name (string union)
    return string(field.Enum.Desc.Name())
}

Update internal/openapiv3/types.go convertEnumField function:

  1. Check for custom enum_value annotations:
func (g *Generator) convertEnumField(field *protogen.Field) *base.SchemaProxy {
    // Check encoding on the field
    encoding := annotations.GetEnumEncoding(field)
    if encoding == http.EnumEncoding_ENUM_ENCODING_NUMBER {
        // Return integer schema
        schema := &base.Schema{
            Type: []string{headerTypeInteger},
        }
        // Add enum values as numbers
        for _, value := range field.Enum.Values {
            schema.Enum = append(schema.Enum, &yaml.Node{
                Kind:  yaml.ScalarNode,
                Value: strconv.Itoa(int(value.Desc.Number())),
            })
        }
        return base.CreateSchemaProxy(schema)
    }
 
    // String encoding (default or explicit STRING)
    schema := &base.Schema{
        Type: []string{"string"},
        Enum: make([]*yaml.Node, 0, len(field.Enum.Values)),
    }
 
    for _, value := range field.Enum.Values {
        // Check for custom enum_value
        customValue := annotations.GetEnumValueMapping(value)
        if customValue != "" {
            schema.Enum = append(schema.Enum, &yaml.Node{
                Kind:  yaml.ScalarNode,
                Value: customValue,
            })
        } else {
            schema.Enum = append(schema.Enum, &yaml.Node{
                Kind:  yaml.ScalarNode,
                Value: string(value.Desc.Name()),
            })
        }
    }
 
    return base.CreateSchemaProxy(schema)
}
```bash go build ./internal/tsclientgen/... ./internal/openapiv3/... go test -v ./internal/tsclientgen/... ./internal/openapiv3/... ``` Verify: - TypeScript union includes custom values when annotated - TypeScript uses 'number' for enum_encoding=NUMBER - OpenAPI schema includes custom values - OpenAPI uses integer type for enum_encoding=NUMBER - ts-client generates custom enum values in union type - ts-client generates 'number' type for NUMBER encoding - OpenAPI generates custom enum values in schema - OpenAPI generates integer type for NUMBER encoding - All existing tests pass
Task 3: Add golden file tests for enum encoding across all generators internal/httpgen/testdata/encoding.proto internal/httpgen/testdata/golden/encoding_http_binding.pb.go internal/clientgen/testdata/golden/encoding_client.pb.go internal/tsclientgen/testdata/golden/encoding_client.ts internal/openapiv3/testdata/golden/EncodingTestService.openapi.yaml Update the encoding.proto created in Plan 02 to include enum encoding tests:
// Add to encoding.proto
enum Status {
  STATUS_UNSPECIFIED = 0 [(sebuf.http.enum_value) = "unknown"];
  STATUS_ACTIVE = 1 [(sebuf.http.enum_value) = "active"];
  STATUS_INACTIVE = 2 [(sebuf.http.enum_value) = "inactive"];
}
 
enum Priority {
  PRIORITY_LOW = 0;
  PRIORITY_MEDIUM = 1;
  PRIORITY_HIGH = 2;
}
 
message EnumEncodingTest {
  // Default encoding with custom values
  Status status = 1;
 
  // NUMBER encoding
  Priority priority_as_number = 2 [(sebuf.http.enum_encoding) = ENUM_ENCODING_NUMBER];
 
  // STRING encoding (explicit, same as default)
  Priority priority_as_string = 3 [(sebuf.http.enum_encoding) = ENUM_ENCODING_STRING];
}

Run golden file updates for all generators:

UPDATE_GOLDEN=1 go test -run Golden ./internal/httpgen/...
UPDATE_GOLDEN=1 go test -run Golden ./internal/clientgen/...
UPDATE_GOLDEN=1 go test -run Golden ./internal/tsclientgen/...
UPDATE_GOLDEN=1 go test -run Golden ./internal/openapiv3/...

Verify golden files contain expected patterns:

Go golden (both http and client):

  • statusToJSON and statusFromJSON maps with custom values
  • MarshalJSON/UnmarshalJSON for Status enum
  • Simple int32 marshal for Priority when enum_encoding=NUMBER

TypeScript golden:

export type Status = "unknown" | "active" | "inactive";
export type Priority = "PRIORITY_LOW" | "PRIORITY_MEDIUM" | "PRIORITY_HIGH";
 
export interface EnumEncodingTest {
  status: Status;
  priorityAsNumber: number;  // NUMBER encoding -> number
  priorityAsString: Priority; // STRING encoding -> enum type
}

OpenAPI golden:

Status:
  type: string
  enum: ["unknown", "active", "inactive"]
Priority:
  type: string
  enum: ["PRIORITY_LOW", "PRIORITY_MEDIUM", "PRIORITY_HIGH"]
EnumEncodingTest:
  properties:
    priorityAsNumber:
      type: integer
      enum: [0, 1, 2]
```bash go test -v -run Golden ./internal/httpgen/... go test -v -run Golden ./internal/clientgen/... go test -v -run Golden ./internal/tsclientgen/... go test -v -run Golden ./internal/openapiv3/... make lint-fix ``` All golden tests pass without UPDATE_GOLDEN flag. - encoding.proto includes enum encoding test cases - Go golden files include enum lookup maps and marshal methods - TypeScript golden includes custom union values and number type - OpenAPI golden includes custom enum values and integer type - All golden tests pass across all 4 generators
1. Build passes: `go build ./...` 2. All tests pass: `go test ./...` 3. Lint clean: `make lint-fix` 4. Golden files updated for all 4 generators 5. Conflicting annotations cause generation error 6. Custom enum values consistent across all generators

<success_criteria>

  • enum_value annotations produce custom JSON strings in all generators
  • enum_encoding=NUMBER produces integer values in all generators
  • enum_encoding=NUMBER + enum_value combination fails generation with clear error
  • Server, client, TypeScript, and OpenAPI all use same enum value strings
  • Backward compatible: enums without annotations produce identical output </success_criteria>
After completion, create `.planning/phases/04-json-primitive-encoding/04-04-SUMMARY.md`