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

Purpose: Enable developers to choose how bytes fields serialize to JSON. BASE64 (default), BASE64_RAW, BASE64URL, BASE64URL_RAW, and HEX are supported. Go server and client must produce identical JSON. OpenAPI must document the actual encoding format.

Output: Go generators produce custom MarshalJSON/UnmarshalJSON for non-default bytes encodings using the protojson-base-then-modify-map pattern. OpenAPI schema generation accounts for bytes encoding annotations with appropriate format and pattern fields.

<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

@internal/httpgen/encoding.go @internal/httpgen/nullable.go @internal/clientgen/encoding.go

Annotation functions from Plan 01#

@internal/annotations/bytes_encoding.go

Generator entry points to modify#

@internal/httpgen/generator.go @internal/clientgen/generator.go

Type mapping files#

@internal/tsclientgen/types.go @internal/openapiv3/types.go

Task 1: Implement bytes encoding in Go generators (go-http and go-client) internal/httpgen/bytes_encoding.go internal/httpgen/generator.go internal/clientgen/bytes_encoding.go internal/clientgen/generator.go Create internal/httpgen/bytes_encoding.go following the exact pattern from encoding.go and nullable.go:
  1. Define BytesEncodingContext struct:
type BytesEncodingContext struct {
    Message       *protogen.Message
    BytesFields   []*BytesEncodingFieldInfo
}
 
type BytesEncodingFieldInfo struct {
    Field    *protogen.Field
    Encoding http.BytesEncoding
}
  1. Implement detection functions:
  • hasBytesEncodingFields(message) bool -- checks if any bytes field has non-default encoding
  • getBytesEncodingFields(message) []*BytesEncodingFieldInfo -- returns annotated fields with their encodings
  • collectBytesEncodingContext(file) []*BytesEncodingContext -- recursively collects from all messages
  • collectBytesEncodingMessages(messages, *contexts) -- recursive helper
  • validateBytesEncodingAnnotations(file) error -- validates all fields in file
  1. Implement generateBytesEncodingFile(file) error method on Generator:
  • File naming: file.GeneratedFilenamePrefix + "_bytes_encoding.pb.go"
  • Only generate if there are messages with non-default bytes encoding fields
  • Use protojson for base serialization, then modify the map
  1. Generated MarshalJSON pattern -- uses Go standard library encoding packages:
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 HEX:
    if len(x.FieldName) > 0 {
        raw["fieldJsonName"], _ = json.Marshal(hex.EncodeToString(x.FieldName))
    }
    // For BASE64_RAW:
    if len(x.FieldName) > 0 {
        raw["fieldJsonName"], _ = json.Marshal(base64.RawStdEncoding.EncodeToString(x.FieldName))
    }
    // For BASE64URL:
    if len(x.FieldName) > 0 {
        raw["fieldJsonName"], _ = json.Marshal(base64.URLEncoding.EncodeToString(x.FieldName))
    }
    // For BASE64URL_RAW:
    if len(x.FieldName) > 0 {
        raw["fieldJsonName"], _ = json.Marshal(base64.RawURLEncoding.EncodeToString(x.FieldName))
    }
    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 HEX: decode hex, re-encode as standard base64, replace in map
    if v, ok := raw["fieldJsonName"]; ok {
        var s string
        if err := json.Unmarshal(v, &s); err == nil {
            decoded, err := hex.DecodeString(s)
            if err == nil {
                raw["fieldJsonName"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded))
            }
        }
    }
    // For BASE64_RAW: decode with RawStdEncoding, re-encode with StdEncoding
    // For BASE64URL: decode with URLEncoding, re-encode with StdEncoding
    // For BASE64URL_RAW: decode with RawURLEncoding, re-encode with StdEncoding
    data, err := json.Marshal(raw)
    if err != nil {
        return err
    }
    return protojson.Unmarshal(data, x)
}
  1. Key mapping of encoding to Go standard library:
  • BASE64 -> base64.StdEncoding (default, no custom code needed)
  • BASE64_RAW -> base64.RawStdEncoding
  • BASE64URL -> base64.URLEncoding
  • BASE64URL_RAW -> base64.RawURLEncoding
  • HEX -> hex.EncodeToString / hex.DecodeString
  1. Required imports for generated code: encoding/json, encoding/base64, encoding/hex, google.golang.org/protobuf/encoding/protojson

  2. In generator.go, add call to g.generateBytesEncodingFile(file) after the timestamp format call.

  3. Add bytes_encoding validation in generateFile, before the encoding file generation (similar to validateEnumAnnotationsInFile).

  4. Copy internal/httpgen/bytes_encoding.go to internal/clientgen/bytes_encoding.go (change package name only, following D-04-02-03).

  5. In clientgen/generator.go, add the same calls.

CRITICAL NOTE from RESEARCH:

  • Only replace field in the map if the Go field is non-empty (len(x.Field) > 0). Empty bytes fields are omitted by protojson, so no action needed.
go build ./internal/httpgen/... ./internal/clientgen/...
go test -v ./internal/httpgen/... ./internal/clientgen/...
make lint-fix
- internal/httpgen/bytes_encoding.go exists with BytesEncodingContext and generation logic - internal/clientgen/bytes_encoding.go is identical (except package name) - generator.go in both packages calls generateBytesEncodingFile - Validation rejects bytes_encoding on non-bytes fields - Generated MarshalJSON uses correct encoding per variant - Generated UnmarshalJSON converts back to standard base64 before protojson.Unmarshal - All existing golden tests still pass (backward compatible)
Task 2: Implement bytes encoding in ts-client and openapiv3 with golden tests internal/tsclientgen/types.go internal/openapiv3/types.go internal/httpgen/testdata/proto/bytes_encoding.proto internal/clientgen/testdata/proto/bytes_encoding.proto internal/tsclientgen/testdata/proto/bytes_encoding.proto internal/openapiv3/testdata/proto/bytes_encoding.proto **TypeScript client (internal/tsclientgen/types.go):**

Bytes fields with any encoding variant always serialize to a string in JSON, so the TypeScript type is always string regardless of encoding. The current case protoreflect.BytesKind: return tsString already handles this correctly. No change needed for TypeScript type mapping.

However, add a comment near the BytesKind case documenting that all bytes_encoding variants produce strings (for future maintainability).

OpenAPI (internal/openapiv3/types.go):

In the BytesKind handling, BEFORE or REPLACING the existing generic format: byte handling, add bytes_encoding detection:

case protoreflect.BytesKind:
    schema.Type = []string{"string"}
    encoding := annotations.GetBytesEncoding(field)
    switch encoding {
    case http.BytesEncoding_BYTES_ENCODING_HEX:
        schema.Format = "hex"
        schema.Pattern = "^[0-9a-fA-F]*$"
    case http.BytesEncoding_BYTES_ENCODING_BASE64_RAW:
        schema.Format = "byte"
        schema.Description = "Base64 encoded without padding"
    case http.BytesEncoding_BYTES_ENCODING_BASE64URL:
        schema.Format = "base64url"
    case http.BytesEncoding_BYTES_ENCODING_BASE64URL_RAW:
        schema.Format = "base64url"
        schema.Description = "URL-safe base64 encoded without padding"
    default:
        // UNSPECIFIED, BASE64 -> standard base64 with padding
        schema.Format = "byte"
    }

Golden test protos:

Create bytes_encoding.proto in all 4 generator testdata directories. Use symlinks from clientgen/tsclientgen/openapiv3 to httpgen (following established pattern):

syntax = "proto3";
package bytes_encoding.test;
 
import "sebuf/http/annotations.proto";
 
option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/bytes_encoding";
 
message BytesEncodingTest {
  // Default (BASE64)
  bytes default_data = 1;
 
  // Explicit BASE64
  bytes base64_data = 2 [(sebuf.http.bytes_encoding) = BYTES_ENCODING_BASE64];
 
  // BASE64_RAW (no padding)
  bytes base64_raw_data = 3 [(sebuf.http.bytes_encoding) = BYTES_ENCODING_BASE64_RAW];
 
  // BASE64URL
  bytes base64url_data = 4 [(sebuf.http.bytes_encoding) = BYTES_ENCODING_BASE64URL];
 
  // BASE64URL_RAW
  bytes base64url_raw_data = 5 [(sebuf.http.bytes_encoding) = BYTES_ENCODING_BASE64URL_RAW];
 
  // HEX
  bytes hex_data = 6 [(sebuf.http.bytes_encoding) = BYTES_ENCODING_HEX];
}
 
service BytesEncodingService {
  option (sebuf.http.service_config) = {
    base_path: "/api/v1"
  };
 
  rpc TestBytesEncoding(BytesEncodingTest) returns (BytesEncodingTest) {
    option (sebuf.http.config) = {
      path: "/bytes-encoding"
      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. - TypeScript bytes type remains "string" for all encoding variants (no change needed, verified) - OpenAPI produces format:hex with pattern for HEX encoding - OpenAPI produces format:byte for BASE64 (default) and BASE64_RAW (with description) - OpenAPI produces format:base64url for BASE64URL and BASE64URL_RAW (with description) - 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 bytes encoding fields 6. TypeScript type is always "string" for bytes fields (regardless of encoding) 7. OpenAPI schemas use correct format and pattern per encoding variant 8. Backward compatible: protos without annotations produce identical output to before

<success_criteria>

  • HEX bytes encoding produces lowercase hex string in Go JSON output
  • BASE64_RAW removes padding from base64 output
  • BASE64URL uses URL-safe characters
  • BASE64URL_RAW combines URL-safe and no-padding
  • UnmarshalJSON correctly converts all variants back to standard base64 before protojson
  • go-http and go-client produce identical code (byte-level after normalization)
  • OpenAPI schemas accurately document the encoding format
  • All golden tests pass, all existing tests pass </success_criteria>
After completion, create `.planning/phases/06-json-data-encoding/06-03-SUMMARY.md`