Purpose: Establish the annotation infrastructure that all 4 generators will use to control nullable primitive serialization and empty message handling. This is the foundation for the rest of Phase 5.
Output: Proto extensions for nullable (bool) and empty_behavior (enum) annotations. Shared Go functions IsNullableField, ValidateNullableAnnotation, GetEmptyBehavior, and ValidateEmptyBehaviorAnnotation 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
@internal/annotations/int64_encoding.go @internal/annotations/enum_encoding.go @internal/annotations/unwrap.go @proto/sebuf/http/annotations.proto
- EmptyBehavior enum (add before the FieldOptions extend block):
// EmptyBehavior controls how empty message fields serialize to JSON.
// "Empty" means all fields at proto default (proto.Size() == 0).
enum EmptyBehavior {
// Follow default behavior: serialize empty messages as {} (same as PRESERVE)
EMPTY_BEHAVIOR_UNSPECIFIED = 0;
// Serialize empty messages as {} (explicit same as default)
EMPTY_BEHAVIOR_PRESERVE = 1;
// Serialize empty messages as null
EMPTY_BEHAVIOR_NULL = 2;
// Omit the field entirely when message is empty
EMPTY_BEHAVIOR_OMIT = 3;
}- Add to existing FieldOptions extension block (inside the extend google.protobuf.FieldOptions block):
// Mark a primitive field as nullable (explicit null vs absent).
// Only valid on proto3 optional fields (HasOptionalKeyword=true).
// When true: unset field serializes as null, set field serializes normally.
// When false (default): unset field is omitted from JSON.
optional bool nullable = 50013;
// Controls how empty message fields serialize to JSON.
// Only valid on singular message fields (not repeated, not map).
// "Empty" = all fields at proto default (proto.Size() == 0).
optional EmptyBehavior empty_behavior = 50014;Use extension numbers 50013 and 50014 (continuing from existing 50012 for enum_value).
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 (
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
"github.com/SebastienMelki/sebuf/http"
)
// NullableValidationError represents an error in nullable annotation validation.
type NullableValidationError struct {
MessageName string
FieldName string
Reason string
}
func (e *NullableValidationError) Error() string {
return "invalid nullable annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
// IsNullableField returns true if the field has nullable=true annotation.
func IsNullableField(field *protogen.Field) bool {
options := field.Desc.Options()
if options == nil {
return false
}
fieldOptions, ok := options.(*descriptorpb.FieldOptions)
if !ok {
return false
}
ext := proto.GetExtension(fieldOptions, http.E_Nullable)
if ext == nil {
return false
}
nullable, ok := ext.(bool)
return ok && nullable
}
// ValidateNullableAnnotation checks if nullable annotation is valid for a field.
// Returns error if nullable=true on a non-optional field or on a message field.
func ValidateNullableAnnotation(field *protogen.Field, messageName string) error {
if !IsNullableField(field) {
return nil // No annotation, nothing to validate
}
// Nullable only valid on proto3 optional fields
if !field.Desc.HasOptionalKeyword() {
return &NullableValidationError{
MessageName: messageName,
FieldName: string(field.Desc.Name()),
Reason: "nullable annotation is only valid on proto3 optional fields",
}
}
// Nullable only valid on primitive types (not messages)
if field.Desc.Kind() == protoreflect.MessageKind {
return &NullableValidationError{
MessageName: messageName,
FieldName: string(field.Desc.Name()),
Reason: "nullable annotation is only valid on primitive fields, not message fields",
}
}
return nil
}Create empty_behavior.go:
package annotations
import (
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
"github.com/SebastienMelki/sebuf/http"
)
// EmptyBehaviorValidationError represents an error in empty_behavior annotation validation.
type EmptyBehaviorValidationError struct {
MessageName string
FieldName string
Reason string
}
func (e *EmptyBehaviorValidationError) Error() string {
return "invalid empty_behavior annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
// GetEmptyBehavior returns the empty behavior for a field.
// Returns EMPTY_BEHAVIOR_UNSPECIFIED if not set (callers should treat as PRESERVE).
func GetEmptyBehavior(field *protogen.Field) http.EmptyBehavior {
options := field.Desc.Options()
if options == nil {
return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
}
fieldOptions, ok := options.(*descriptorpb.FieldOptions)
if !ok {
return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
}
ext := proto.GetExtension(fieldOptions, http.E_EmptyBehavior)
if ext == nil {
return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
}
behavior, ok := ext.(http.EmptyBehavior)
if !ok {
return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
}
return behavior
}
// HasEmptyBehaviorAnnotation returns true if the field has any empty_behavior annotation set
// (including explicit PRESERVE, NULL, or OMIT - not just UNSPECIFIED).
func HasEmptyBehaviorAnnotation(field *protogen.Field) bool {
return GetEmptyBehavior(field) != http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
}
// ValidateEmptyBehaviorAnnotation checks if empty_behavior annotation is valid for a field.
// Returns error if used on primitive, repeated, or map fields.
func ValidateEmptyBehaviorAnnotation(field *protogen.Field, messageName string) error {
if !HasEmptyBehaviorAnnotation(field) {
return nil // No annotation, nothing to validate
}
// Empty behavior only valid on message fields
if field.Desc.Kind() != protoreflect.MessageKind {
return &EmptyBehaviorValidationError{
MessageName: messageName,
FieldName: string(field.Desc.Name()),
Reason: "empty_behavior annotation is only valid on message fields",
}
}
// Not valid on repeated fields
if field.Desc.IsList() {
return &EmptyBehaviorValidationError{
MessageName: messageName,
FieldName: string(field.Desc.Name()),
Reason: "empty_behavior annotation is not valid on repeated fields",
}
}
// Not valid on map fields
if field.Desc.IsMap() {
return &EmptyBehaviorValidationError{
MessageName: messageName,
FieldName: string(field.Desc.Name()),
Reason: "empty_behavior annotation is not valid on map fields",
}
}
return nil
}Add unit tests to annotations_test.go for:
- Extension descriptor availability (E_Nullable, E_EmptyBehavior)
- EmptyBehavior enum values are correctly defined
- NullableValidationError and EmptyBehaviorValidationError error message format
go build ./internal/annotations/...
go test -v ./internal/annotations/...
make lint-fixAll tests pass, lint clean.
- IsNullableField function exists and returns true only for nullable=true fields
- ValidateNullableAnnotation rejects non-optional fields and message fields
- GetEmptyBehavior function exists and returns correct EmptyBehavior enum
- HasEmptyBehaviorAnnotation helper exists
- ValidateEmptyBehaviorAnnotation rejects primitives, repeated, and map fields
- NullableValidationError and EmptyBehaviorValidationError types with Error() method
- All functions follow existing annotation package patterns
- Unit tests cover extension descriptors and error types
- Lint passes
<success_criteria>
- Proto files can import and use nullable and empty_behavior annotations
- IsNullableField(field) returns true only for fields annotated with nullable=true
- ValidateNullableAnnotation returns error for invalid usage (non-optional, message fields)
- GetEmptyBehavior(field) returns the configured EmptyBehavior enum value
- ValidateEmptyBehaviorAnnotation returns error for invalid usage (primitives, repeated, maps)
- All existing tests continue to pass (zero regression) </success_criteria>
