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>
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
- 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
}- 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 {...}
}- For enum_encoding=NUMBER, generate simpler marshal:
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(int32(s))
}- 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
}- 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
}
}
}- 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
- 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("")
}- 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:
- 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)
}// 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]<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>
