Implement timestamp format options across all 4 generators (go-http, go-client, ts-client, openapiv3).

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>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/06-json-data-encoding/06-RESEARCH.md @.planning/phases/06-json-data-encoding/06-01-SUMMARY.md

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

Task 1: Implement timestamp format in Go generators (go-http and go-client) internal/httpgen/timestamp_format.go internal/httpgen/generator.go internal/clientgen/timestamp_format.go internal/clientgen/generator.go Create internal/httpgen/timestamp_format.go following the exact pattern from encoding.go (int64) and nullable.go:
  1. Define TimestampFormatContext struct:
type TimestampFormatContext struct {
    Message         *protogen.Message
    TimestampFields []*TimestampFormatFieldInfo
}
 
type TimestampFormatFieldInfo struct {
    Field  *protogen.Field
    Format http.TimestampFormat
}
  1. Implement detection functions:
  • hasTimestampFormatFields(message) bool -- checks if any Timestamp field has non-default format
  • getTimestampFormatFields(message) []*TimestampFormatFieldInfo -- returns annotated fields with their formats
  • collectTimestampFormatContext(file) []*TimestampFormatContext -- recursively collects from all messages
  • collectTimestampFormatMessages(messages, *contexts) -- recursive helper
  • validateTimestampFormatAnnotations(file) error -- validates all fields in file
  1. Implement generateTimestampFormatEncodingFile(file) error method 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)
  1. 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)
}
  1. 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)
}
  1. Required imports for generated code: encoding/json, time, google.golang.org/protobuf/encoding/protojson Note: timestamppb is NOT needed in generated code (we use x.FieldName.AsTime() which returns time.Time)

  2. In generator.go, add call to g.generateTimestampFormatEncodingFile(file) after the existing encoding file calls (after generateEmptyBehaviorEncodingFile).

  3. 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).

  4. 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-fix
- internal/httpgen/timestamp_format.go exists with TimestampFormatContext and generation logic - internal/clientgen/timestamp_format.go is identical (except package name) - generator.go in both packages calls generateTimestampFormatEncodingFile - Validation rejects timestamp_format on non-Timestamp fields - Generated MarshalJSON converts UNIX_SECONDS to integer, UNIX_MILLIS to integer, DATE to date string - Generated UnmarshalJSON converts back to RFC 3339 before protojson.Unmarshal - All existing golden tests still pass (backward compatible)
Task 2: Implement timestamp format in ts-client and openapiv3 generators with golden tests internal/tsclientgen/types.go internal/openapiv3/types.go internal/httpgen/testdata/proto/timestamp_format.proto internal/clientgen/testdata/proto/timestamp_format.proto internal/tsclientgen/testdata/proto/timestamp_format.proto internal/openapiv3/testdata/proto/timestamp_format.proto **TypeScript client (internal/tsclientgen/types.go):**

In 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 messages

Also 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/...
```bash go build ./internal/tsclientgen/... ./internal/openapiv3/... go test -v ./internal/httpgen/... ./internal/clientgen/... ./internal/tsclientgen/... ./internal/openapiv3/... make lint-fix ``` All golden tests pass without UPDATE_GOLDEN flag. Lint clean. - ts-client returns "number" for UNIX_SECONDS and UNIX_MILLIS Timestamp fields - ts-client returns "string" for RFC3339 and DATE Timestamp fields - Unannotated Timestamp fields handled correctly (string in TS, date-time in OpenAPI) - OpenAPI produces type:integer format:unix-timestamp for UNIX_SECONDS - OpenAPI produces type:integer format:unix-timestamp-ms for UNIX_MILLIS - OpenAPI produces type:string format:date for DATE - OpenAPI produces type:string format:date-time for RFC3339/default - Golden test protos created (symlinked where appropriate) - All golden tests pass - All existing tests pass (backward compatible)
1. Build passes: `go build ./...` 2. All tests pass: `go test ./...` 3. Lint clean: `make lint-fix` 4. Golden files updated and committed for all 4 generators 5. Go server and client produce identical MarshalJSON for timestamp format fields 6. TypeScript types match Go serialization (number for unix, string for RFC3339/date) 7. OpenAPI schemas match Go serialization (integer for unix, string for RFC3339/date) 8. Backward compatible: protos without annotations produce identical output to before

<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>
After completion, create `.planning/phases/06-json-data-encoding/06-02-SUMMARY.md`