Purpose: Enable developers to choose how google.protobuf.Timestamp fields serialize to JSON. RFC3339 (default), UNIX_SECONDS, UNIX_MILLIS, and DATE formats are supported. Go server and client must produce identical JSON. TypeScript types must match (number for unix, string for RFC3339/date). OpenAPI must document the actual wire format.
Output: Go generators produce custom MarshalJSON/UnmarshalJSON for non-default timestamp formats using the protojson-base-then-modify-map pattern. TypeScript type mapping and OpenAPI schema generation account for timestamp format annotations.
<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 exactly (established in Phase 4/5)
@internal/httpgen/encoding.go @internal/httpgen/nullable.go @internal/httpgen/empty_behavior.go @internal/clientgen/encoding.go @internal/tsclientgen/types.go @internal/openapiv3/types.go
Annotation functions from Plan 01#
@internal/annotations/timestamp_format.go
Generator entry points to modify#
@internal/httpgen/generator.go @internal/clientgen/generator.go
- Define TimestampFormatContext struct:
type TimestampFormatContext struct {
Message *protogen.Message
TimestampFields []*TimestampFormatFieldInfo
}
type TimestampFormatFieldInfo struct {
Field *protogen.Field
Format http.TimestampFormat
}- Implement detection functions:
hasTimestampFormatFields(message) bool-- checks if any Timestamp field has non-default formatgetTimestampFormatFields(message) []*TimestampFormatFieldInfo-- returns annotated fields with their formatscollectTimestampFormatContext(file) []*TimestampFormatContext-- recursively collects from all messagescollectTimestampFormatMessages(messages, *contexts)-- recursive helpervalidateTimestampFormatAnnotations(file) error-- validates all fields in file
- Implement
generateTimestampFormatEncodingFile(file) errormethod on Generator:
- File naming:
file.GeneratedFilenamePrefix + "_timestamp_format.pb.go" - Only generate if there are messages with timestamp format annotations
- Use protojson for base serialization, then modify the map (same pattern as encoding.go for int64)
- Generated MarshalJSON pattern:
func (x *MessageName) MarshalJSON() ([]byte, error) {
if x == nil {
return []byte("null"), nil
}
data, err := protojson.Marshal(x)
if err != nil {
return nil, err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
// For UNIX_SECONDS:
if x.FieldName != nil {
t := x.FieldName.AsTime()
raw["fieldJsonName"], _ = json.Marshal(t.Unix())
}
// For UNIX_MILLIS:
if x.FieldName != nil {
t := x.FieldName.AsTime()
raw["fieldJsonName"], _ = json.Marshal(t.UnixMilli())
}
// For DATE:
if x.FieldName != nil {
t := x.FieldName.AsTime()
raw["fieldJsonName"], _ = json.Marshal(t.Format("2006-01-02"))
}
return json.Marshal(raw)
}- Generated UnmarshalJSON pattern:
func (x *MessageName) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// For UNIX_SECONDS: parse number, convert to RFC 3339, replace in map
if v, ok := raw["fieldJsonName"]; ok {
var n int64
if err := json.Unmarshal(v, &n); err == nil {
t := time.Unix(n, 0)
raw["fieldJsonName"], _ = json.Marshal(t.Format(time.RFC3339Nano))
}
}
// For UNIX_MILLIS: similar with time.UnixMilli(n)
// For DATE: parse string, add midnight UTC, format as RFC 3339
data, err := json.Marshal(raw)
if err != nil {
return err
}
return protojson.Unmarshal(data, x)
}-
Required imports for generated code:
encoding/json,time,google.golang.org/protobuf/encoding/protojsonNote:timestamppbis NOT needed in generated code (we use x.FieldName.AsTime() which returns time.Time) -
In generator.go, add call to
g.generateTimestampFormatEncodingFile(file)after the existing encoding file calls (after generateEmptyBehaviorEncodingFile). -
Copy internal/httpgen/timestamp_format.go to internal/clientgen/timestamp_format.go (change package name only). This follows D-04-02-03 and D-05-02-01 (identical .go files in httpgen and clientgen).
-
In clientgen/generator.go, add the same call.
CRITICAL NOTES from RESEARCH:
- Timestamp is a MESSAGE type (protoreflect.MessageKind), not a scalar. Detection uses field.Message.Desc.FullName() == "google.protobuf.Timestamp"
- protojson emits Timestamp as RFC 3339 string in JSON, NOT as {seconds, nanos}. The raw map will contain
"fieldName":"\"2024-01-15T09:30:00Z\""(a JSON string), and we replace it. - DATE format is intentionally lossy (drops time component)
- UNIX_SECONDS truncates nanos
- Only generate MarshalJSON/UnmarshalJSON if message has at least one non-default timestamp format field
go build ./internal/httpgen/... ./internal/clientgen/...
go test -v ./internal/httpgen/... ./internal/clientgen/...
make lint-fixIn the tsFieldType function, BEFORE the generic MessageKind handling (if field.Desc.Kind() == protoreflect.MessageKind && field.Message != nil), add Timestamp detection:
// Check for google.protobuf.Timestamp with format annotation
if annotations.IsTimestampField(field) {
format := annotations.GetTimestampFormat(field)
switch format {
case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_SECONDS,
http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_MILLIS:
return "number"
default:
// RFC3339, DATE, UNSPECIFIED -> string
return "string"
}
}Also update tsElementType for repeated Timestamp fields with the same logic.
Also ensure Timestamp messages are NOT collected in the messageSet (since they're now inlined as string/number). Add an IsTimestampField check in the messageSet.addMessage method to skip Timestamp messages when the field has a format annotation. Or simpler: check in tsFieldType first -- if it's a Timestamp field, return the type directly without falling through to the message name.
Make sure default (unannotated) Timestamp fields still work as before. Currently they fall through to string(field.Message.Desc.Name()) which returns "Timestamp". If there's an existing Timestamp interface being generated, that behavior should be preserved for unannotated fields. Check how protojson serializes Timestamp (RFC 3339 string) -- the TS type should be string for unannotated Timestamp fields too, since protojson produces a string, not {seconds, nanos}. This may already be handled or may need a fix.
OpenAPI (internal/openapiv3/types.go):
In the convertScalarField function (or wherever MessageKind is handled), BEFORE the generic $ref: '#/components/schemas/...' handling, add Timestamp detection:
case protoreflect.MessageKind:
// Check for google.protobuf.Timestamp with format annotation
if annotations.IsTimestampField(field) {
format := annotations.GetTimestampFormat(field)
switch format {
case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_SECONDS:
schema.Type = []string{"integer"}
schema.Format = "unix-timestamp"
schema.Description = "Unix timestamp in seconds"
return schema (wrapped in proxy)
case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_MILLIS:
schema.Type = []string{"integer"}
schema.Format = "unix-timestamp-ms"
schema.Description = "Unix timestamp in milliseconds"
return schema
case http.TimestampFormat_TIMESTAMP_FORMAT_DATE:
schema.Type = []string{"string"}
schema.Format = "date"
return schema
default:
// RFC3339 / UNSPECIFIED
schema.Type = []string{"string"}
schema.Format = "date-time"
return schema
}
}
// existing generic $ref handling for other messagesAlso handle unannotated Timestamp fields: they should produce type: string, format: date-time instead of a $ref to a Timestamp schema (since protojson serializes as RFC 3339 string, not {seconds, nanos}).
Golden test protos:
Create timestamp_format.proto in all 4 generator testdata directories. Use symlinks from clientgen/tsclientgen/openapiv3 to httpgen (following the established pattern from Phase 4/5):
syntax = "proto3";
package timestamp_format.test;
import "google/protobuf/timestamp.proto";
import "sebuf/http/annotations.proto";
option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/timestamp_format";
message TimestampFormatTest {
// Default (RFC3339)
google.protobuf.Timestamp default_ts = 1;
// Explicit RFC3339
google.protobuf.Timestamp rfc3339_ts = 2 [(sebuf.http.timestamp_format) = TIMESTAMP_FORMAT_RFC3339];
// Unix seconds
google.protobuf.Timestamp unix_seconds_ts = 3 [(sebuf.http.timestamp_format) = TIMESTAMP_FORMAT_UNIX_SECONDS];
// Unix milliseconds
google.protobuf.Timestamp unix_millis_ts = 4 [(sebuf.http.timestamp_format) = TIMESTAMP_FORMAT_UNIX_MILLIS];
// Date only
google.protobuf.Timestamp date_ts = 5 [(sebuf.http.timestamp_format) = TIMESTAMP_FORMAT_DATE];
}
service TimestampFormatService {
option (sebuf.http.service_config) = {
base_path: "/api/v1"
};
rpc TestTimestampFormat(TimestampFormatTest) returns (TimestampFormatTest) {
option (sebuf.http.config) = {
path: "/timestamp-format"
method: HTTP_METHOD_POST
};
}
}Run UPDATE_GOLDEN=1 tests to generate golden files, then verify they pass without the flag:
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/httpgen/...
UPDATE_GOLDEN=1 go test -run TestGoldenFiles ./internal/clientgen/...
UPDATE_GOLDEN=1 go test -run TestGoldenFiles ./internal/tsclientgen/...
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/openapiv3/...<success_criteria>
- UNIX_SECONDS Timestamp produces JSON integer (not RFC 3339 string) in Go generators
- UNIX_MILLIS Timestamp produces JSON integer (not RFC 3339 string) in Go generators
- DATE Timestamp produces JSON date string "2024-01-15" (not RFC 3339) in Go generators
- UnmarshalJSON correctly converts unix/date back to RFC 3339 before protojson
- go-http and go-client produce identical code (byte-level after normalization)
- TypeScript types are number for unix timestamps, string for RFC3339/date
- OpenAPI schemas accurately reflect the wire format
- All golden tests pass, all existing tests pass </success_criteria>
