Implement empty_behavior annotation support across all 4 generators (go-http, go-client, ts-client, openapiv3).

Purpose: Enable developers to control how empty message fields serialize to JSON. PRESERVE emits {}, NULL emits null, OMIT removes the field. This provides fine-grained control over API response shapes.

Output: Go generators produce MarshalJSON that handles empty messages according to the annotation. OpenAPI generator documents nullable message fields correctly. TypeScript types remain unchanged (message fields are already optional).

<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/05-json-nullable-empty/05-CONTEXT.md @.planning/phases/05-json-nullable-empty/05-RESEARCH.md @.planning/phases/05-json-nullable-empty/05-01-SUMMARY.md

Existing encoding patterns to follow

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

Annotation functions from Plan 01#

@internal/annotations/empty_behavior.go

Task 1: Implement empty_behavior in Go generators (go-http and go-client) internal/httpgen/empty_behavior.go internal/httpgen/generator.go internal/clientgen/empty_behavior.go internal/clientgen/generator.go Create internal/httpgen/empty_behavior.go following the pattern in nullable.go:
package httpgen
 
import (
    "strings"
 
    "google.golang.org/protobuf/compiler/protogen"
 
    "github.com/SebastienMelki/sebuf/http"
    "github.com/SebastienMelki/sebuf/internal/annotations"
)
 
// EmptyBehaviorContext holds information about messages that need custom JSON encoding
// for empty_behavior fields.
type EmptyBehaviorContext struct {
    Message             *protogen.Message
    EmptyBehaviorFields []*EmptyBehaviorFieldInfo
}
 
// EmptyBehaviorFieldInfo holds field info with its empty behavior setting.
type EmptyBehaviorFieldInfo struct {
    Field    *protogen.Field
    Behavior http.EmptyBehavior
}
 
// hasEmptyBehaviorFields returns true if any message field has empty_behavior annotation.
func hasEmptyBehaviorFields(message *protogen.Message) bool {
    for _, field := range message.Fields {
        if annotations.HasEmptyBehaviorAnnotation(field) {
            return true
        }
    }
    return false
}
 
// getEmptyBehaviorFields returns all message fields with empty_behavior annotation.
func getEmptyBehaviorFields(message *protogen.Message) []*EmptyBehaviorFieldInfo {
    var fields []*EmptyBehaviorFieldInfo
    for _, field := range message.Fields {
        if annotations.HasEmptyBehaviorAnnotation(field) {
            fields = append(fields, &EmptyBehaviorFieldInfo{
                Field:    field,
                Behavior: annotations.GetEmptyBehavior(field),
            })
        }
    }
    return fields
}
 
// collectEmptyBehaviorContext analyzes messages in a file and collects empty_behavior info.
func collectEmptyBehaviorContext(file *protogen.File) []*EmptyBehaviorContext {
    var contexts []*EmptyBehaviorContext
    collectEmptyBehaviorMessages(file.Messages, &contexts)
    return contexts
}
 
func collectEmptyBehaviorMessages(messages []*protogen.Message, contexts *[]*EmptyBehaviorContext) {
    for _, msg := range messages {
        if hasEmptyBehaviorFields(msg) {
            *contexts = append(*contexts, &EmptyBehaviorContext{
                Message:             msg,
                EmptyBehaviorFields: getEmptyBehaviorFields(msg),
            })
        }
        collectEmptyBehaviorMessages(msg.Messages, contexts)
    }
}
 
// validateEmptyBehaviorAnnotations validates all empty_behavior annotations in a file.
func validateEmptyBehaviorAnnotations(file *protogen.File) error {
    return validateEmptyBehaviorInMessages(file.Messages)
}
 
func validateEmptyBehaviorInMessages(messages []*protogen.Message) error {
    for _, msg := range messages {
        for _, field := range msg.Fields {
            if err := annotations.ValidateEmptyBehaviorAnnotation(field, msg.GoIdent.GoName); err != nil {
                return err
            }
        }
        if err := validateEmptyBehaviorInMessages(msg.Messages); err != nil {
            return err
        }
    }
    return nil
}
 
// generateEmptyBehaviorEncodingFile generates the *_empty_behavior.pb.go file if needed.
func (g *Generator) generateEmptyBehaviorEncodingFile(file *protogen.File) error {
    if err := validateEmptyBehaviorAnnotations(file); err != nil {
        return err
    }
 
    contexts := collectEmptyBehaviorContext(file)
    if len(contexts) == 0 {
        return nil
    }
 
    filename := file.GeneratedFilenamePrefix + "_empty_behavior.pb.go"
    gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath)
 
    g.writeHeader(gf, file)
    g.writeEmptyBehaviorImports(gf)
 
    for _, ctx := range contexts {
        g.generateEmptyBehaviorMarshalJSON(gf, ctx)
        g.generateEmptyBehaviorUnmarshalJSON(gf, ctx)
    }
 
    return nil
}
 
func (g *Generator) writeEmptyBehaviorImports(gf *protogen.GeneratedFile) {
    gf.P("import (")
    gf.P(`"encoding/json"`)
    gf.P()
    gf.P(`"google.golang.org/protobuf/encoding/protojson"`)
    gf.P(`"google.golang.org/protobuf/proto"`)
    gf.P(")")
    gf.P()
}
 
// generateEmptyBehaviorMarshalJSON generates MarshalJSON that handles empty message fields.
func (g *Generator) generateEmptyBehaviorMarshalJSON(gf *protogen.GeneratedFile, ctx *EmptyBehaviorContext) {
    msgName := ctx.Message.GoIdent.GoName
 
    var fieldNames []string
    for _, f := range ctx.EmptyBehaviorFields {
        fieldNames = append(fieldNames, string(f.Field.Desc.Name()))
    }
 
    gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".")
    gf.P("// This method handles empty_behavior fields: ", strings.Join(fieldNames, ", "))
    gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {")
    gf.P("if x == nil {")
    gf.P("return []byte(\"null\"), nil")
    gf.P("}")
    gf.P()
 
    gf.P("// Use protojson for base serialization")
    gf.P("data, err := protojson.Marshal(x)")
    gf.P("if err != nil {")
    gf.P("return nil, err")
    gf.P("}")
    gf.P()
 
    gf.P("// Parse into a map to handle empty_behavior fields")
    gf.P("var raw map[string]json.RawMessage")
    gf.P("if err := json.Unmarshal(data, &raw); err != nil {")
    gf.P("return nil, err")
    gf.P("}")
    gf.P()
 
    // For each empty_behavior field, apply the configured behavior
    for _, fieldInfo := range ctx.EmptyBehaviorFields {
        g.generateEmptyBehaviorFieldMarshal(gf, fieldInfo)
    }
 
    gf.P("return json.Marshal(raw)")
    gf.P("}")
    gf.P()
}
 
func (g *Generator) generateEmptyBehaviorFieldMarshal(gf *protogen.GeneratedFile, fieldInfo *EmptyBehaviorFieldInfo) {
    field := fieldInfo.Field
    jsonName := field.Desc.JSONName()
    goName := field.GoName
    behavior := fieldInfo.Behavior
 
    gf.P("// Handle empty_behavior for field: ", field.Desc.Name())
    gf.P("if x.", goName, " != nil && proto.Size(x.", goName, ") == 0 {")
 
    switch behavior {
    case http.EmptyBehavior_EMPTY_BEHAVIOR_NULL:
        gf.P("// EMPTY_BEHAVIOR_NULL: serialize empty message as null")
        gf.P(`raw["`, jsonName, `"] = []byte("null")`)
    case http.EmptyBehavior_EMPTY_BEHAVIOR_OMIT:
        gf.P("// EMPTY_BEHAVIOR_OMIT: remove field when message is empty")
        gf.P(`delete(raw, "`, jsonName, `")`)
    case http.EmptyBehavior_EMPTY_BEHAVIOR_PRESERVE:
        gf.P("// EMPTY_BEHAVIOR_PRESERVE: keep as {} (default protojson behavior)")
        gf.P("// No action needed - protojson already emits {}")
    default:
        // UNSPECIFIED treated as PRESERVE
        gf.P("// EMPTY_BEHAVIOR_UNSPECIFIED: use default (PRESERVE)")
    }
 
    gf.P("}")
    gf.P()
}
 
// generateEmptyBehaviorUnmarshalJSON generates UnmarshalJSON that handles empty_behavior.
// For NULL behavior, accept null as empty message. For OMIT, missing field means empty.
func (g *Generator) generateEmptyBehaviorUnmarshalJSON(gf *protogen.GeneratedFile, ctx *EmptyBehaviorContext) {
    msgName := ctx.Message.GoIdent.GoName
 
    // Check if any field has NULL behavior (needs special handling)
    hasNullBehavior := false
    for _, f := range ctx.EmptyBehaviorFields {
        if f.Behavior == http.EmptyBehavior_EMPTY_BEHAVIOR_NULL {
            hasNullBehavior = true
            break
        }
    }
 
    if !hasNullBehavior {
        // No special unmarshal needed for PRESERVE/OMIT
        return
    }
 
    var fieldNames []string
    for _, f := range ctx.EmptyBehaviorFields {
        fieldNames = append(fieldNames, string(f.Field.Desc.Name()))
    }
 
    gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".")
    gf.P("// This method handles empty_behavior fields: ", strings.Join(fieldNames, ", "))
    gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {")
    gf.P("// Parse to check for explicit null values on empty_behavior=NULL fields")
    gf.P("var raw map[string]json.RawMessage")
    gf.P("if err := json.Unmarshal(data, &raw); err != nil {")
    gf.P("return err")
    gf.P("}")
    gf.P()
 
    // For NULL fields, convert null to empty object for protojson
    for _, fieldInfo := range ctx.EmptyBehaviorFields {
        if fieldInfo.Behavior == http.EmptyBehavior_EMPTY_BEHAVIOR_NULL {
            field := fieldInfo.Field
            jsonName := field.Desc.JSONName()
 
            gf.P("// Handle empty_behavior=NULL: convert null to {} for protojson")
            gf.P(`if rawVal, ok := raw["`, jsonName, `"]; ok && string(rawVal) == "null" {`)
            gf.P(`raw["`, jsonName, `"] = []byte("{}")`)
            gf.P("}")
            gf.P()
        }
    }
 
    gf.P("// Re-marshal for protojson")
    gf.P("modified, err := json.Marshal(raw)")
    gf.P("if err != nil {")
    gf.P("return err")
    gf.P("}")
    gf.P()
    gf.P("return protojson.Unmarshal(modified, x)")
    gf.P("}")
    gf.P()
}

Update internal/httpgen/generator.go to call generateEmptyBehaviorEncodingFile in the Generate method.

Create internal/clientgen/empty_behavior.go with identical implementation (copy from httpgen, change package name).

Update internal/clientgen/generator.go to call generateEmptyBehaviorEncodingFile.

go build ./internal/httpgen/... ./internal/clientgen/...
go test -v ./internal/httpgen/... ./internal/clientgen/...
make lint-fix

All builds and tests pass, lint clean.

  • internal/httpgen/empty_behavior.go exists with generateEmptyBehaviorMarshalJSON
  • internal/clientgen/empty_behavior.go exists with identical implementation
  • Both generators call generateEmptyBehaviorEncodingFile in their Generate method
  • Validation errors returned for invalid empty_behavior annotations
  • Generated code handles NULL, OMIT, and PRESERVE behaviors correctly
  • proto.Size() used for empty message detection
Task 2: Implement empty_behavior in OpenAPI generator internal/openapiv3/types.go internal/openapiv3/generator.go Update internal/openapiv3/types.go to handle empty_behavior annotation:
  1. In convertField or convertScalarField for message fields, check for empty_behavior annotation:
// For message fields with empty_behavior=NULL, use oneOf with null
case protoreflect.MessageKind:
    behavior := annotations.GetEmptyBehavior(field)
 
    if behavior == http.EmptyBehavior_EMPTY_BEHAVIOR_NULL {
        // Use oneOf to allow either the message schema or null
        nullSchema := &base.Schema{Type: []string{"null"}}
        return base.CreateSchemaProxy(&base.Schema{
            OneOf: []*base.SchemaProxy{
                base.CreateSchemaProxyRef(fmt.Sprintf("#/components/schemas/%s", g.getSchemaName(field.Message))),
                base.CreateSchemaProxy(nullSchema),
            },
        })
    }
 
    // Default: reference to message schema
    return base.CreateSchemaProxyRef(fmt.Sprintf("#/components/schemas/%s", g.getSchemaName(field.Message)))
  1. For empty_behavior=OMIT fields, they should NOT be in the required array. This is already the default behavior since message fields are optional by default.

  2. Add validation for empty_behavior annotation in the generator:

// In the message processing loop, validate empty_behavior
for _, field := range message.Fields {
    if err := annotations.ValidateEmptyBehaviorAnnotation(field, message.GoIdent.GoName); err != nil {
        return err // or log and continue depending on error handling pattern
    }
}

Note: TypeScript types don't need changes for empty_behavior - message fields are already optional (fieldName?: MessageType). The empty_behavior only affects serialization, which TypeScript doesn't control.

go build ./internal/openapiv3/...
go test -v ./internal/openapiv3/...
make lint-fix
- OpenAPI generator produces oneOf schema for empty_behavior=NULL message fields - OpenAPI generator validates empty_behavior annotations - OMIT fields are not marked required (default behavior) - PRESERVE fields use standard message reference
Task 3: Add empty_behavior test proto and golden file tests internal/httpgen/testdata/empty_behavior.proto internal/tsclientgen/testdata/empty_behavior.proto internal/openapiv3/testdata/empty_behavior.proto Create test proto file for empty_behavior annotation testing.

Create internal/httpgen/testdata/empty_behavior.proto:

syntax = "proto3";
 
package test.empty_behavior;
 
import "sebuf/http/annotations.proto";
 
option go_package = "github.com/SebastienMelki/sebuf/test/empty_behavior;emptybehavior";
 
// Metadata is a simple message to test empty detection
message Metadata {
  string key = 1;
  string value = 2;
}
 
// Settings demonstrates various empty_behavior modes
message Settings {
  bool enabled = 1;
  int32 timeout = 2;
}
 
// Response demonstrates empty_behavior on message fields
message Response {
  string id = 1;
 
  // PRESERVE: empty message serializes as {}
  Metadata metadata_preserve = 2 [(sebuf.http.empty_behavior) = EMPTY_BEHAVIOR_PRESERVE];
 
  // NULL: empty message serializes as null
  Metadata metadata_null = 3 [(sebuf.http.empty_behavior) = EMPTY_BEHAVIOR_NULL];
 
  // OMIT: empty message field is omitted
  Metadata metadata_omit = 4 [(sebuf.http.empty_behavior) = EMPTY_BEHAVIOR_OMIT];
 
  // No annotation: follows default (PRESERVE)
  Metadata metadata_default = 5;
 
  // Another message type to test
  Settings settings = 6 [(sebuf.http.empty_behavior) = EMPTY_BEHAVIOR_NULL];
}
 
// EmptyBehaviorService tests empty behavior in responses
service EmptyBehaviorService {
  option (sebuf.http.service_config) = {
    base_path: "/api/v1"
  };
 
  rpc GetResponse(GetResponseRequest) returns (Response) {
    option (sebuf.http.config) = {
      path: "/responses/{id}"
      method: HTTP_METHOD_GET
    };
  }
}
 
message GetResponseRequest {
  string id = 1;
}

Create symlinks for ts-client and openapiv3 testdata directories:

cd internal/tsclientgen/testdata && ln -sf ../../httpgen/testdata/empty_behavior.proto empty_behavior.proto
cd internal/openapiv3/testdata && ln -sf ../../httpgen/testdata/empty_behavior.proto empty_behavior.proto

Run golden file test generation to create expected output files.

# Run tests to generate golden files
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/httpgen/...
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/tsclientgen/...
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/openapiv3/...
 
# Verify golden files exist and tests pass
go test -v ./internal/httpgen/... ./internal/tsclientgen/... ./internal/openapiv3/...
- empty_behavior.proto test file exists with all three behavior modes - Golden files generated for all 3 generators - Tests pass with expected empty_behavior output - Go: MarshalJSON handles NULL (null), OMIT (delete), PRESERVE ({}) - OpenAPI: oneOf schema for NULL fields
1. Go generators compile: `go build ./internal/httpgen/... ./internal/clientgen/...` 2. OpenAPI generator compiles: `go build ./internal/openapiv3/...` 3. All tests pass: `go test -v ./internal/...` 4. Lint clean: `make lint-fix` 5. Golden files show correct empty_behavior output: - Go: NULL -> `"field": null`, OMIT -> field deleted, PRESERVE -> `"field": {}` - OpenAPI: NULL -> oneOf with null type

<success_criteria>

  • empty_behavior=NULL serializes empty messages as null in Go generators
  • empty_behavior=OMIT omits empty message fields in Go generators
  • empty_behavior=PRESERVE serializes empty messages as {} (default behavior)
  • proto.Size() == 0 correctly detects empty messages
  • OpenAPI documents NULL fields with oneOf schema including null type
  • Invalid empty_behavior annotations (primitives, repeated, maps) cause generation errors
  • All existing tests continue to pass (zero regression) </success_criteria>
After completion, create `.planning/phases/05-json-nullable-empty/05-03-SUMMARY.md`