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>
Existing encoding patterns to follow
@internal/httpgen/encoding.go @internal/httpgen/nullable.go
Annotation functions from Plan 01#
@internal/annotations/empty_behavior.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-fixAll 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
- 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)))-
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.
-
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-fixCreate 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.protoRun 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/...<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>
