Purpose: Establish the annotation infrastructure that all 4 generators will use to control JSON encoding of google.protobuf.Timestamp fields and bytes fields. This is the foundation for the rest of Phase 6.
Output: Proto extensions for timestamp_format (50015) and bytes_encoding (50016) annotations. Shared Go functions for parsing, detection, and validation 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 exactly
@internal/annotations/int64_encoding.go @internal/annotations/nullable.go @internal/annotations/empty_behavior.go @proto/sebuf/http/annotations.proto
- TimestampFormat enum:
// TimestampFormat controls how google.protobuf.Timestamp fields serialize to JSON.
enum TimestampFormat {
// Follow protojson default (RFC 3339 string: "2024-01-15T09:30:00Z")
TIMESTAMP_FORMAT_UNSPECIFIED = 0;
// Explicit RFC 3339 (same as default but self-documenting)
TIMESTAMP_FORMAT_RFC3339 = 1;
// Unix seconds as integer: 1705312200 (nanos truncated)
TIMESTAMP_FORMAT_UNIX_SECONDS = 2;
// Unix milliseconds as integer: 1705312200000 (sub-millisecond nanos truncated)
TIMESTAMP_FORMAT_UNIX_MILLIS = 3;
// Date-only string: "2024-01-15" (time component dropped, lossy)
TIMESTAMP_FORMAT_DATE = 4;
}- BytesEncoding enum:
// BytesEncoding controls how bytes fields serialize to JSON.
enum BytesEncoding {
// Follow protojson default (standard base64 with padding)
BYTES_ENCODING_UNSPECIFIED = 0;
// Standard base64 with padding (RFC 4648): "SGVsbG8="
BYTES_ENCODING_BASE64 = 1;
// Base64 without padding: "SGVsbG8"
BYTES_ENCODING_BASE64_RAW = 2;
// URL-safe base64 with padding: "SGVsbG8="
BYTES_ENCODING_BASE64URL = 3;
// URL-safe base64 without padding: "SGVsbG8"
BYTES_ENCODING_BASE64URL_RAW = 4;
// Hexadecimal encoding (lowercase): "48656c6c6f"
BYTES_ENCODING_HEX = 5;
}- Add to existing FieldOptions extension block (inside the
extend google.protobuf.FieldOptionsblock):
// Controls timestamp JSON encoding for this field.
// Valid on: google.protobuf.Timestamp fields only.
// Default: RFC3339 (protojson default).
optional TimestampFormat timestamp_format = 50015;
// Controls bytes JSON encoding for this field.
// Valid on: bytes fields only.
// Default: BASE64 (protojson default).
optional BytesEncoding bytes_encoding = 50016;Use extension numbers 50015 and 50016 (continuing from existing 50014 for empty_behavior).
After editing, regenerate Go code: cd proto && buf generate
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"
)
// TimestampFormatValidationError represents an error in timestamp_format annotation validation.
type TimestampFormatValidationError struct {
MessageName string
FieldName string
Reason string
}
func (e *TimestampFormatValidationError) Error() string {
return "invalid timestamp_format annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
// GetTimestampFormat returns the timestamp format for a field.
// Returns TIMESTAMP_FORMAT_UNSPECIFIED if not set (callers should use protojson default: RFC3339).
func GetTimestampFormat(field *protogen.Field) http.TimestampFormat {
// Standard annotation extraction pattern: check options, get extension, type assert
// Return UNSPECIFIED for nil/missing/wrong-type at each step
}
// HasTimestampFormatAnnotation returns true if the field has any non-default timestamp_format.
// Returns false for UNSPECIFIED and RFC3339 (both use protojson default behavior).
func HasTimestampFormatAnnotation(field *protogen.Field) bool {
format := GetTimestampFormat(field)
return format != http.TimestampFormat_TIMESTAMP_FORMAT_UNSPECIFIED &&
format != http.TimestampFormat_TIMESTAMP_FORMAT_RFC3339
}
// IsTimestampField returns true if the field is a google.protobuf.Timestamp message type.
// Uses field.Desc.Kind() == MessageKind && field.Message.Desc.FullName() == "google.protobuf.Timestamp"
func IsTimestampField(field *protogen.Field) bool {
return field.Desc.Kind() == protoreflect.MessageKind &&
field.Message != nil &&
field.Message.Desc.FullName() == "google.protobuf.Timestamp"
}
// ValidateTimestampFormatAnnotation checks if timestamp_format is valid for a field.
// Returns error if used on non-Timestamp fields.
func ValidateTimestampFormatAnnotation(field *protogen.Field, messageName string) error {
format := GetTimestampFormat(field)
if format == http.TimestampFormat_TIMESTAMP_FORMAT_UNSPECIFIED {
return nil // No annotation, nothing to validate
}
if !IsTimestampField(field) {
return &TimestampFormatValidationError{...}
}
return nil
}Create bytes_encoding.go following the same pattern:
package annotations
// BytesEncodingValidationError represents an error in bytes_encoding annotation validation.
type BytesEncodingValidationError struct {
MessageName string
FieldName string
Reason string
}
func (e *BytesEncodingValidationError) Error() string {
return "invalid bytes_encoding annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
// GetBytesEncoding returns the bytes encoding for a field.
// Returns BYTES_ENCODING_UNSPECIFIED if not set (callers should use protojson default: BASE64).
func GetBytesEncoding(field *protogen.Field) http.BytesEncoding {
// Same extraction pattern as GetTimestampFormat
}
// HasBytesEncodingAnnotation returns true if the field has any non-default bytes_encoding.
// Returns false for UNSPECIFIED and BASE64 (both use protojson default behavior).
func HasBytesEncodingAnnotation(field *protogen.Field) bool {
encoding := GetBytesEncoding(field)
return encoding != http.BytesEncoding_BYTES_ENCODING_UNSPECIFIED &&
encoding != http.BytesEncoding_BYTES_ENCODING_BASE64
}
// ValidateBytesEncodingAnnotation checks if bytes_encoding is valid for a field.
// Returns error if used on non-bytes fields.
func ValidateBytesEncodingAnnotation(field *protogen.Field, messageName string) error {
encoding := GetBytesEncoding(field)
if encoding == http.BytesEncoding_BYTES_ENCODING_UNSPECIFIED {
return nil
}
if field.Desc.Kind() != protoreflect.BytesKind {
return &BytesEncodingValidationError{...}
}
return nil
}Add unit tests to annotations_test.go for:
- Extension descriptor availability (E_TimestampFormat, E_BytesEncoding)
- TimestampFormat and BytesEncoding enum values are correctly defined
- TimestampFormatValidationError and BytesEncodingValidationError error message format
- IsTimestampField helper returns expected results
go build ./internal/annotations/...
go test -v ./internal/annotations/...
make lint-fixAll tests pass, lint clean.
- GetTimestampFormat function exists and returns correct TimestampFormat enum
- HasTimestampFormatAnnotation returns true for non-default, non-RFC3339 formats
- IsTimestampField correctly detects google.protobuf.Timestamp fields
- ValidateTimestampFormatAnnotation rejects non-Timestamp fields
- GetBytesEncoding function exists and returns correct BytesEncoding enum
- HasBytesEncodingAnnotation returns true for non-default, non-BASE64 encodings
- ValidateBytesEncodingAnnotation rejects non-bytes fields
- All error types have Error() methods
- All functions follow existing annotation package patterns
- Unit tests cover extension descriptors, enum values, and error types
- Lint passes
<success_criteria>
- Proto files can import and use timestamp_format and bytes_encoding annotations
- GetTimestampFormat(field) returns the configured TimestampFormat enum value
- GetBytesEncoding(field) returns the configured BytesEncoding enum value
- IsTimestampField(field) detects google.protobuf.Timestamp by FullName
- Validation functions reject invalid annotation usage (wrong field types)
- HasTimestampFormatAnnotation and HasBytesEncodingAnnotation correctly exclude default values
- All existing tests continue to pass (zero regression) </success_criteria>
