diff --git a/docs/architecture.md b/docs/architecture.md index 90a0c66f..f43346b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ func main() { | Plugin | Primary Function | Output | Dependencies | |--------|-----------------|---------|--------------| | `protoc-gen-go-http` | Generate HTTP handlers, routing & validation | `*_http*.pb.go` | `protoc-gen-go`, sebuf annotations | -| `protoc-gen-go-client` | Generate type-safe Go HTTP clients | `*_client.pb.go` | `protoc-gen-go`, sebuf annotations | +| `protoc-gen-go-client` | Generate type-safe Go HTTP clients | `*_client.pb.go` | `protoc-gen-go`, sebuf annotations; uses `go-http`-owned JSON mapping methods when generated alongside server code | | `protoc-gen-ts-client` | Generate type-safe TypeScript HTTP clients | `*_client.ts` | sebuf annotations | | `protoc-gen-ts-server` | Generate framework-agnostic TypeScript HTTP servers | `*_server.ts` | sebuf annotations | | `protoc-gen-openapiv3` | Generate OpenAPI specifications | `*.yaml`, `*.json` | None (standalone) | diff --git a/docs/client-generation.md b/docs/client-generation.md index 1c34c56c..3c5f2783 100644 --- a/docs/client-generation.md +++ b/docs/client-generation.md @@ -181,7 +181,15 @@ const ( client := api.NewUserServiceClient("http://localhost:8080") ``` -The client automatically handles special JSON serialization, including messages with `unwrap` annotations for map values. See [JSON/Protobuf Compatibility](./json-protobuf-compatibility.md) for details. +For JSON requests and responses, the client automatically uses custom sebuf JSON marshalers when they are present on your protobuf types. See [JSON/Protobuf Compatibility](./json-protobuf-compatibility.md) for details. + +> **JSON-mapping annotations require `go-http` generation.** +> Package-level marshalers for annotations such as `unwrap`, `int64_encoding`, +> `enum_encoding`, `nullable`, `empty_behavior`, `timestamp_format`, +> `bytes_encoding`, `oneof_config`, and `flatten` are generated by +> `protoc-gen-go-http`. If you generate only `go-client`, those annotations are +> not applied to JSON client requests or responses. Generate `go-http` alongside +> `go-client` in the same Go package when client JSON must honor them. ### Binary Protobuf diff --git a/docs/json-protobuf-compatibility.md b/docs/json-protobuf-compatibility.md index 6923815b..67fa22d8 100644 --- a/docs/json-protobuf-compatibility.md +++ b/docs/json-protobuf-compatibility.md @@ -116,7 +116,7 @@ With `unwrap`, the JSON output matches the desired format: When you use the `unwrap` annotation: 1. **HTTP Generation**: sebuf generates custom `MarshalJSON()` and `UnmarshalJSON()` methods for messages containing maps with unwrapped values -2. **Client Generation**: The generated client automatically uses the custom marshalers +2. **Client Generation**: The generated client automatically uses those custom marshalers when they are present. Generate `protoc-gen-go-http` alongside `protoc-gen-go-client` when Go clients need sebuf JSON-mapping behavior. 3. **OpenAPI Generation**: The OpenAPI schema shows the unwrapped structure (array values, not wrapper objects) ### Complete Example diff --git a/examples/enum-params/buf.lock b/examples/enum-params/buf.lock index 3579961c..f514b392 100644 --- a/examples/enum-params/buf.lock +++ b/examples/enum-params/buf.lock @@ -1,9 +1,6 @@ # Generated by buf. DO NOT EDIT. version: v2 deps: - - name: buf.build/bufbuild/protovalidate - commit: 50325440f8f24053b047484a6bf60b76 - digest: b5:74cb6f5c0853c3c10aafc701614194bbd63326bdb8ef4068214454b8894b03ba4113e04b3a33a8321cdf05336e37db4dc14a5e2495db8462566914f36086ba31 - name: buf.build/sebmelki/sebuf commit: b5f679ca6c5f4f148c3414adfea268d3 digest: b5:007be9b0418e0284f34578307ba516b56259a29932bfd3b662515d3107d3886e2d47592edc580e4a520155c3ebed7b55375b90ac0fa327ccb603f17bf84d7eb1 diff --git a/examples/enum-params/go.mod b/examples/enum-params/go.mod index a1a3f42f..6b1d09ab 100644 --- a/examples/enum-params/go.mod +++ b/examples/enum-params/go.mod @@ -9,7 +9,7 @@ require ( ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/google/cel-go v0.28.0 // indirect diff --git a/examples/enum-params/go.sum b/examples/enum-params/go.sum index a8cc191f..3f33d766 100644 --- a/examples/enum-params/go.sum +++ b/examples/enum-params/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= diff --git a/examples/error-handler/buf.lock b/examples/error-handler/buf.lock index 9a33a286..464226d9 100644 --- a/examples/error-handler/buf.lock +++ b/examples/error-handler/buf.lock @@ -2,8 +2,8 @@ version: v2 deps: - name: buf.build/bufbuild/protovalidate - commit: 2a1774d888024a9b93ce7eb4b59f6a83 - digest: b5:6b7f9bc919b65e5b79d7b726ffc03d6f815a412d6b792970fa6f065cae162107bd0a9d47272c8ab1a2c9514e87b13d3fbf71df614374d62d2183afb64be2d30a + commit: 435963d1631043e694e56e6bcc3c79c3 + digest: b5:f4ea07ad2dd94bd7243562f9908b9fb104feef8076040c89d9f7c1dedc074de4d4ce2b997686ef4400f3eccb765a7cfc20ed4acdd70b9a3699351245c61dba97 - name: buf.build/sebmelki/sebuf - commit: 8af7d745b4554521bb89cde70a20ce0b - digest: b5:e676b75b804ae2b798c260e94309f3aa1e44d4b867dd7c8f5d8d634399765c44b6fda92de0574861d8d4bacc90a01c47fd04985a345b94441eedc87b500fbaf8 + commit: b5f679ca6c5f4f148c3414adfea268d3 + digest: b5:007be9b0418e0284f34578307ba516b56259a29932bfd3b662515d3107d3886e2d47592edc580e4a520155c3ebed7b55375b90ac0fa327ccb603f17bf84d7eb1 diff --git a/examples/error-handler/go.mod b/examples/error-handler/go.mod index 26d6df8a..6626eebd 100644 --- a/examples/error-handler/go.mod +++ b/examples/error-handler/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/error-handler go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-20250818125809-ff61bcf670dd github.com/google/uuid v1.6.0 diff --git a/examples/error-handler/go.sum b/examples/error-handler/go.sum index b2565c30..8f959148 100644 --- a/examples/error-handler/go.sum +++ b/examples/error-handler/go.sum @@ -1,6 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v0.14.0 h1:kr/rC/no+DtRyYX+8KXLDxNnI1rINz0imk5K44ZpZ3A= buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA= cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= diff --git a/examples/market-data-unwrap/README.md b/examples/market-data-unwrap/README.md index c433c0e9..3b16def9 100644 --- a/examples/market-data-unwrap/README.md +++ b/examples/market-data-unwrap/README.md @@ -208,7 +208,7 @@ go run main.go go run client_example.go ``` -The generated client handles all the unwrap serialization automatically: +Because this example generates `protoc-gen-go-http` alongside `protoc-gen-go-client`, the generated client uses the go-http-owned unwrap marshalers automatically: ```go package main @@ -282,8 +282,8 @@ docker run -p 8081:8080 -v $(pwd)/docs:/app swaggerapi/swagger-ui ### How Unwrap Works 1. **Proto definition**: Mark one repeated field in a message with `[(sebuf.http.unwrap) = true]` -2. **Code generation**: sebuf generates custom `MarshalJSON()` and `UnmarshalJSON()` methods -3. **Runtime**: When the message is a map value, JSON serialization collapses the wrapper +2. **Code generation**: `protoc-gen-go-http` generates custom `MarshalJSON()` and `UnmarshalJSON()` methods +3. **Runtime**: When the message is a map value, JSON serialization collapses the wrapper; the generated Go client uses those methods when go-http is generated into the same package ### Constraints @@ -295,8 +295,8 @@ docker run -p 8081:8080 -v $(pwd)/docs:/app swaggerapi/swagger-ui | File | Description | |------|-------------| -| `*_unwrap.pb.go` | Custom JSON marshaling for messages with unwrap fields | -| `*_client.pb.go` | HTTP client that uses the custom marshalers | +| `*_unwrap.pb.go` | go-http-owned custom JSON marshaling for messages with unwrap fields | +| `*_client.pb.go` | HTTP client that uses the custom marshalers when present | | `*.openapi.yaml` | OpenAPI spec with correct array schemas | ## Troubleshooting @@ -306,7 +306,7 @@ docker run -p 8081:8080 -v $(pwd)/docs:/app swaggerapi/swagger-ui - Run `make clean && make generate` to regenerate code **Client not handling unwrap correctly?** -- The client uses custom marshalers automatically +- Generate `protoc-gen-go-http` alongside `protoc-gen-go-client`; go-http owns the custom marshalers - Check that you're using the generated client, not manual HTTP calls **OpenAPI shows object instead of array for map values?** diff --git a/examples/market-data-unwrap/api/proto/services/market_data_service_client.pb.go b/examples/market-data-unwrap/api/proto/services/market_data_service_client.pb.go index 200645fc..212161b5 100644 --- a/examples/market-data-unwrap/api/proto/services/market_data_service_client.pb.go +++ b/examples/market-data-unwrap/api/proto/services/market_data_service_client.pb.go @@ -25,6 +25,12 @@ const ( ContentTypeProto = "application/x-protobuf" ) +// sebufUnmarshaler is implemented by generated messages with custom JSON unmarshaling. +// It allows passing protojson.UnmarshalOptions (e.g. DiscardUnknown) through custom unmarshalers. +type sebufUnmarshaler interface { + UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error +} + // MarketDataServiceClient is the client API for MarketDataService service. type MarketDataServiceClient interface { GetOptionBars(ctx context.Context, req *GetOptionBarsRequest, opts ...MarketDataServiceCallOption) (*GetOptionBarsResponse, error) @@ -33,10 +39,11 @@ type MarketDataServiceClient interface { // marketDataServiceClient is the implementation of MarketDataServiceClient. type marketDataServiceClient struct { - baseURL string - httpClient *http.Client - contentType string - defaultHeaders map[string]string + baseURL string + httpClient *http.Client + contentType string + defaultHeaders map[string]string + discardUnknownFields bool } var _ MarketDataServiceClient = (*marketDataServiceClient)(nil) @@ -69,13 +76,22 @@ func WithMarketDataServiceDefaultHeader(key, value string) MarketDataServiceClie } } +// WithMarketDataServiceDiscardUnknownFields sets whether to discard unknown fields in JSON responses. +// When true, unknown fields are silently ignored instead of causing unmarshal errors. +func WithMarketDataServiceDiscardUnknownFields(discard bool) MarketDataServiceClientOption { + return func(c *marketDataServiceClient) { + c.discardUnknownFields = discard + } +} + // MarketDataServiceCallOption configures a single RPC call. type MarketDataServiceCallOption func(*marketDataServiceCallOptions) // marketDataServiceCallOptions holds options for a single RPC call. type marketDataServiceCallOptions struct { - headers map[string]string - contentType string + headers map[string]string + contentType string + discardUnknownFields *bool } // WithMarketDataServiceHeader adds a header to a single request. @@ -95,6 +111,14 @@ func WithMarketDataServiceCallContentType(contentType string) MarketDataServiceC } } +// WithMarketDataServiceCallDiscardUnknownFields sets whether to discard unknown fields for a single request. +// Overrides the client-level setting from WithMarketDataServiceDiscardUnknownFields. +func WithMarketDataServiceCallDiscardUnknownFields(discard bool) MarketDataServiceCallOption { + return func(o *marketDataServiceCallOptions) { + o.discardUnknownFields = &discard + } +} + // WithMarketDataServiceAPCAAPIKEYID Alpaca API key ID for authentication func WithMarketDataServiceAPCAAPIKEYID(value string) MarketDataServiceClientOption { return WithMarketDataServiceDefaultHeader("APCA-API-KEY-ID", value) @@ -207,9 +231,15 @@ func (c *marketDataServiceClient) GetOptionBars(ctx context.Context, req *GetOpt return nil, c.handleErrorResponse(resp.StatusCode, respBody, contentType) } + // Resolve discardUnknownFields: per-call option overrides client default + discardUnknown := c.discardUnknownFields + if callOpts.discardUnknownFields != nil { + discardUnknown = *callOpts.discardUnknownFields + } + // Unmarshal response result := &GetOptionBarsResponse{} - if err := c.unmarshalResponse(respBody, result, contentType); err != nil { + if err := c.unmarshalResponse(respBody, result, contentType, discardUnknown); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } @@ -277,9 +307,15 @@ func (c *marketDataServiceClient) GetLatestOptionBars(ctx context.Context, req * return nil, c.handleErrorResponse(resp.StatusCode, respBody, contentType) } + // Resolve discardUnknownFields: per-call option overrides client default + discardUnknown := c.discardUnknownFields + if callOpts.discardUnknownFields != nil { + discardUnknown = *callOpts.discardUnknownFields + } + // Unmarshal response result := &GetLatestOptionBarsResponse{} - if err := c.unmarshalResponse(respBody, result, contentType); err != nil { + if err := c.unmarshalResponse(respBody, result, contentType, discardUnknown); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } @@ -303,16 +339,18 @@ func (c *marketDataServiceClient) marshalRequest(req proto.Message, contentType func (c *marketDataServiceClient) handleErrorResponse(statusCode int, body []byte, contentType string) error { // Try to parse as ValidationError first (for 400 errors) + // Always use strict mode (false) for error parsing to avoid loose JSON + // falsely matching ValidationError or Error types. if statusCode == http.StatusBadRequest { validationErr := &sebufhttp.ValidationError{} - if unmarshalErr := c.unmarshalResponse(body, validationErr, contentType); unmarshalErr == nil { + if unmarshalErr := c.unmarshalResponse(body, validationErr, contentType, false); unmarshalErr == nil { return validationErr } } // Try to parse as generic Error genericErr := &sebufhttp.Error{} - if unmarshalErr := c.unmarshalResponse(body, genericErr, contentType); unmarshalErr == nil { + if unmarshalErr := c.unmarshalResponse(body, genericErr, contentType, false); unmarshalErr == nil { return genericErr } @@ -320,21 +358,27 @@ func (c *marketDataServiceClient) handleErrorResponse(statusCode int, body []byt return fmt.Errorf("request failed with status %d: %s", statusCode, string(body)) } -func (c *marketDataServiceClient) unmarshalResponse(body []byte, msg proto.Message, contentType string) error { +func (c *marketDataServiceClient) unmarshalResponse(body []byte, msg proto.Message, contentType string, discardUnknown bool) error { if len(body) == 0 { return nil } + opts := protojson.UnmarshalOptions{DiscardUnknown: discardUnknown} + switch contentType { case ContentTypeJSON: - // Check for custom JSON unmarshaler (unwrap support) - if unmarshaler, ok := msg.(json.Unmarshaler); ok { - return unmarshaler.UnmarshalJSON(body) + // Check for sebuf-generated custom unmarshaler (passes options through) + if u, ok := msg.(sebufUnmarshaler); ok { + return u.UnmarshalJSONSebuf(body, opts) + } + // Check for third-party json.Unmarshaler (best effort, cannot pass options) + if u, ok := msg.(json.Unmarshaler); ok { + return u.UnmarshalJSON(body) } - return protojson.Unmarshal(body, msg) + return opts.Unmarshal(body, msg) case ContentTypeProto: return proto.Unmarshal(body, msg) default: - return protojson.Unmarshal(body, msg) + return opts.Unmarshal(body, msg) } } diff --git a/examples/market-data-unwrap/api/proto/services/market_data_service_http.pb.go b/examples/market-data-unwrap/api/proto/services/market_data_service_http.pb.go index f73f790c..5dd7b0f9 100644 --- a/examples/market-data-unwrap/api/proto/services/market_data_service_http.pb.go +++ b/examples/market-data-unwrap/api/proto/services/market_data_service_http.pb.go @@ -23,18 +23,18 @@ func RegisterMarketDataServiceServer(server MarketDataServiceServer, opts ...Ser methodHeaders := getGetOptionBarsHeaders() getOptionBarsHandler := BindingMiddleware[GetOptionBarsRequest]( - genericHandler(server.GetOptionBars, config.errorHandler), serviceHeaders, methodHeaders, + genericHandler(server.GetOptionBars, config.errorHandler, config.marshalOpts), serviceHeaders, methodHeaders, getOptionBarsPathParams, getOptionBarsQueryParams, - "GET", config.errorHandler, + "GET", config.errorHandler, config.marshalOpts, ) config.mux.Handle("GET /v2/options/bars", getOptionBarsHandler) methodHeaders = getGetLatestOptionBarsHeaders() getLatestOptionBarsHandler := BindingMiddleware[GetLatestOptionBarsRequest]( - genericHandler(server.GetLatestOptionBars, config.errorHandler), serviceHeaders, methodHeaders, + genericHandler(server.GetLatestOptionBars, config.errorHandler, config.marshalOpts), serviceHeaders, methodHeaders, getLatestOptionBarsPathParams, getLatestOptionBarsQueryParams, - "GET", config.errorHandler, + "GET", config.errorHandler, config.marshalOpts, ) config.mux.Handle("GET /v2/options/bars/latest", getLatestOptionBarsHandler) diff --git a/examples/market-data-unwrap/api/proto/services/market_data_service_http_binding.pb.go b/examples/market-data-unwrap/api/proto/services/market_data_service_http_binding.pb.go index b3f9aea3..3d7fd9cb 100644 --- a/examples/market-data-unwrap/api/proto/services/market_data_service_http_binding.pb.go +++ b/examples/market-data-unwrap/api/proto/services/market_data_service_http_binding.pb.go @@ -36,6 +36,13 @@ const ( type bodyCtxKey struct{} +// sebufMarshaler is implemented by generated messages with custom JSON marshaling. +// It allows passing protojson.MarshalOptions (e.g. EmitUnpopulated) through +// custom marshalers so server-configured options reach every wire-format site. +type sebufMarshaler interface { + MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) +} + // PathParamConfig defines configuration for a path parameter. type PathParamConfig struct { URLParam string // Parameter name in URL path @@ -62,31 +69,20 @@ func getRequest[Req any](ctx context.Context) Req { // and validates them using protovalidate and header validation. // It supports path parameters, query parameters, and request body binding. func BindingMiddleware[Req any](next http.Handler, serviceHeaders, methodHeaders []*sebufhttp.Header, - pathParams []PathParamConfig, queryParams []QueryParamConfig, httpMethod string, errorHandler ErrorHandler) http.Handler { + pathParams []PathParamConfig, queryParams []QueryParamConfig, httpMethod string, errorHandler ErrorHandler, marshalOpts protojson.MarshalOptions) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Validate headers first if validationErr := validateHeaders(r, serviceHeaders, methodHeaders); validationErr != nil { - writeErrorWithHandler(w, r, validationErr, errorHandler) + writeErrorWithHandler(w, r, validationErr, errorHandler, marshalOpts) return } toBind := new(Req) - // Bind path parameters - if msg, ok := any(toBind).(proto.Message); ok { - if err := bindPathParams(r, msg, pathParams); err != nil { - writeErrorWithHandler(w, r, err, errorHandler) - return - } - - // Bind query parameters - if err := bindQueryParams(r, msg, queryParams); err != nil { - writeErrorWithHandler(w, r, err, errorHandler) - return - } - } - - // Bind body only for POST, PUT, PATCH methods + // Bind body FIRST for POST, PUT, PATCH methods. + // This must happen before path/query binding because protojson.Unmarshal + // calls proto.Reset(), which would wipe any previously-set fields. + // By binding body first, path and query params applied afterwards take precedence. if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" { err := bindDataBasedOnContentType(r, toBind) if err != nil { @@ -99,7 +95,21 @@ func BindingMiddleware[Req any](next http.Handler, serviceHeaders, methodHeaders }, }, } - writeErrorWithHandler(w, r, validationErr, errorHandler) + writeErrorWithHandler(w, r, validationErr, errorHandler, marshalOpts) + return + } + } + + // Bind path and query parameters AFTER body, so URL-stated values always win + if msg, ok := any(toBind).(proto.Message); ok { + if err := bindPathParams(r, msg, pathParams); err != nil { + writeErrorWithHandler(w, r, err, errorHandler, marshalOpts) + return + } + + // Bind query parameters + if err := bindQueryParams(r, msg, queryParams); err != nil { + writeErrorWithHandler(w, r, err, errorHandler, marshalOpts) return } } @@ -107,7 +117,7 @@ func BindingMiddleware[Req any](next http.Handler, serviceHeaders, methodHeaders // Validate the complete message if msg, ok := any(toBind).(proto.Message); ok { if err := ValidateMessage(msg); err != nil { - writeErrorWithHandler(w, r, convertProtovalidateError(err), errorHandler) + writeErrorWithHandler(w, r, convertProtovalidateError(err), errorHandler, marshalOpts) return } } @@ -126,6 +136,30 @@ func filterFlags(content string) string { return content } +// resolveResponseContentType determines the response serialization format. +// Per HTTP semantics (RFC 9110), the Accept header governs the desired response format. +// Falls back to request Content-Type if Accept is absent, then defaults to JSON. +func resolveResponseContentType(r *http.Request) string { + accept := filterFlags(r.Header.Get("Accept")) + switch accept { + case BinaryContentType, ProtoContentType: + return accept + case JSONContentType: + return JSONContentType + case "", "*/*": + // No Accept or wildcard: fall back to request Content-Type + ct := filterFlags(r.Header.Get("Content-Type")) + switch ct { + case BinaryContentType, ProtoContentType: + return ct + default: + return JSONContentType + } + default: + return JSONContentType + } +} + func bindDataBasedOnContentType[Req any](r *http.Request, toBind *Req) error { contentType := filterFlags(r.Header.Get("Content-Type")) switch contentType { @@ -216,7 +250,7 @@ func bindPathParams(r *http.Request, msg proto.Message, params []PathParamConfig continue // Field not found, skip } - convertedValue, err := convertStringToFieldValue(value, field.Kind()) + convertedValue, err := convertStringToFieldValue(value, field) if err != nil { return &sebufhttp.ValidationError{ Violations: []*sebufhttp.FieldViolation{{ @@ -244,6 +278,14 @@ func bindQueryParams(r *http.Request, msg proto.Message, params []QueryParamConf for _, param := range params { values := query[param.QueryName] + // Filter empty values (e.g., ?param= treated as unset) + var filtered []string + for _, v := range values { + if v != "" { + filtered = append(filtered, v) + } + } + values = filtered if len(values) == 0 { if param.Required { return &sebufhttp.ValidationError{ @@ -265,7 +307,7 @@ func bindQueryParams(r *http.Request, msg proto.Message, params []QueryParamConf if field.IsList() { list := reflectMsg.Mutable(field).List() for _, v := range values { - converted, err := convertStringToFieldValue(v, field.Kind()) + converted, err := convertStringToFieldValue(v, field) if err != nil { return &sebufhttp.ValidationError{ Violations: []*sebufhttp.FieldViolation{{ @@ -277,7 +319,7 @@ func bindQueryParams(r *http.Request, msg proto.Message, params []QueryParamConf list.Append(converted) } } else { - converted, err := convertStringToFieldValue(values[0], field.Kind()) + converted, err := convertStringToFieldValue(values[0], field) if err != nil { return &sebufhttp.ValidationError{ Violations: []*sebufhttp.FieldViolation{{ @@ -294,8 +336,20 @@ func bindQueryParams(r *http.Request, msg proto.Message, params []QueryParamConf } // convertStringToFieldValue converts a string value to the appropriate protoreflect.Value. -func convertStringToFieldValue(value string, kind protoreflect.Kind) (protoreflect.Value, error) { - switch kind { +func convertStringToFieldValue(value string, field protoreflect.FieldDescriptor) (protoreflect.Value, error) { + switch field.Kind() { + case protoreflect.EnumKind: + // Try numeric value first — accept unknown numbers for proto3 forward-compat + if v, err := strconv.ParseInt(value, 10, 32); err == nil { + return protoreflect.ValueOfEnum(protoreflect.EnumNumber(v)), nil + } + // Fall back to enum name lookup + enumDesc := field.Enum() + enumVal := enumDesc.Values().ByName(protoreflect.Name(value)) + if enumVal != nil { + return protoreflect.ValueOfEnum(enumVal.Number()), nil + } + return protoreflect.Value{}, fmt.Errorf("invalid value %q for enum %s", value, enumDesc.Name()) case protoreflect.StringKind: return protoreflect.ValueOfString(value), nil case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: @@ -341,11 +395,11 @@ func convertStringToFieldValue(value string, kind protoreflect.Kind) (protorefle } return protoreflect.ValueOfFloat64(v), nil default: - return protoreflect.Value{}, fmt.Errorf("unsupported field type: %v", kind) + return protoreflect.Value{}, fmt.Errorf("unsupported field type: %v", field.Kind()) } } -func genericHandler[Req any, Res any](serve func(context.Context, Req) (Res, error), errorHandler ErrorHandler) http.HandlerFunc { +func genericHandler[Req any, Res any](serve func(context.Context, Req) (Res, error), errorHandler ErrorHandler, marshalOpts protojson.MarshalOptions) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { request := getRequest[Req](r.Context()) @@ -354,30 +408,27 @@ func genericHandler[Req any, Res any](serve func(context.Context, Req) (Res, err // Check if error is already a proto.Message (e.g., custom proto error types) // If so, pass it directly - defaultErrorResponse will preserve its structure if _, ok := err.(proto.Message); ok { - writeErrorWithHandler(w, r, err, errorHandler) + writeErrorWithHandler(w, r, err, errorHandler, marshalOpts) return } errorMsg := &sebufhttp.Error{ Message: err.Error(), } - writeErrorWithHandler(w, r, errorMsg, errorHandler) + writeErrorWithHandler(w, r, errorMsg, errorHandler, marshalOpts) return } - responseBytes, err := marshalResponse(r, response) + responseBytes, err := marshalResponse(r, response, marshalOpts) if err != nil { errorMsg := &sebufhttp.Error{ Message: fmt.Sprintf("failed to marshal response: %v", err), } - writeErrorWithHandler(w, r, errorMsg, errorHandler) + writeErrorWithHandler(w, r, errorMsg, errorHandler, marshalOpts) return } - // Set Content-Type based on request Content-Type (matching serialization format) - respContentType := "application/json" - if ct := filterFlags(r.Header.Get("Content-Type")); ct == BinaryContentType || ct == ProtoContentType { - respContentType = "application/x-protobuf" - } + // Set response Content-Type based on Accept header (RFC 9110) + respContentType := resolveResponseContentType(r) w.Header().Set("Content-Type", respContentType) _, err = w.Write(responseBytes) @@ -385,39 +436,40 @@ func genericHandler[Req any, Res any](serve func(context.Context, Req) (Res, err errorMsg := &sebufhttp.Error{ Message: fmt.Sprintf("failed to write response: %v", err), } - writeErrorWithHandler(w, r, errorMsg, errorHandler) + writeErrorWithHandler(w, r, errorMsg, errorHandler, marshalOpts) return } } } -func marshalResponse(r *http.Request, response any) ([]byte, error) { - contentType := r.Header.Get("Content-Type") - if contentType == "" { - contentType = JSONContentType - } +func marshalResponse(r *http.Request, response any, marshalOpts protojson.MarshalOptions) ([]byte, error) { + contentType := resolveResponseContentType(r) msg, ok := response.(proto.Message) if !ok { return nil, fmt.Errorf("response is not a protocol buffer message") } - switch filterFlags(contentType) { - case JSONContentType: - // Check for custom JSON marshaler (unwrap support) - if marshaler, ok := response.(json.Marshaler); ok { - return marshaler.MarshalJSON() - } - return protojson.Marshal(msg) + switch contentType { case BinaryContentType, ProtoContentType: return proto.Marshal(msg) default: - // Default to JSON for unrecognized content types - if marshaler, ok := response.(json.Marshaler); ok { - return marshaler.MarshalJSON() - } - return protojson.Marshal(msg) + return marshalJSONWithOpts(msg, marshalOpts) + } +} + +// marshalJSONWithOpts dispatches JSON marshaling: +// - sebufMarshaler (sebuf-generated custom marshalers) receives marshalOpts +// - json.Marshaler (third-party / back-compat) is called with no options +// - otherwise marshalOpts.Marshal is used +func marshalJSONWithOpts(msg proto.Message, marshalOpts protojson.MarshalOptions) ([]byte, error) { + if m, ok := msg.(sebufMarshaler); ok { + return m.MarshalJSONSebuf(marshalOpts) } + if m, ok := msg.(json.Marshaler); ok { + return m.MarshalJSON() + } + return marshalOpts.Marshal(msg) } // responseCapture wraps ResponseWriter to track if Write or WriteHeader was called @@ -438,27 +490,17 @@ func (rc *responseCapture) Write(b []byte) (int, error) { } // writeProtoMessageResponse writes a protobuf message as an HTTP response -func writeProtoMessageResponse(w http.ResponseWriter, r *http.Request, msg proto.Message, statusCode int, fallbackMsg string) { - contentType := r.Header.Get("Content-Type") - if contentType == "" { - contentType = JSONContentType - } +func writeProtoMessageResponse(w http.ResponseWriter, r *http.Request, msg proto.Message, statusCode int, fallbackMsg string, marshalOpts protojson.MarshalOptions) { + respContentType := resolveResponseContentType(r) var responseBytes []byte var err error - var respContentType string - switch filterFlags(contentType) { - case JSONContentType: - responseBytes, err = protojson.Marshal(msg) - respContentType = "application/json" + switch respContentType { case BinaryContentType, ProtoContentType: responseBytes, err = proto.Marshal(msg) - respContentType = "application/x-protobuf" default: - // Default to JSON for unrecognized content types - responseBytes, err = protojson.Marshal(msg) - respContentType = "application/json" + responseBytes, err = marshalJSONWithOpts(msg, marshalOpts) } if err != nil { @@ -473,19 +515,19 @@ func writeProtoMessageResponse(w http.ResponseWriter, r *http.Request, msg proto } // writeValidationErrorResponse writes a ValidationError as a response -func writeValidationErrorResponse(w http.ResponseWriter, r *http.Request, validationErr *sebufhttp.ValidationError) { - writeProtoMessageResponse(w, r, validationErr, http.StatusBadRequest, "validation failed") +func writeValidationErrorResponse(w http.ResponseWriter, r *http.Request, validationErr *sebufhttp.ValidationError, marshalOpts protojson.MarshalOptions) { + writeProtoMessageResponse(w, r, validationErr, http.StatusBadRequest, "validation failed", marshalOpts) } // writeValidationError converts a protovalidate error to ValidationError and writes it as response -func writeValidationError(w http.ResponseWriter, r *http.Request, err error) { +func writeValidationError(w http.ResponseWriter, r *http.Request, err error, marshalOpts protojson.MarshalOptions) { validationErr := convertProtovalidateError(err) - writeValidationErrorResponse(w, r, validationErr) + writeValidationErrorResponse(w, r, validationErr, marshalOpts) } // writeErrorResponse writes an Error as a response -func writeErrorResponse(w http.ResponseWriter, r *http.Request, errorMsg *sebufhttp.Error) { - writeProtoMessageResponse(w, r, errorMsg, http.StatusInternalServerError, "internal server error") +func writeErrorResponse(w http.ResponseWriter, r *http.Request, errorMsg *sebufhttp.Error, marshalOpts protojson.MarshalOptions) { + writeProtoMessageResponse(w, r, errorMsg, http.StatusInternalServerError, "internal server error", marshalOpts) } // convertProtovalidateError converts a protovalidate error to ValidationError @@ -554,7 +596,7 @@ func defaultErrorStatusCode(err error) int { } // writeErrorWithHandler calls custom handler if set, then marshals response -func writeErrorWithHandler(w http.ResponseWriter, r *http.Request, err error, handler ErrorHandler) { +func writeErrorWithHandler(w http.ResponseWriter, r *http.Request, err error, handler ErrorHandler, marshalOpts protojson.MarshalOptions) { var response proto.Message var capture *responseCapture @@ -577,35 +619,26 @@ func writeErrorWithHandler(w http.ResponseWriter, r *http.Request, err error, ha // If handler already set status, don't set it again if capture != nil && capture.wroteHeader { // Handler set status, just write the body - writeResponseBody(w, r, response) + writeResponseBody(w, r, response, marshalOpts) return } // Write full response with status code - writeProtoMessageResponse(w, r, response, statusCode, "error processing request") + writeProtoMessageResponse(w, r, response, statusCode, "error processing request", marshalOpts) } // writeResponseBody writes the response body without setting status code -func writeResponseBody(w http.ResponseWriter, r *http.Request, msg proto.Message) { - contentType := r.Header.Get("Content-Type") - if contentType == "" { - contentType = JSONContentType - } +func writeResponseBody(w http.ResponseWriter, r *http.Request, msg proto.Message, marshalOpts protojson.MarshalOptions) { + respContentType := resolveResponseContentType(r) var responseBytes []byte var err error - var respContentType string - switch filterFlags(contentType) { - case JSONContentType: - responseBytes, err = protojson.Marshal(msg) - respContentType = "application/json" + switch respContentType { case BinaryContentType, ProtoContentType: responseBytes, err = proto.Marshal(msg) - respContentType = "application/x-protobuf" default: - responseBytes, err = protojson.Marshal(msg) - respContentType = "application/json" + responseBytes, err = marshalJSONWithOpts(msg, marshalOpts) } if err != nil { diff --git a/examples/market-data-unwrap/api/proto/services/market_data_service_http_config.pb.go b/examples/market-data-unwrap/api/proto/services/market_data_service_http_config.pb.go index 0cb5f53a..93fa8a6a 100644 --- a/examples/market-data-unwrap/api/proto/services/market_data_service_http_config.pb.go +++ b/examples/market-data-unwrap/api/proto/services/market_data_service_http_config.pb.go @@ -6,6 +6,7 @@ package services import ( "net/http" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -30,6 +31,7 @@ type serverConfiguration struct { mux *http.ServeMux withMux bool errorHandler ErrorHandler + marshalOpts protojson.MarshalOptions } func getDefaultConfiguration() *serverConfiguration { @@ -61,3 +63,13 @@ func WithErrorHandler(handler ErrorHandler) ServerOption { c.errorHandler = handler } } + +// WithMarshalOptions configures the protojson.MarshalOptions used when serializing +// JSON responses (including SSE events and error bodies). The zero value preserves +// default behavior. Use this to surface zero-value fields with EmitUnpopulated, +// switch to proto field names with UseProtoNames, or tune any other protojson knob. +func WithMarshalOptions(opts protojson.MarshalOptions) ServerOption { + return func(c *serverConfiguration) { + c.marshalOpts = opts + } +} diff --git a/examples/market-data-unwrap/api/proto/services/market_data_service_unwrap.pb.go b/examples/market-data-unwrap/api/proto/services/market_data_service_unwrap.pb.go index 47812bc5..69ad93a3 100644 --- a/examples/market-data-unwrap/api/proto/services/market_data_service_unwrap.pb.go +++ b/examples/market-data-unwrap/api/proto/services/market_data_service_unwrap.pb.go @@ -13,9 +13,9 @@ import ( "google.golang.org/protobuf/encoding/protojson" ) -// MarshalJSON implements json.Marshaler for GetOptionBarsResponse. +// MarshalJSONSebuf implements sebufMarshaler for GetOptionBarsResponse. // This method handles unwrap field serialization for map values. -func (x *GetOptionBarsResponse) MarshalJSON() ([]byte, error) { +func (x *GetOptionBarsResponse) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { if x == nil { return []byte("null"), nil } @@ -30,7 +30,15 @@ func (x *GetOptionBarsResponse) MarshalJSON() ([]byte, error) { // Marshal the unwrap field directly (the array) items := make([]json.RawMessage, 0, len(wrapper.GetBars())) for _, item := range wrapper.GetBars() { - data, err := protojson.Marshal(item) + var data []byte + var err error + if m, ok := any(item).(interface { + MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) + }); ok { + data, err = m.MarshalJSONSebuf(opts) + } else { + data, err = opts.Marshal(item) + } if err != nil { return nil, err } @@ -62,6 +70,11 @@ func (x *GetOptionBarsResponse) MarshalJSON() ([]byte, error) { return json.Marshal(out) } +// MarshalJSON implements json.Marshaler for GetOptionBarsResponse. +func (x *GetOptionBarsResponse) MarshalJSON() ([]byte, error) { + return x.MarshalJSONSebuf(protojson.MarshalOptions{}) +} + // UnmarshalJSON implements json.Unmarshaler for GetOptionBarsResponse. // This method handles unwrap field deserialization for map values. func (x *GetOptionBarsResponse) UnmarshalJSON(data []byte) error { diff --git a/examples/market-data-unwrap/buf.lock b/examples/market-data-unwrap/buf.lock index 1a170276..464226d9 100644 --- a/examples/market-data-unwrap/buf.lock +++ b/examples/market-data-unwrap/buf.lock @@ -2,8 +2,8 @@ version: v2 deps: - name: buf.build/bufbuild/protovalidate - commit: 2a1774d888024a9b93ce7eb4b59f6a83 - digest: b5:6b7f9bc919b65e5b79d7b726ffc03d6f815a412d6b792970fa6f065cae162107bd0a9d47272c8ab1a2c9514e87b13d3fbf71df614374d62d2183afb64be2d30a + commit: 435963d1631043e694e56e6bcc3c79c3 + digest: b5:f4ea07ad2dd94bd7243562f9908b9fb104feef8076040c89d9f7c1dedc074de4d4ce2b997686ef4400f3eccb765a7cfc20ed4acdd70b9a3699351245c61dba97 - name: buf.build/sebmelki/sebuf - commit: 60489ad3941b45deb03eeea4c0a09181 - digest: b5:b48311811af0e8a1239f845dc9fb218fd0000f1812b0dabdc2a7315b4552117111681dd2fe85094eeac6006655908c662d5d910f3455a788948b5aac85330769 + commit: b5f679ca6c5f4f148c3414adfea268d3 + digest: b5:007be9b0418e0284f34578307ba516b56259a29932bfd3b662515d3107d3886e2d47592edc580e4a520155c3ebed7b55375b90ac0fa327ccb603f17bf84d7eb1 diff --git a/examples/market-data-unwrap/docs/MarketDataService.openapi.json b/examples/market-data-unwrap/docs/MarketDataService.openapi.json index 630613b8..b988d856 100644 --- a/examples/market-data-unwrap/docs/MarketDataService.openapi.json +++ b/examples/market-data-unwrap/docs/MarketDataService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"BarsEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/OptionBar"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetLatestOptionBarsRequest":{"description":"GetLatestOptionBarsRequest is the request to get the latest option bars.","properties":{"feed":{"description":"Data feed (indicative, opra).","enum":["indicative","opra"],"example":"opra","examples":["opra","indicative"],"type":"string"},"symbols":{"description":"Comma-separated list of option symbols.","example":"TSLA260123C00335000","examples":["TSLA260123C00335000"],"type":"string"}},"required":["symbols"],"type":"object"},"GetLatestOptionBarsResponse":{"description":"GetLatestOptionBarsResponse contains the latest option bar for each symbol.","properties":{"bars":{"additionalProperties":{"$ref":"#/components/schemas/OptionBar"},"description":"Map of symbol to latest bar.","type":"object"}},"type":"object"},"GetOptionBarsRequest":{"description":"GetOptionBarsRequest is the request to get historical option bars.","properties":{"end":{"description":"End timestamp (RFC 3339 or YYYY-MM-DD).","example":"2025-12-31","examples":["2025-12-31","2025-12-15T16:00:00Z"],"type":"string"},"limit":{"description":"Maximum number of bars to return (1-10000, default 1000).","example":100,"examples":[100,1000],"format":"int32","maximum":10000,"minimum":1,"type":"integer"},"pageToken":{"description":"Page token for pagination.","example":"dG9rZW4xMjM=","examples":["dG9rZW4xMjM="],"type":"string"},"sort":{"description":"Sort order (asc, desc).","enum":["asc","desc"],"example":"asc","examples":["asc","desc"],"type":"string"},"start":{"description":"Start timestamp (RFC 3339 or YYYY-MM-DD).","example":"2025-12-01","examples":["2025-12-01","2025-12-15T09:30:00Z"],"type":"string"},"symbols":{"description":"Comma-separated list of option symbols in OCC format.","example":"TSLA260123C00335000","examples":["TSLA260123C00335000","AAPL240119C00150000,AAPL240119P00145000"],"type":"string"},"timeframe":{"description":"Bar timeframe (e.g., 1Min, 5Min, 1Hour, 1Day).","example":"1Day","examples":["1Day","1Hour","5Min"],"type":"string"}},"required":["symbols","timeframe"],"type":"object"},"GetOptionBarsResponse":{"description":"GetOptionBarsResponse contains the option bars data.\n The map values use the unwrap annotation for cleaner JSON serialization:\n {\"bars\": {\"TSLA260123C00335000\": [...bars...]}} instead of {\"bars\": {\"TSLA260123C00335000\": {\"bars\": [...]}}}","properties":{"bars":{"additionalProperties":{"items":{"$ref":"#/components/schemas/OptionBar"},"type":"array"},"description":"Map of symbol to list of bars. Each symbol maps directly to an array of bars.","type":"object"},"nextPageToken":{"description":"Next page token for pagination, null if no more pages.","example":"eyJwYWdlIjogMn0=","examples":["eyJwYWdlIjogMn0="],"type":"string"}},"type":"object"},"OptionBar":{"description":"OptionBar represents an option OHLCV bar data point.","properties":{"c":{"description":"Close price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"false":{"description":"Number of trades.","example":1,"examples":[1],"format":"int64","type":"string"},"h":{"description":"High price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"l":{"description":"Low price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"o":{"description":"Open price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"t":{"description":"Timestamp in RFC 3339 format.","example":"2025-12-15T15:05:00Z","examples":["2025-12-15T15:05:00Z","2025-12-15T18:05:00Z"],"type":"string"},"v":{"description":"Volume.","example":1,"examples":[1,20],"format":"int64","type":"string"},"vw":{"description":"Volume weighted average price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"}},"type":"object"},"OptionBarsList":{"description":"OptionBarsList is a wrapper for a list of bars that will be unwrapped in JSON.\n This enables map values to serialize as arrays instead of nested objects.","items":{"$ref":"#/components/schemas/OptionBar"},"type":"array"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"MarketDataService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/v2/options/bars":{"get":{"description":"GetOptionBars retrieves historical option bars for the specified symbols.","operationId":"GetOptionBars","parameters":[{"description":"Alpaca API key ID for authentication","in":"header","name":"APCA-API-KEY-ID","required":true,"schema":{"example":"PKXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Alpaca API secret key for authentication","in":"header","name":"APCA-API-SECRET-KEY","required":true,"schema":{"example":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Comma-separated list of option symbols in OCC format.","in":"query","name":"symbols","required":false,"schema":{"type":"string"}},{"description":"Bar timeframe (e.g., 1Min, 5Min, 1Hour, 1Day).","in":"query","name":"timeframe","required":false,"schema":{"type":"string"}},{"description":"Start timestamp (RFC 3339 or YYYY-MM-DD).","in":"query","name":"start","required":false,"schema":{"type":"string"}},{"description":"End timestamp (RFC 3339 or YYYY-MM-DD).","in":"query","name":"end","required":false,"schema":{"type":"string"}},{"description":"Maximum number of bars to return (1-10000, default 1000).","in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Page token for pagination.","in":"query","name":"page_token","required":false,"schema":{"type":"string"}},{"description":"Sort order (asc, desc).","in":"query","name":"sort","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetOptionBarsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetOptionBars","tags":["MarketDataService"]}},"/v2/options/bars/latest":{"get":{"description":"GetLatestOptionBars retrieves the latest option bar for each symbol.","operationId":"GetLatestOptionBars","parameters":[{"description":"Alpaca API key ID for authentication","in":"header","name":"APCA-API-KEY-ID","required":true,"schema":{"example":"PKXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Alpaca API secret key for authentication","in":"header","name":"APCA-API-SECRET-KEY","required":true,"schema":{"example":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Comma-separated list of option symbols.","in":"query","name":"symbols","required":false,"schema":{"type":"string"}},{"description":"Data feed (indicative, opra).","in":"query","name":"feed","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetLatestOptionBarsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetLatestOptionBars","tags":["MarketDataService"]}}}} \ No newline at end of file +{"components":{"schemas":{"BarsEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/OptionBar"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetLatestOptionBarsRequest":{"description":"GetLatestOptionBarsRequest is the request to get the latest option bars.","properties":{"feed":{"description":"Data feed (indicative, opra).","enum":["indicative","opra"],"example":"opra","examples":["opra","indicative"],"type":"string"},"symbols":{"description":"Comma-separated list of option symbols.","example":"TSLA260123C00335000","examples":["TSLA260123C00335000"],"type":"string"}},"required":["symbols"],"type":"object"},"GetLatestOptionBarsResponse":{"description":"GetLatestOptionBarsResponse contains the latest option bar for each symbol.","properties":{"bars":{"additionalProperties":{"$ref":"#/components/schemas/OptionBar"},"description":"Map of symbol to latest bar.","type":"object"}},"type":"object"},"GetOptionBarsRequest":{"description":"GetOptionBarsRequest is the request to get historical option bars.","properties":{"end":{"description":"End timestamp (RFC 3339 or YYYY-MM-DD).","example":"2025-12-31","examples":["2025-12-31","2025-12-15T16:00:00Z"],"type":"string"},"limit":{"description":"Maximum number of bars to return (1-10000, default 1000).","example":100,"examples":[100,1000],"format":"int32","maximum":10000,"minimum":1,"type":"integer"},"pageToken":{"description":"Page token for pagination.","example":"dG9rZW4xMjM=","examples":["dG9rZW4xMjM="],"type":"string"},"sort":{"description":"Sort order (asc, desc).","enum":["asc","desc"],"example":"asc","examples":["asc","desc"],"type":"string"},"start":{"description":"Start timestamp (RFC 3339 or YYYY-MM-DD).","example":"2025-12-01","examples":["2025-12-01","2025-12-15T09:30:00Z"],"type":"string"},"symbols":{"description":"Comma-separated list of option symbols in OCC format.","example":"TSLA260123C00335000","examples":["TSLA260123C00335000","AAPL240119C00150000,AAPL240119P00145000"],"type":"string"},"timeframe":{"description":"Bar timeframe (e.g., 1Min, 5Min, 1Hour, 1Day).","example":"1Day","examples":["1Day","1Hour","5Min"],"type":"string"}},"required":["symbols","timeframe"],"type":"object"},"GetOptionBarsResponse":{"description":"GetOptionBarsResponse contains the option bars data.\n The map values use the unwrap annotation for cleaner JSON serialization:\n {\"bars\": {\"TSLA260123C00335000\": [...bars...]}} instead of {\"bars\": {\"TSLA260123C00335000\": {\"bars\": [...]}}}","properties":{"bars":{"additionalProperties":{"items":{"$ref":"#/components/schemas/OptionBar"},"type":"array"},"description":"Map of symbol to list of bars. Each symbol maps directly to an array of bars.","type":"object"},"nextPageToken":{"description":"Next page token for pagination, null if no more pages.","example":"eyJwYWdlIjogMn0=","examples":["eyJwYWdlIjogMn0="],"type":"string"}},"type":"object"},"OptionBar":{"description":"OptionBar represents an option OHLCV bar data point.","properties":{"c":{"description":"Close price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"false":{"description":"Number of trades.","example":"1","examples":["1"],"format":"int64","type":"string"},"h":{"description":"High price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"l":{"description":"Low price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"o":{"description":"Open price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"},"t":{"description":"Timestamp in RFC 3339 format.","example":"2025-12-15T15:05:00Z","examples":["2025-12-15T15:05:00Z","2025-12-15T18:05:00Z"],"type":"string"},"v":{"description":"Volume.","example":"1","examples":["1","20"],"format":"int64","type":"string"},"vw":{"description":"Volume weighted average price.","example":143.08,"examples":[143.08,145.34],"format":"double","type":"number"}},"type":"object"},"OptionBarsList":{"description":"OptionBarsList is a wrapper for a list of bars that will be unwrapped in JSON.\n This enables map values to serialize as arrays instead of nested objects.","items":{"$ref":"#/components/schemas/OptionBar"},"type":"array"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"MarketDataService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/v2/options/bars":{"get":{"description":"GetOptionBars retrieves historical option bars for the specified symbols.","operationId":"GetOptionBars","parameters":[{"description":"Alpaca API key ID for authentication","in":"header","name":"APCA-API-KEY-ID","required":true,"schema":{"example":"PKXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Alpaca API secret key for authentication","in":"header","name":"APCA-API-SECRET-KEY","required":true,"schema":{"example":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Comma-separated list of option symbols in OCC format.","in":"query","name":"symbols","required":false,"schema":{"type":"string"}},{"description":"Bar timeframe (e.g., 1Min, 5Min, 1Hour, 1Day).","in":"query","name":"timeframe","required":false,"schema":{"type":"string"}},{"description":"Start timestamp (RFC 3339 or YYYY-MM-DD).","in":"query","name":"start","required":false,"schema":{"type":"string"}},{"description":"End timestamp (RFC 3339 or YYYY-MM-DD).","in":"query","name":"end","required":false,"schema":{"type":"string"}},{"description":"Maximum number of bars to return (1-10000, default 1000).","in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Page token for pagination.","in":"query","name":"page_token","required":false,"schema":{"type":"string"}},{"description":"Sort order (asc, desc).","in":"query","name":"sort","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetOptionBarsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetOptionBars","tags":["MarketDataService"]}},"/v2/options/bars/latest":{"get":{"description":"GetLatestOptionBars retrieves the latest option bar for each symbol.","operationId":"GetLatestOptionBars","parameters":[{"description":"Alpaca API key ID for authentication","in":"header","name":"APCA-API-KEY-ID","required":true,"schema":{"example":"PKXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Alpaca API secret key for authentication","in":"header","name":"APCA-API-SECRET-KEY","required":true,"schema":{"example":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","type":"string"}},{"description":"Comma-separated list of option symbols.","in":"query","name":"symbols","required":false,"schema":{"type":"string"}},{"description":"Data feed (indicative, opra).","in":"query","name":"feed","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetLatestOptionBarsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetLatestOptionBars","tags":["MarketDataService"]}}}} \ No newline at end of file diff --git a/examples/market-data-unwrap/docs/MarketDataService.openapi.yaml b/examples/market-data-unwrap/docs/MarketDataService.openapi.yaml index 67dc5c23..f9fb50ae 100644 --- a/examples/market-data-unwrap/docs/MarketDataService.openapi.yaml +++ b/examples/market-data-unwrap/docs/MarketDataService.openapi.yaml @@ -194,17 +194,17 @@ components: start: type: string examples: - - 2025-12-01 - - 2025-12-15T09:30:00Z + - "2025-12-01" + - "2025-12-15T09:30:00Z" description: Start timestamp (RFC 3339 or YYYY-MM-DD). - example: 2025-12-01 + example: "2025-12-01" end: type: string examples: - - 2025-12-31 - - 2025-12-15T16:00:00Z + - "2025-12-31" + - "2025-12-15T16:00:00Z" description: End timestamp (RFC 3339 or YYYY-MM-DD). - example: 2025-12-31 + example: "2025-12-31" limit: type: integer examples: @@ -275,10 +275,10 @@ components: t: type: string examples: - - 2025-12-15T15:05:00Z - - 2025-12-15T18:05:00Z + - "2025-12-15T15:05:00Z" + - "2025-12-15T18:05:00Z" description: Timestamp in RFC 3339 format. - example: 2025-12-15T15:05:00Z + example: "2025-12-15T15:05:00Z" o: type: number examples: @@ -314,18 +314,18 @@ components: v: type: string examples: - - 1 - - 20 + - "1" + - "20" format: int64 description: Volume. - example: 1 + example: "1" n: type: string examples: - - 1 + - "1" format: int64 description: Number of trades. - example: 1 + example: "1" vw: type: number examples: diff --git a/examples/market-data-unwrap/go.mod b/examples/market-data-unwrap/go.mod index b5553d9a..8dc3d3da 100644 --- a/examples/market-data-unwrap/go.mod +++ b/examples/market-data-unwrap/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/market-data-unwrap go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v1.1.0 github.com/SebastienMelki/sebuf v0.0.0 google.golang.org/protobuf v1.36.11 diff --git a/examples/market-data-unwrap/go.sum b/examples/market-data-unwrap/go.sum index 91c13f43..a1ae1b52 100644 --- a/examples/market-data-unwrap/go.sum +++ b/examples/market-data-unwrap/go.sum @@ -1,6 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.1.0 h1:pQqEQRpOo4SqS60qkvmhLTTQU9JwzEvdyiqAtXa5SeY= buf.build/go/protovalidate v1.1.0/go.mod h1:bGZcPiAQDC3ErCHK3t74jSoJDFOs2JH3d7LWuTEIdss= cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= diff --git a/examples/marshal-options/go.mod b/examples/marshal-options/go.mod index 81c1997a..678652d1 100644 --- a/examples/marshal-options/go.mod +++ b/examples/marshal-options/go.mod @@ -9,7 +9,7 @@ require ( ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/google/cel-go v0.28.0 // indirect diff --git a/examples/marshal-options/go.sum b/examples/marshal-options/go.sum index a8cc191f..3f33d766 100644 --- a/examples/marshal-options/go.sum +++ b/examples/marshal-options/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= diff --git a/examples/multi-service-api/go.mod b/examples/multi-service-api/go.mod index 3ff2a938..8b8bfa18 100644 --- a/examples/multi-service-api/go.mod +++ b/examples/multi-service-api/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/multi-service-api go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-20250818125809-ff61bcf670dd google.golang.org/protobuf v1.36.11 diff --git a/examples/nested-resources/go.mod b/examples/nested-resources/go.mod index 944fd73e..3bfbfb62 100644 --- a/examples/nested-resources/go.mod +++ b/examples/nested-resources/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/nested-resources go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-20250818125809-ff61bcf670dd google.golang.org/protobuf v1.36.11 diff --git a/examples/python-client-demo/go.mod b/examples/python-client-demo/go.mod index 8051dc8c..62c2bf0c 100644 --- a/examples/python-client-demo/go.mod +++ b/examples/python-client-demo/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/python-client-demo go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-00010101000000-000000000000 google.golang.org/protobuf v1.36.11 diff --git a/examples/python-encoding-demo/go.mod b/examples/python-encoding-demo/go.mod index 8bc028e8..40627c24 100644 --- a/examples/python-encoding-demo/go.mod +++ b/examples/python-encoding-demo/go.mod @@ -9,7 +9,7 @@ require ( ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect cel.dev/expr v0.23.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/google/cel-go v0.25.0 // indirect diff --git a/examples/python-errors-demo/go.mod b/examples/python-errors-demo/go.mod index a97baacc..7b8e6b15 100644 --- a/examples/python-errors-demo/go.mod +++ b/examples/python-errors-demo/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/python-errors-demo go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-00010101000000-000000000000 google.golang.org/protobuf v1.36.11 diff --git a/examples/restful-crud/Makefile b/examples/restful-crud/Makefile index f6a3edb1..2be41668 100644 --- a/examples/restful-crud/Makefile +++ b/examples/restful-crud/Makefile @@ -17,6 +17,7 @@ install: @go install github.com/bufbuild/buf/cmd/buf@latest @go install google.golang.org/protobuf/cmd/protoc-gen-go@latest @GOPROXY=direct go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-go-http@latest + @GOPROXY=direct go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-go-client@latest @GOPROXY=direct go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-openapiv3@latest @echo "Tools installed" diff --git a/examples/restful-crud/go.mod b/examples/restful-crud/go.mod index 4712e607..b17519b1 100644 --- a/examples/restful-crud/go.mod +++ b/examples/restful-crud/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/restful-crud go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-20250818125809-ff61bcf670dd google.golang.org/protobuf v1.36.11 diff --git a/examples/rn-client-demo/go.mod b/examples/rn-client-demo/go.mod index 457c016a..95f2513e 100644 --- a/examples/rn-client-demo/go.mod +++ b/examples/rn-client-demo/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/rn-client-demo go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-00010101000000-000000000000 google.golang.org/protobuf v1.36.11 diff --git a/examples/simple-api/Makefile b/examples/simple-api/Makefile index ff9f1e2e..ad05e578 100644 --- a/examples/simple-api/Makefile +++ b/examples/simple-api/Makefile @@ -36,6 +36,7 @@ run: generate # Clean generated files clean: + @rm -rf api docs @rm -f *.pb.go @rm -f *_helpers.pb.go @rm -f *_http*.pb.go diff --git a/examples/simple-api/buf.gen.yaml b/examples/simple-api/buf.gen.yaml index 9888e8cb..1329d191 100644 --- a/examples/simple-api/buf.gen.yaml +++ b/examples/simple-api/buf.gen.yaml @@ -43,4 +43,4 @@ plugins: out: api opt: - module=github.com/SebastienMelki/sebuf/examples/simple-api/api - - generate_mock=false + - generate_mock=true diff --git a/examples/simple-api/go.mod b/examples/simple-api/go.mod index adbf13ac..4876a858 100644 --- a/examples/simple-api/go.mod +++ b/examples/simple-api/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/simple-api go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-20250818125809-ff61bcf670dd google.golang.org/protobuf v1.36.11 diff --git a/examples/sse-streaming/buf.lock b/examples/sse-streaming/buf.lock index 3579961c..464226d9 100644 --- a/examples/sse-streaming/buf.lock +++ b/examples/sse-streaming/buf.lock @@ -2,8 +2,8 @@ version: v2 deps: - name: buf.build/bufbuild/protovalidate - commit: 50325440f8f24053b047484a6bf60b76 - digest: b5:74cb6f5c0853c3c10aafc701614194bbd63326bdb8ef4068214454b8894b03ba4113e04b3a33a8321cdf05336e37db4dc14a5e2495db8462566914f36086ba31 + commit: 435963d1631043e694e56e6bcc3c79c3 + digest: b5:f4ea07ad2dd94bd7243562f9908b9fb104feef8076040c89d9f7c1dedc074de4d4ce2b997686ef4400f3eccb765a7cfc20ed4acdd70b9a3699351245c61dba97 - name: buf.build/sebmelki/sebuf commit: b5f679ca6c5f4f148c3414adfea268d3 digest: b5:007be9b0418e0284f34578307ba516b56259a29932bfd3b662515d3107d3886e2d47592edc580e4a520155c3ebed7b55375b90ac0fa327ccb603f17bf84d7eb1 diff --git a/examples/sse-streaming/go.mod b/examples/sse-streaming/go.mod index 06c5af89..e5f5f3e9 100644 --- a/examples/sse-streaming/go.mod +++ b/examples/sse-streaming/go.mod @@ -9,7 +9,7 @@ require ( ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/google/cel-go v0.27.0 // indirect diff --git a/examples/sse-streaming/go.sum b/examples/sse-streaming/go.sum index 91380fe4..2437ab9f 100644 --- a/examples/sse-streaming/go.sum +++ b/examples/sse-streaming/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE= buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= diff --git a/examples/ts-client-demo/go.mod b/examples/ts-client-demo/go.mod index adea3ae4..9b336ebf 100644 --- a/examples/ts-client-demo/go.mod +++ b/examples/ts-client-demo/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/ts-client-demo go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-00010101000000-000000000000 google.golang.org/protobuf v1.36.11 diff --git a/examples/validation-showcase/go.mod b/examples/validation-showcase/go.mod index d6c5c139..207d9453 100644 --- a/examples/validation-showcase/go.mod +++ b/examples/validation-showcase/go.mod @@ -3,7 +3,7 @@ module github.com/SebastienMelki/sebuf/examples/validation-showcase go 1.26.0 require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 buf.build/go/protovalidate v0.14.0 github.com/SebastienMelki/sebuf v0.0.0-20250818125809-ff61bcf670dd google.golang.org/protobuf v1.36.11 diff --git a/internal/clientgen/bytes_encoding.go b/internal/clientgen/bytes_encoding.go deleted file mode 100644 index 08007dd4..00000000 --- a/internal/clientgen/bytes_encoding.go +++ /dev/null @@ -1,325 +0,0 @@ -package clientgen - -import ( - "strings" - - "google.golang.org/protobuf/compiler/protogen" - "google.golang.org/protobuf/reflect/protoreflect" - - "github.com/SebastienMelki/sebuf/http" - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// BytesEncodingContext holds information about messages that need custom JSON encoding -// for bytes fields with non-default encoding (HEX, BASE64_RAW, BASE64URL, BASE64URL_RAW). -type BytesEncodingContext struct { - // Message is the message that needs custom marshal/unmarshal - Message *protogen.Message - // BytesFields are fields with non-default bytes_encoding annotation - BytesFields []*BytesEncodingFieldInfo -} - -// BytesEncodingFieldInfo holds field info with its bytes encoding setting. -type BytesEncodingFieldInfo struct { - Field *protogen.Field - Encoding http.BytesEncoding -} - -// hasBytesEncodingFields returns true if any bytes field in the message has non-default encoding. -func hasBytesEncodingFields(message *protogen.Message) bool { - for _, field := range message.Fields { - if field.Desc.Kind() == protoreflect.BytesKind && annotations.HasBytesEncodingAnnotation(field) { - return true - } - } - return false -} - -// getBytesEncodingFields returns all bytes fields with non-default encoding annotation. -func getBytesEncodingFields(message *protogen.Message) []*BytesEncodingFieldInfo { - var fields []*BytesEncodingFieldInfo - for _, field := range message.Fields { - if field.Desc.Kind() == protoreflect.BytesKind && annotations.HasBytesEncodingAnnotation(field) { - fields = append(fields, &BytesEncodingFieldInfo{ - Field: field, - Encoding: annotations.GetBytesEncoding(field), - }) - } - } - return fields -} - -// collectBytesEncodingContext analyzes messages in a file and collects bytes encoding information. -func collectBytesEncodingContext(file *protogen.File) []*BytesEncodingContext { - var contexts []*BytesEncodingContext - collectBytesEncodingMessages(file.Messages, &contexts) - return contexts -} - -// collectBytesEncodingMessages recursively collects messages with non-default bytes encoding fields. -func collectBytesEncodingMessages(messages []*protogen.Message, contexts *[]*BytesEncodingContext) { - for _, msg := range messages { - if hasBytesEncodingFields(msg) { - *contexts = append(*contexts, &BytesEncodingContext{ - Message: msg, - BytesFields: getBytesEncodingFields(msg), - }) - } - // Check nested messages - collectBytesEncodingMessages(msg.Messages, contexts) - } -} - -// validateBytesEncodingAnnotations validates all bytes_encoding annotations in a file. -// Returns the first validation error encountered, or nil if all valid. -func validateBytesEncodingAnnotations(file *protogen.File) error { - return validateBytesEncodingInMessages(file.Messages) -} - -// validateBytesEncodingInMessages recursively validates bytes_encoding annotations. -func validateBytesEncodingInMessages(messages []*protogen.Message) error { - for _, msg := range messages { - for _, field := range msg.Fields { - if err := annotations.ValidateBytesEncodingAnnotation(field, msg.GoIdent.GoName); err != nil { - return err - } - } - if err := validateBytesEncodingInMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -// generateBytesEncodingFile generates the *_bytes_encoding.pb.go file if needed. -func (g *Generator) generateBytesEncodingFile(file *protogen.File) error { - if err := validateBytesEncodingAnnotations(file); err != nil { - return err - } - - contexts := collectBytesEncodingContext(file) - if len(contexts) == 0 { - return nil - } - - filename := file.GeneratedFilenamePrefix + "_bytes_encoding.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeEncodingHeader(gf, file) - g.writeBytesEncodingImports(gf, contexts) - - for _, ctx := range contexts { - g.generateBytesMarshalJSON(gf, ctx) - g.generateBytesUnmarshalJSON(gf, ctx) - } - - return nil -} - -// writeBytesEncodingImports writes the imports needed for bytes encoding. -func (g *Generator) writeBytesEncodingImports(gf *protogen.GeneratedFile, contexts []*BytesEncodingContext) { - needsBase64 := false - needsHex := false - - for _, ctx := range contexts { - for _, f := range ctx.BytesFields { - //exhaustive:ignore -- only non-default encodings need imports; UNSPECIFIED/BASE64 are filtered out - switch f.Encoding { - case http.BytesEncoding_BYTES_ENCODING_BASE64_RAW, - http.BytesEncoding_BYTES_ENCODING_BASE64URL, - http.BytesEncoding_BYTES_ENCODING_BASE64URL_RAW: - needsBase64 = true - case http.BytesEncoding_BYTES_ENCODING_HEX: - needsHex = true - needsBase64 = true // UnmarshalJSON re-encodes to standard base64 for protojson - default: - // No extra import needed - } - } - } - - gf.P("import (") - if needsBase64 { - gf.P(`"encoding/base64"`) - } - if needsHex { - gf.P(`"encoding/hex"`) - } - gf.P(`"encoding/json"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// generateBytesMarshalJSON generates a MarshalJSON method that encodes bytes fields -// with the configured encoding (HEX, BASE64_RAW, BASE64URL, BASE64URL_RAW). -// This is identical to the httpgen implementation to ensure server/client consistency. -// -//nolint:dupl // Code generation patterns naturally have similar structure across encoding types -func (g *Generator) generateBytesMarshalJSON(gf *protogen.GeneratedFile, ctx *BytesEncodingContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.BytesFields { - fieldNames = append(fieldNames, string(f.Field.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles bytes_encoding fields: ", strings.Join(fieldNames, ", ")) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization (handles all other fields correctly)") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to modify bytes-encoded fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - for _, fieldInfo := range ctx.BytesFields { - g.generateBytesFieldMarshal(gf, fieldInfo) - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateBytesFieldMarshal generates marshaling code for a single bytes field. -func (g *Generator) generateBytesFieldMarshal(gf *protogen.GeneratedFile, fieldInfo *BytesEncodingFieldInfo) { - field := fieldInfo.Field - goName := field.GoName - jsonName := field.Desc.JSONName() - encoding := fieldInfo.Encoding - - gf.P("// Encode ", field.Desc.Name(), " with ", encoding.String()) - gf.P("if len(x.", goName, ") > 0 {") - - //exhaustive:ignore -- only non-default encodings reach here; UNSPECIFIED/BASE64 are filtered by hasBytesEncodingFields - switch encoding { - case http.BytesEncoding_BYTES_ENCODING_HEX: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(hex.EncodeToString(x.`, goName, `))`) - case http.BytesEncoding_BYTES_ENCODING_BASE64_RAW: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.RawStdEncoding.EncodeToString(x.`, goName, `))`) - case http.BytesEncoding_BYTES_ENCODING_BASE64URL: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.URLEncoding.EncodeToString(x.`, goName, `))`) - case http.BytesEncoding_BYTES_ENCODING_BASE64URL_RAW: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.RawURLEncoding.EncodeToString(x.`, goName, `))`) - default: - // Should not be reached since we only collect non-default encodings - } - - gf.P("}") - gf.P() -} - -// generateBytesUnmarshalJSON generates an UnmarshalJSON method that decodes bytes fields -// from the configured encoding back to standard base64 for protojson. -// This is identical to the httpgen implementation to ensure server/client consistency. -// -//nolint:dupl // Code generation patterns naturally have similar structure across encoding types -func (g *Generator) generateBytesUnmarshalJSON(gf *protogen.GeneratedFile, ctx *BytesEncodingContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.BytesFields { - fieldNames = append(fieldNames, string(f.Field.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles bytes_encoding fields: ", strings.Join(fieldNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// Parse the raw JSON to extract bytes-encoded fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - for _, fieldInfo := range ctx.BytesFields { - g.generateBytesFieldUnmarshal(gf, fieldInfo) - } - - gf.P("// Re-marshal with standard base64 values for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("// Use protojson to unmarshal the rest") - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateBytesFieldUnmarshal generates unmarshaling code for a single bytes field. -// It decodes from the configured encoding, then re-encodes as standard base64 for protojson. -func (g *Generator) generateBytesFieldUnmarshal(gf *protogen.GeneratedFile, fieldInfo *BytesEncodingFieldInfo) { - field := fieldInfo.Field - jsonName := field.Desc.JSONName() - encoding := fieldInfo.Encoding - - gf.P("// Decode ", field.Desc.Name(), " from ", encoding.String(), " to standard base64") - gf.P(`if v, ok := raw["`, jsonName, `"]; ok {`) - gf.P("var s string") - gf.P("if err := json.Unmarshal(v, &s); err == nil {") - - //exhaustive:ignore -- only non-default encodings reach here; UNSPECIFIED/BASE64 are filtered by hasBytesEncodingFields - switch encoding { - case http.BytesEncoding_BYTES_ENCODING_HEX: - gf.P("decoded, decErr := hex.DecodeString(s)") - gf.P("if decErr == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded))`) - gf.P("}") - case http.BytesEncoding_BYTES_ENCODING_BASE64_RAW: - gf.P("decoded, decErr := base64.RawStdEncoding.DecodeString(s)") - gf.P("if decErr == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded))`) - gf.P("}") - case http.BytesEncoding_BYTES_ENCODING_BASE64URL: - gf.P("decoded, decErr := base64.URLEncoding.DecodeString(s)") - gf.P("if decErr == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded))`) - gf.P("}") - case http.BytesEncoding_BYTES_ENCODING_BASE64URL_RAW: - gf.P("decoded, decErr := base64.RawURLEncoding.DecodeString(s)") - gf.P("if decErr == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded))`) - gf.P("}") - default: - // Should not be reached - } - - gf.P("}") - gf.P("}") - gf.P() -} diff --git a/internal/clientgen/empty_behavior.go b/internal/clientgen/empty_behavior.go deleted file mode 100644 index cc8f61f1..00000000 --- a/internal/clientgen/empty_behavior.go +++ /dev/null @@ -1,275 +0,0 @@ -package clientgen - -import ( - "strings" - - "google.golang.org/protobuf/compiler/protogen" - - "github.com/SebastienMelki/sebuf/http" - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// EmptyBehaviorContext holds information about messages that need custom JSON encoding -// for empty_behavior fields. -type EmptyBehaviorContext struct { - // Message is the message that needs custom marshal/unmarshal - Message *protogen.Message - // EmptyBehaviorFields are fields with empty_behavior annotation - EmptyBehaviorFields []*EmptyBehaviorFieldInfo -} - -// EmptyBehaviorFieldInfo holds field info with its empty behavior setting. -type EmptyBehaviorFieldInfo struct { - Field *protogen.Field - Behavior http.EmptyBehavior -} - -// hasEmptyBehaviorFields returns true if any message field has empty_behavior annotation. -func hasEmptyBehaviorFields(message *protogen.Message) bool { - for _, field := range message.Fields { - if annotations.HasEmptyBehaviorAnnotation(field) { - return true - } - } - return false -} - -// getEmptyBehaviorFields returns all message fields with empty_behavior annotation. -func getEmptyBehaviorFields(message *protogen.Message) []*EmptyBehaviorFieldInfo { - var fields []*EmptyBehaviorFieldInfo - for _, field := range message.Fields { - if annotations.HasEmptyBehaviorAnnotation(field) { - fields = append(fields, &EmptyBehaviorFieldInfo{ - Field: field, - Behavior: annotations.GetEmptyBehavior(field), - }) - } - } - return fields -} - -// collectEmptyBehaviorContext analyzes messages in a file and collects empty_behavior info. -func collectEmptyBehaviorContext(file *protogen.File) []*EmptyBehaviorContext { - var contexts []*EmptyBehaviorContext - collectEmptyBehaviorMessages(file.Messages, &contexts) - return contexts -} - -// collectEmptyBehaviorMessages recursively collects messages with empty_behavior fields. -func collectEmptyBehaviorMessages(messages []*protogen.Message, contexts *[]*EmptyBehaviorContext) { - for _, msg := range messages { - if hasEmptyBehaviorFields(msg) { - *contexts = append(*contexts, &EmptyBehaviorContext{ - Message: msg, - EmptyBehaviorFields: getEmptyBehaviorFields(msg), - }) - } - // Check nested messages - collectEmptyBehaviorMessages(msg.Messages, contexts) - } -} - -// validateEmptyBehaviorAnnotations validates all empty_behavior annotations in a file. -func validateEmptyBehaviorAnnotations(file *protogen.File) error { - return validateEmptyBehaviorInMessages(file.Messages) -} - -// validateEmptyBehaviorInMessages recursively validates empty_behavior annotations. -func validateEmptyBehaviorInMessages(messages []*protogen.Message) error { - for _, msg := range messages { - for _, field := range msg.Fields { - if err := annotations.ValidateEmptyBehaviorAnnotation(field, msg.GoIdent.GoName); err != nil { - return err - } - } - if err := validateEmptyBehaviorInMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -// generateEmptyBehaviorEncodingFile generates the *_empty_behavior.pb.go file if needed. -func (g *Generator) generateEmptyBehaviorEncodingFile(file *protogen.File) error { - if err := validateEmptyBehaviorAnnotations(file); err != nil { - return err - } - - contexts := collectEmptyBehaviorContext(file) - if len(contexts) == 0 { - return nil - } - - filename := file.GeneratedFilenamePrefix + "_empty_behavior.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeHeader(gf, file) - g.writeEmptyBehaviorImports(gf) - - for _, ctx := range contexts { - g.generateEmptyBehaviorMarshalJSON(gf, ctx) - g.generateEmptyBehaviorUnmarshalJSON(gf, ctx) - } - - return nil -} - -// writeEmptyBehaviorImports writes the imports needed for empty_behavior encoding. -func (g *Generator) writeEmptyBehaviorImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P(`"encoding/json"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(`"google.golang.org/protobuf/proto"`) - gf.P(")") - gf.P() -} - -// generateEmptyBehaviorMarshalJSON generates MarshalJSON that handles empty message fields. -// -//nolint:dupl // Code generation patterns naturally have similar structure across encoding types -func (g *Generator) generateEmptyBehaviorMarshalJSON(gf *protogen.GeneratedFile, ctx *EmptyBehaviorContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.EmptyBehaviorFields { - fieldNames = append(fieldNames, string(f.Field.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles empty_behavior fields: ", strings.Join(fieldNames, ", ")) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to handle empty_behavior fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - // For each empty_behavior field, apply the configured behavior - for _, fieldInfo := range ctx.EmptyBehaviorFields { - g.generateEmptyBehaviorFieldMarshal(gf, fieldInfo) - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateEmptyBehaviorFieldMarshal generates marshaling code for a single empty_behavior field. -func (g *Generator) generateEmptyBehaviorFieldMarshal(gf *protogen.GeneratedFile, fieldInfo *EmptyBehaviorFieldInfo) { - field := fieldInfo.Field - jsonName := field.Desc.JSONName() - goName := field.GoName - behavior := fieldInfo.Behavior - - gf.P("// Handle empty_behavior for field: ", field.Desc.Name()) - gf.P("if x.", goName, " != nil && proto.Size(x.", goName, ") == 0 {") - - switch behavior { - case http.EmptyBehavior_EMPTY_BEHAVIOR_NULL: - gf.P("// EMPTY_BEHAVIOR_NULL: serialize empty message as null") - gf.P(`raw["`, jsonName, `"] = []byte("null")`) - case http.EmptyBehavior_EMPTY_BEHAVIOR_OMIT: - gf.P("// EMPTY_BEHAVIOR_OMIT: remove field when message is empty") - gf.P(`delete(raw, "`, jsonName, `")`) - case http.EmptyBehavior_EMPTY_BEHAVIOR_PRESERVE: - gf.P("// EMPTY_BEHAVIOR_PRESERVE: keep as {} (default protojson behavior)") - gf.P("// No action needed - protojson already emits {}") - case http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED: - // UNSPECIFIED treated as PRESERVE - gf.P("// EMPTY_BEHAVIOR_UNSPECIFIED: use default (PRESERVE)") - } - - gf.P("}") - gf.P() -} - -// generateEmptyBehaviorUnmarshalJSON generates UnmarshalJSON that handles empty_behavior. -// For NULL behavior, accept null as empty message. For OMIT, missing field means empty. -func (g *Generator) generateEmptyBehaviorUnmarshalJSON(gf *protogen.GeneratedFile, ctx *EmptyBehaviorContext) { - msgName := ctx.Message.GoIdent.GoName - - // Check if any field has NULL behavior (needs special handling) - hasNullBehavior := false - for _, f := range ctx.EmptyBehaviorFields { - if f.Behavior == http.EmptyBehavior_EMPTY_BEHAVIOR_NULL { - hasNullBehavior = true - break - } - } - - if !hasNullBehavior { - // No special unmarshal needed for PRESERVE/OMIT - return - } - - var fieldNames []string - for _, f := range ctx.EmptyBehaviorFields { - fieldNames = append(fieldNames, string(f.Field.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles empty_behavior fields: ", strings.Join(fieldNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// Parse to check for explicit null values on empty_behavior=NULL fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - // For NULL fields, convert null to empty object for protojson - for _, fieldInfo := range ctx.EmptyBehaviorFields { - if fieldInfo.Behavior == http.EmptyBehavior_EMPTY_BEHAVIOR_NULL { - field := fieldInfo.Field - jsonName := field.Desc.JSONName() - - gf.P("// Handle empty_behavior=NULL: convert null to {} for protojson") - gf.P(`if rawVal, ok := raw["`, jsonName, `"]; ok && string(rawVal) == "null" {`) - gf.P(`raw["`, jsonName, `"] = []byte("{}")`) - gf.P("}") - gf.P() - } - } - - gf.P("// Re-marshal for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} diff --git a/internal/clientgen/encoding.go b/internal/clientgen/encoding.go deleted file mode 100644 index 302093b8..00000000 --- a/internal/clientgen/encoding.go +++ /dev/null @@ -1,772 +0,0 @@ -package clientgen - -import ( - "fmt" - "io" - "os" - "strings" - - "google.golang.org/protobuf/compiler/protogen" - - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// Constants for proto field kinds used in int64 encoding detection. -const ( - kindInt64 = "int64" - kindSint64 = "sint64" - kindSfixed64 = "sfixed64" - kindUint64 = "uint64" - kindFixed64 = "fixed64" -) - -// Int64EncodingContext holds information about messages that need custom JSON encoding -// for int64/uint64 fields with NUMBER encoding. -type Int64EncodingContext struct { - // Message is the message that needs custom marshal/unmarshal - Message *protogen.Message - // NumberFields are fields with int64_encoding=NUMBER annotation - NumberFields []*protogen.Field - // NestedFields are message-type fields whose type transitively reaches NUMBER encoding. - // A message can need both: patching its own fields is not enough when it also holds a - // child that reaches an annotated field, because protojson owns the child's bytes. - NestedFields []*protogen.Field -} - -// hasInt64NumberFields returns true if any int64/uint64 field in the message has NUMBER encoding. -// This checks direct fields only (not nested messages). -func hasInt64NumberFields(message *protogen.Message) bool { - for _, field := range message.Fields { - if isInt64Type(field) && annotations.IsInt64NumberEncoding(field) { - return true - } - } - return false -} - -// getInt64NumberFields returns all int64/uint64 fields that have NUMBER encoding. -func getInt64NumberFields(message *protogen.Message) []*protogen.Field { - var fields []*protogen.Field - for _, field := range message.Fields { - if isInt64Type(field) && annotations.IsInt64NumberEncoding(field) { - fields = append(fields, field) - } - } - return fields -} - -// isInt64Type returns true if the field is an int64 or uint64 type (including variants). -func isInt64Type(field *protogen.Field) bool { - kind := field.Desc.Kind().String() - switch kind { - case kindInt64, kindSint64, kindSfixed64, kindUint64, kindFixed64: - return true - default: - return false - } -} - -// collectInt64EncodingContext analyzes messages in a file and collects int64 encoding information. -func collectInt64EncodingContext(file *protogen.File) []*Int64EncodingContext { - var contexts []*Int64EncodingContext - collectInt64EncodingMessages(file.Messages, &contexts) - return contexts -} - -// collectInt64EncodingMessages recursively collects messages with int64 NUMBER encoding fields. -// A message with direct NUMBER fields also carries its nested fields: patching only its own -// fields would leave a child that reaches an annotated field serialized by protojson, so the -// child's int64 would stay a quoted string. The self-referential Node{Node child; int64 id -// [NUMBER]} shape is the smallest case where both are needed at once. -func collectInt64EncodingMessages(messages []*protogen.Message, contexts *[]*Int64EncodingContext) { - for _, msg := range messages { - if msg.Desc.IsMapEntry() { - continue - } - if hasInt64NumberFields(msg) { - *contexts = append(*contexts, &Int64EncodingContext{ - Message: msg, - NumberFields: getInt64NumberFields(msg), - NestedFields: getTransitiveInt64NestedFields(msg), - }) - } - // Check nested messages - collectInt64EncodingMessages(msg.Messages, contexts) - } -} - -// getTransitiveInt64NestedFields returns the message-type fields (singular or repeated, never -// map) whose type transitively reaches an int64 NUMBER field. -func getTransitiveInt64NestedFields(msg *protogen.Message) []*protogen.Field { - var fields []*protogen.Field - for _, field := range msg.Fields { - if fieldTransitivelyHasInt64Number(field) { - fields = append(fields, field) - } - } - return fields -} - -// Int64WrapperContext holds information about messages that contain nested messages -// with int64 NUMBER encoding — requiring transitive MarshalJSON/UnmarshalJSON. -type Int64WrapperContext struct { - // Message is the wrapper message that needs transitive marshal/unmarshal - Message *protogen.Message - // NestedFields are message-type fields whose type transitively reaches NUMBER encoding - NestedFields []*protogen.Field -} - -// messageTransitivelyHasInt64Number reports whether msg, or any message it nests (singular or -// repeated) at any depth, has a direct int64/uint64 field with int64_encoding=NUMBER. Walking -// field.Message resolves across proto files, so imported types are covered — a per-file name set -// is not (issue #217). The visited set guards against recursive message definitions. -// -// Unlike messageTransitivelyHasCustomEnum, this deliberately does NOT descend through map values. -// The emitters cannot re-serialize a map field, so counting a map path as "reachable" would mark a -// parent as a wrapper whose child never gets a marshaler: the parent falls back to protojson and -// the map values stay quoted, and the useless wrapper can trip the MarshalJSON conflict check -// against an annotation the message legitimately carries. The predicate stays aligned with what -// the emitters can actually traverse. -func messageTransitivelyHasInt64Number(msg *protogen.Message, visited map[string]bool) bool { - if msg == nil { - return false - } - key := string(msg.Desc.FullName()) - if visited[key] { - return false - } - visited[key] = true - - if hasInt64NumberFields(msg) { - return true - } - - for _, field := range msg.Fields { - if child := nestedMessageChild(field); child != nil && - messageTransitivelyHasInt64Number(child, visited) { - return true - } - } - return false -} - -// fieldTransitivelyHasInt64Number reports whether a message field's type (singular or repeated, -// never a map) reaches an int64 NUMBER field at any depth. -func fieldTransitivelyHasInt64Number(field *protogen.Field) bool { - child := nestedMessageChild(field) - if child == nil { - return false - } - return messageTransitivelyHasInt64Number(child, map[string]bool{}) -} - -// collectWrapperContexts finds messages that contain fields whose message type transitively -// reaches int64 NUMBER encoding, at any depth and in any file. -func collectWrapperContexts(file *protogen.File) []*Int64WrapperContext { - var contexts []*Int64WrapperContext - collectWrapperMessages(file.Messages, &contexts) - return contexts -} - -// collectWrapperMessages recursively collects wrapper messages. -func collectWrapperMessages( - messages []*protogen.Message, - contexts *[]*Int64WrapperContext, -) { - for _, msg := range messages { - // Skip synthetic proto3 map-entry messages: proto3 map fields create implicit - // nested message types (e.g. Foo_BarEntry) that are never emitted as exported Go - // struct types by protoc-gen-go. - if msg.Desc.IsMapEntry() { - continue - } - - // Skip messages that already have direct NUMBER fields (handled by existing logic) - if hasInt64NumberFields(msg) { - collectWrapperMessages(msg.Messages, contexts) - continue - } - - // Map fields are excluded: the emitted wrapper cannot traverse them. - nestedFields := getTransitiveInt64NestedFields(msg) - - if len(nestedFields) > 0 { - *contexts = append(*contexts, &Int64WrapperContext{ - Message: msg, - NestedFields: nestedFields, - }) - } - - collectWrapperMessages(msg.Messages, contexts) - } -} - -// checkInt64WrapperMarshalJSONConflict returns an error if a message that needs a transitive -// int64 wrapper marshaler also carries another MarshalJSON-generating annotation. Only one -// feature can own a message's MarshalJSON/UnmarshalJSON methods, so combining them would -// produce duplicate method declarations. Fail fast with a clear message (matching -// enum/flatten/oneof behavior). -func checkInt64WrapperMarshalJSONConflict(msg *protogen.Message) error { - var conflicts []string - - if hasNullableFields(msg) { - conflicts = append(conflicts, "nullable") - } - if hasEmptyBehaviorFields(msg) { - conflicts = append(conflicts, "empty_behavior") - } - if hasTimestampFormatFields(msg) { - conflicts = append(conflicts, "timestamp_format") - } - if hasBytesEncodingFields(msg) { - conflicts = append(conflicts, "bytes_encoding") - } - if hasCustomEnumFields(msg) { - conflicts = append(conflicts, "enum_value") - } - if hasFlattenFields(msg) { - conflicts = append(conflicts, "flatten") - } - if hasOneofDiscriminator(msg) { - conflicts = append(conflicts, "oneof_config") - } - - if len(conflicts) > 0 { - return fmt.Errorf( - "message %s: nested int64_encoding=NUMBER requires MarshalJSON but conflicts with %s "+ - "(also requires MarshalJSON) -- "+ - "only one MarshalJSON-generating feature is supported per message", - msg.GoIdent.GoName, strings.Join(conflicts, ", "), - ) - } - - return nil -} - -// printInt64PrecisionWarning prints a generation-time warning for fields with NUMBER encoding. -func printInt64PrecisionWarning(w io.Writer, field *protogen.Field, messageName string) { - _, _ = w.Write([]byte( - "Warning: Field " + messageName + "." + string(field.Desc.Name()) + - " uses int64_encoding=NUMBER. Values > 2^53 may lose precision in JavaScript.\n", - )) -} - -// generateInt64EncodingFile generates the *_encoding.pb.go file if needed. -func (g *Generator) generateInt64EncodingFile(file *protogen.File) error { - contexts := collectInt64EncodingContext(file) - - // Collect wrapper messages whose fields transitively reach messages with NUMBER fields - wrapperContexts := collectWrapperContexts(file) - - // If no messages need int64 encoding, skip generation - if len(contexts) == 0 && len(wrapperContexts) == 0 { - return nil - } - - // A wrapper message must own MarshalJSON exclusively — fail fast if another - // annotation on the same message also generates it. - for _, ctx := range wrapperContexts { - if err := checkInt64WrapperMarshalJSONConflict(ctx.Message); err != nil { - return err - } - } - - filename := file.GeneratedFilenamePrefix + "_encoding.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeEncodingHeader(gf, file) - g.writeInt64EncodingImports(gf, len(contexts) > 0) - - // Generate marshal/unmarshal for messages with direct NUMBER fields - for _, ctx := range contexts { - for _, field := range ctx.NumberFields { - printInt64PrecisionWarning(os.Stderr, field, ctx.Message.GoIdent.GoName) - } - - g.generateInt64MarshalJSON(gf, ctx) - g.generateInt64UnmarshalJSON(gf, ctx) - } - - // Generate transitive marshal/unmarshal for wrapper messages - for _, ctx := range wrapperContexts { - g.generateWrapperMarshalJSON(gf, ctx) - g.generateWrapperUnmarshalJSON(gf, ctx) - } - - return nil -} - -func (g *Generator) writeEncodingHeader(gf *protogen.GeneratedFile, file *protogen.File) { - gf.P("// Code generated by protoc-gen-go-client. DO NOT EDIT.") - gf.P("// source: ", file.Desc.Path()) - gf.P() - gf.P("package ", file.GoPackageName) - gf.P() -} - -// writeInt64EncodingImports emits the import block. strconv is only used by the direct-field -// unmarshalers, so a file holding nothing but transitive wrappers — which happens whenever the -// annotated message is declared in an imported file (issue #217) — must not import it, or the -// generated code fails to compile with "strconv imported and not used". -func (g *Generator) writeInt64EncodingImports(gf *protogen.GeneratedFile, needsStrconv bool) { - gf.P("import (") - gf.P(`"encoding/json"`) - if needsStrconv { - gf.P(`"strconv"`) - } - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// generateInt64MarshalJSON generates a MarshalJSON method that encodes int64 NUMBER fields as numbers. -// This is identical to the httpgen implementation to ensure server/client consistency. -func (g *Generator) generateInt64MarshalJSON(gf *protogen.GeneratedFile, ctx *Int64EncodingContext) { - msgName := ctx.Message.GoIdent.GoName - - // Build list of NUMBER field names for the comment - var numberFieldNames []string - for _, f := range ctx.NumberFields { - numberFieldNames = append(numberFieldNames, string(f.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles int64_encoding=NUMBER fields: ", strings.Join(numberFieldNames, ", ")) - if len(ctx.NestedFields) > 0 { - var nestedFieldNames []string - for _, f := range ctx.NestedFields { - nestedFieldNames = append(nestedFieldNames, string(f.Desc.Name())) - } - gf.P( - "// It also re-marshals nested messages that reach int64_encoding=NUMBER fields: ", - strings.Join(nestedFieldNames, ", "), - ) - } - gf.P("// Warning: int64 fields with NUMBER encoding may lose precision for values > 2^53 in JavaScript.") - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - // First, marshal using protojson to get the base JSON - gf.P("// Use protojson for base serialization (handles all other fields correctly)") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - // Unmarshal into a map to modify the NUMBER fields - gf.P("// Parse into a map to modify NUMBER-encoded int64 fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - // For each NUMBER field, replace the string representation with a number - for _, field := range ctx.NumberFields { - g.generateInt64FieldMarshal(gf, field) - } - - // Patching this message's own fields is not enough: a child that reaches an annotated field - // is still owned by protojson in the base output, so its int64 would stay a quoted string. - emitNestedFieldsMarshal(gf, ctx.NestedFields) - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateInt64FieldMarshal generates code to marshal a single int64 NUMBER field. -func (g *Generator) generateInt64FieldMarshal(gf *protogen.GeneratedFile, field *protogen.Field) { - fieldName := field.GoName - jsonName := field.Desc.JSONName() - - if field.Desc.IsList() { - // Handle repeated int64 fields - g.generateRepeatedInt64FieldMarshal(gf, fieldName, jsonName) - } else { - // Handle singular int64 field - g.generateSingularInt64FieldMarshal(gf, fieldName, jsonName) - } -} - -// generateSingularInt64FieldMarshal generates marshal code for a singular int64 NUMBER field. -func (g *Generator) generateSingularInt64FieldMarshal( - gf *protogen.GeneratedFile, - fieldName, jsonName string, -) { - gf.P("// Convert ", fieldName, " from string to number") - gf.P("if x.", fieldName, " != 0 {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(x.`, fieldName, `)`) - gf.P("} else {") - gf.P("// Remove the field if zero (proto3 default behavior)") - gf.P(`delete(raw, "`, jsonName, `")`) - gf.P("}") - gf.P() -} - -// generateRepeatedInt64FieldMarshal generates marshal code for a repeated int64 NUMBER field. -func (g *Generator) generateRepeatedInt64FieldMarshal( - gf *protogen.GeneratedFile, - fieldName, jsonName string, -) { - gf.P("// Convert repeated ", fieldName, " from strings to numbers") - gf.P("if len(x.", fieldName, ") > 0 {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(x.`, fieldName, `)`) - gf.P("}") - gf.P() -} - -// generateInt64UnmarshalJSON generates an UnmarshalJSON method that decodes int64 NUMBER fields from numbers. -// This is identical to the httpgen implementation to ensure server/client consistency. -func (g *Generator) generateInt64UnmarshalJSON(gf *protogen.GeneratedFile, ctx *Int64EncodingContext) { - msgName := ctx.Message.GoIdent.GoName - - // Build list of NUMBER field names for the comment - var numberFieldNames []string - for _, f := range ctx.NumberFields { - numberFieldNames = append(numberFieldNames, string(f.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles int64_encoding=NUMBER fields: ", strings.Join(numberFieldNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// First, parse the raw JSON to extract NUMBER-encoded fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - // For each NUMBER field, convert number to string for protojson - for _, field := range ctx.NumberFields { - g.generateInt64FieldUnmarshal(gf, field) - } - - // Nested children need the same treatment on the way in: protojson would reject the bare - // JSON numbers their own annotated fields are encoded as. - emitNestedFieldsUnmarshal(gf, ctx.NestedFields) - - gf.P("// Re-marshal to JSON with string values for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("// Use protojson to unmarshal the rest") - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateInt64FieldUnmarshal generates code to unmarshal a single int64 NUMBER field. -func (g *Generator) generateInt64FieldUnmarshal(gf *protogen.GeneratedFile, field *protogen.Field) { - jsonName := field.Desc.JSONName() - - if field.Desc.IsList() { - // Handle repeated int64 fields - g.generateRepeatedInt64FieldUnmarshal(gf, field, jsonName) - } else { - // Handle singular int64 field - g.generateSingularInt64FieldUnmarshal(gf, field, jsonName) - } -} - -// generateSingularInt64FieldUnmarshal generates unmarshal code for a singular int64 NUMBER field. -func (g *Generator) generateSingularInt64FieldUnmarshal( - gf *protogen.GeneratedFile, - field *protogen.Field, - jsonName string, -) { - isUnsigned := isUint64Type(field) - - gf.P("// Convert ", jsonName, " from number to string for protojson") - gf.P(`if rawVal, ok := raw["`, jsonName, `"]; ok {`) - if isUnsigned { - gf.P("var num uint64") - gf.P("if err := json.Unmarshal(rawVal, &num); err == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(strconv.FormatUint(num, 10))`) - } else { - gf.P("var num int64") - gf.P("if err := json.Unmarshal(rawVal, &num); err == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(strconv.FormatInt(num, 10))`) - } - gf.P("}") - gf.P("}") - gf.P() -} - -// generateRepeatedInt64FieldUnmarshal generates unmarshal code for a repeated int64 NUMBER field. -func (g *Generator) generateRepeatedInt64FieldUnmarshal( - gf *protogen.GeneratedFile, - field *protogen.Field, - jsonName string, -) { - isUnsigned := isUint64Type(field) - - gf.P("// Convert repeated ", jsonName, " from numbers to strings for protojson") - gf.P(`if rawVal, ok := raw["`, jsonName, `"]; ok {`) - if isUnsigned { - gf.P("var nums []uint64") - gf.P("if err := json.Unmarshal(rawVal, &nums); err == nil {") - gf.P("strs := make([]string, len(nums))") - gf.P("for i, n := range nums {") - gf.P("strs[i] = strconv.FormatUint(n, 10)") - gf.P("}") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(strs)`) - } else { - gf.P("var nums []int64") - gf.P("if err := json.Unmarshal(rawVal, &nums); err == nil {") - gf.P("strs := make([]string, len(nums))") - gf.P("for i, n := range nums {") - gf.P("strs[i] = strconv.FormatInt(n, 10)") - gf.P("}") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(strs)`) - } - gf.P("}") - gf.P("}") - gf.P() -} - -// isUint64Type returns true if the field is an unsigned 64-bit type. -func isUint64Type(field *protogen.Field) bool { - kind := field.Desc.Kind().String() - return kind == kindUint64 || kind == kindFixed64 -} - -// emitNestedFieldsMarshal emits the re-serialization of message-typed fields whose type reaches -// an int64 NUMBER field, forwarding opts so the child's MarshalJSONSebuf is invoked. Shared by the -// direct and wrapper marshalers: a message with its own NUMBER fields still needs this for its -// children. Assumes `raw`, `opts` and `err` are in scope. -func emitNestedFieldsMarshal(gf *protogen.GeneratedFile, nestedFields []*protogen.Field) { - for _, field := range nestedFields { - jsonName := field.Desc.JSONName() - if field.Desc.IsList() { - // Repeated field: per-element opts forwarding so child MarshalJSONSebuf receives opts. - gf.P("// Re-serialize repeated \"", jsonName, "\" forwarding opts to each element") - gf.P("if len(x.", field.GoName, ") > 0 {") - gf.P("items := make([]json.RawMessage, 0, len(x.", field.GoName, "))") - gf.P("for _, item := range x.", field.GoName, " {") - gf.P( - "if m, ok := any(item).(interface{ MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) }); ok {", - ) - gf.P("itemData, itemErr := m.MarshalJSONSebuf(opts)") - gf.P("if itemErr != nil {") - gf.P("return nil, itemErr") - gf.P("}") - gf.P("items = append(items, itemData)") - gf.P("} else {") - gf.P("itemData, itemErr := opts.Marshal(item)") - gf.P("if itemErr != nil {") - gf.P("return nil, itemErr") - gf.P("}") - gf.P("items = append(items, itemData)") - gf.P("}") - gf.P("}") - gf.P("raw[\"", jsonName, "\"], err = json.Marshal(items)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P("}") - gf.P() - } else { - // Singular field: nil check then re-serialize forwarding opts when possible. - gf.P("// Re-serialize \"", jsonName, "\" forwarding opts when child supports MarshalJSONSebuf") - gf.P("if x.", field.GoName, " != nil {") - gf.P( - "if m, ok := any(x.", - field.GoName, - ").(interface{ MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) }); ok {", - ) - gf.P("raw[\"", jsonName, "\"], err = m.MarshalJSONSebuf(opts)") - gf.P("} else {") - gf.P("raw[\"", jsonName, "\"], err = opts.Marshal(x.", field.GoName, ")") - gf.P("}") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P("}") - gf.P() - } - } -} - -// generateWrapperMarshalJSON generates a MarshalJSONSebuf that re-marshals nested -// messages via the sebuf opts pipeline, so their custom MarshalJSONSebuf methods are called. -func (g *Generator) generateWrapperMarshalJSON(gf *protogen.GeneratedFile, ctx *Int64WrapperContext) { - msgName := ctx.Message.GoIdent.GoName - - var nestedFieldNames []string - for _, f := range ctx.NestedFields { - nestedFieldNames = append(nestedFieldNames, string(f.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P( - "// This method re-marshals nested messages that have int64_encoding=NUMBER fields: ", - strings.Join(nestedFieldNames, ", "), - ) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - gf.P("// Use protojson for base serialization (handles all other fields correctly)") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - gf.P("// Parse into a map to re-serialize nested messages with custom MarshalJSON") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - emitNestedFieldsMarshal(gf, ctx.NestedFields) - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// emitNestedFieldsUnmarshal emits per-field decoding of message-typed fields whose type reaches -// an int64 NUMBER field, dispatching through the child's UnmarshalJSONSebuf so opts propagate. -// Shared by the direct and wrapper unmarshalers. Assumes `raw` and `opts` are in scope. -func emitNestedFieldsUnmarshal(gf *protogen.GeneratedFile, nestedFields []*protogen.Field) { - for _, field := range nestedFields { - jsonName := field.Desc.JSONName() - if field.Desc.IsList() { - // Repeated field: decode as raw items so we can dispatch each element - // through UnmarshalJSONSebuf (opts propagation) or json.Unmarshaler fallback. - gf.P("// Handle repeated \"", jsonName, "\" using its custom unmarshaler") - gf.P("if rawVal, ok := raw[\"", jsonName, "\"]; ok {") - gf.P("var rawItems []json.RawMessage") - gf.P("if err := json.Unmarshal(rawVal, &rawItems); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("protoItems := make([]json.RawMessage, len(rawItems))") - gf.P("for i, itemRaw := range rawItems {") - gf.P("inner := &", gf.QualifiedGoIdent(field.Message.GoIdent), "{}") - gf.P( - "if u, ok := any(inner).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {", - ) - gf.P("if err := u.UnmarshalJSONSebuf(itemRaw, opts); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("} else if err := json.Unmarshal(itemRaw, inner); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("itemJSON, marshalErr := protojson.Marshal(inner)") - gf.P("if marshalErr != nil {") - gf.P("return marshalErr") - gf.P("}") - gf.P("protoItems[i] = itemJSON") - gf.P("}") - gf.P("protoJSON, marshalErr := json.Marshal(protoItems)") - gf.P("if marshalErr != nil {") - gf.P("return marshalErr") - gf.P("}") - gf.P("raw[\"", jsonName, "\"] = protoJSON") - gf.P("}") - gf.P() - } else { - // Singular field: dispatch through UnmarshalJSONSebuf or json.Unmarshaler fallback. - gf.P("// Handle \"", jsonName, "\" using its custom unmarshaler") - gf.P("if rawVal, ok := raw[\"", jsonName, "\"]; ok {") - gf.P("inner := &", gf.QualifiedGoIdent(field.Message.GoIdent), "{}") - gf.P( - "if u, ok := any(inner).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {", - ) - gf.P("if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("} else if err := json.Unmarshal(rawVal, inner); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("innerJSON, err := protojson.Marshal(inner)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P("raw[\"", jsonName, "\"] = innerJSON") - gf.P("}") - gf.P() - } - } -} - -// generateWrapperUnmarshalJSON generates an UnmarshalJSONSebuf that delegates nested -// message parsing via the sebufUnmarshaler interface (propagating opts), then converts -// back for protojson. Also emits a backward-compatible UnmarshalJSON wrapper. -func (g *Generator) generateWrapperUnmarshalJSON(gf *protogen.GeneratedFile, ctx *Int64WrapperContext) { - msgName := ctx.Message.GoIdent.GoName - - var nestedFieldNames []string - for _, f := range ctx.NestedFields { - nestedFieldNames = append(nestedFieldNames, string(f.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P( - "// This method handles nested messages that have int64_encoding=NUMBER fields: ", - strings.Join(nestedFieldNames, ", "), - ) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - emitNestedFieldsUnmarshal(gf, ctx.NestedFields) - - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} diff --git a/internal/clientgen/enum_encoding.go b/internal/clientgen/enum_encoding.go deleted file mode 100644 index 77e583c7..00000000 --- a/internal/clientgen/enum_encoding.go +++ /dev/null @@ -1,205 +0,0 @@ -package clientgen - -import ( - "fmt" - - "google.golang.org/protobuf/compiler/protogen" - "google.golang.org/protobuf/reflect/protoreflect" - - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// EnumEncodingContext holds information about enums that need custom JSON encoding. -type EnumEncodingContext struct { - Enum *protogen.Enum - HasCustomValues bool - HasNumberEncoding bool -} - -func validateEnumAnnotations(field *protogen.Field) error { - if annotations.HasConflictingEnumAnnotations(field) { - return fmt.Errorf( - "field %s has both enum_encoding=NUMBER and enum_value annotations - this is not allowed", - field.Desc.Name(), - ) - } - return nil -} - -func collectEnumsWithCustomValues(file *protogen.File) []*EnumEncodingContext { - var contexts []*EnumEncodingContext - seen := make(map[string]bool) - - for _, enum := range file.Enums { - if hasCustomEnumValues(enum) { - fullName := string(enum.Desc.FullName()) - if !seen[fullName] { - seen[fullName] = true - contexts = append(contexts, &EnumEncodingContext{ - Enum: enum, - HasCustomValues: true, - }) - } - } - } - - for _, msg := range file.Messages { - collectEnumsFromMessage(msg, &contexts, seen) - } - - return contexts -} - -func collectEnumsFromMessage(msg *protogen.Message, contexts *[]*EnumEncodingContext, seen map[string]bool) { - for _, enum := range msg.Enums { - if hasCustomEnumValues(enum) { - fullName := string(enum.Desc.FullName()) - if !seen[fullName] { - seen[fullName] = true - *contexts = append(*contexts, &EnumEncodingContext{ - Enum: enum, - HasCustomValues: true, - }) - } - } - } - - for _, nested := range msg.Messages { - collectEnumsFromMessage(nested, contexts, seen) - } -} - -func hasCustomEnumValues(enum *protogen.Enum) bool { - return annotations.HasAnyEnumValueMapping(enum) -} - -func (g *Generator) generateEnumEncodingFile(file *protogen.File) error { - contexts := collectEnumsWithCustomValues(file) - - if len(contexts) == 0 { - return nil - } - - filename := file.GeneratedFilenamePrefix + "_enum_encoding.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeEncodingHeader(gf, file) - g.writeEnumEncodingImports(gf) - - for _, ctx := range contexts { - g.generateEnumLookupMaps(gf, ctx.Enum) - g.generateEnumMarshalJSON(gf, ctx.Enum) - g.generateEnumUnmarshalJSON(gf, ctx.Enum) - } - - return nil -} - -func (g *Generator) writeEnumEncodingImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P("\"encoding/json\"") - gf.P("\"fmt\"") - gf.P(")") - gf.P() -} - -func (g *Generator) generateEnumLookupMaps(gf *protogen.GeneratedFile, enum *protogen.Enum) { - enumName := enum.GoIdent.GoName - lowerName := annotations.LowerFirst(enumName) - - gf.P("var ", lowerName, "ToJSON = map[", enumName, "]string{") - for _, value := range enum.Values { - customValue := annotations.GetEnumValueMapping(value) - jsonValue := customValue - if jsonValue == "" { - jsonValue = string(value.Desc.Name()) - } - gf.P(value.GoIdent.GoName, ": \"", jsonValue, "\",") - } - gf.P("}") - gf.P() - - gf.P("var ", lowerName, "FromJSON = map[string]", enumName, "{") - for _, value := range enum.Values { - customValue := annotations.GetEnumValueMapping(value) - jsonValue := customValue - if jsonValue == "" { - jsonValue = string(value.Desc.Name()) - } - gf.P("\"", jsonValue, "\": ", value.GoIdent.GoName, ",") - } - for _, value := range enum.Values { - customValue := annotations.GetEnumValueMapping(value) - if customValue != "" { - protoName := string(value.Desc.Name()) - gf.P("\"", protoName, "\": ", value.GoIdent.GoName, ",") - } - } - gf.P("}") - gf.P() -} - -func (g *Generator) generateEnumMarshalJSON(gf *protogen.GeneratedFile, enum *protogen.Enum) { - enumName := enum.GoIdent.GoName - lowerName := annotations.LowerFirst(enumName) - - gf.P("func (x ", enumName, ") MarshalJSON() ([]byte, error) {") - gf.P("if s, ok := ", lowerName, "ToJSON[x]; ok {") - gf.P("return json.Marshal(s)") - gf.P("}") - gf.P("return json.Marshal(x.String())") - gf.P("}") - gf.P() -} - -func (g *Generator) generateEnumUnmarshalJSON(gf *protogen.GeneratedFile, enum *protogen.Enum) { - enumName := enum.GoIdent.GoName - lowerName := annotations.LowerFirst(enumName) - - gf.P("func (x *", enumName, ") UnmarshalJSON(data []byte) error {") - gf.P("var s string") - gf.P("if err := json.Unmarshal(data, &s); err == nil {") - gf.P("if v, ok := ", lowerName, "FromJSON[s]; ok {") - gf.P("*x = v") - gf.P("return nil") - gf.P("}") - gf.P("return fmt.Errorf(\"unknown ", enumName, " value: %q\", s)") - gf.P("}") - gf.P() - gf.P("var n int32") - gf.P("if err := json.Unmarshal(data, &n); err == nil {") - gf.P("*x = ", enumName, "(n)") - gf.P("return nil") - gf.P("}") - gf.P() - gf.P("return fmt.Errorf(\"cannot unmarshal %s into ", enumName, "\", string(data))") - gf.P("}") - gf.P() -} - -func (g *Generator) validateEnumAnnotationsInFile(file *protogen.File) error { - for _, msg := range file.Messages { - if err := g.validateEnumAnnotationsInMessage(msg); err != nil { - return err - } - } - return nil -} - -func (g *Generator) validateEnumAnnotationsInMessage(msg *protogen.Message) error { - for _, field := range msg.Fields { - if field.Desc.Kind() == protoreflect.EnumKind { - if err := validateEnumAnnotations(field); err != nil { - return err - } - } - } - - for _, nested := range msg.Messages { - if err := g.validateEnumAnnotationsInMessage(nested); err != nil { - return err - } - } - - return nil -} diff --git a/internal/clientgen/enum_field_encoding.go b/internal/clientgen/enum_field_encoding.go deleted file mode 100644 index edd7fee5..00000000 --- a/internal/clientgen/enum_field_encoding.go +++ /dev/null @@ -1,692 +0,0 @@ -package clientgen - -import ( - "fmt" - "strconv" - "strings" - - "google.golang.org/protobuf/compiler/protogen" - "google.golang.org/protobuf/reflect/protoreflect" - - "github.com/SebastienMelki/sebuf/http" - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// enumFieldShape describes how a custom-enum field is laid out in JSON. -type enumFieldShape int - -const ( - enumShapeSingular enumFieldShape = iota // "field": "value" - enumShapeRepeated // "field": ["value", ...] - enumShapeMap // "field": {"key": "value", ...} -) - -// EnumFieldInfo pairs a field with the custom-value enum it references and its JSON shape. -type EnumFieldInfo struct { - Field *protogen.Field - // Enum is the enum type carrying enum_value mappings. For map fields this is the - // map value's enum type. - Enum *protogen.Enum - Shape enumFieldShape -} - -// EnumFieldEncodingContext holds a message that needs a message-level marshaler because it either -// carries custom enum_value fields directly or nests (at any depth) a message that does. The -// generated MarshalJSONSebuf patches the direct enum fields and re-serializes the nested message -// fields through their own MarshalJSONSebuf, so custom enum strings propagate through the tree. -type EnumFieldEncodingContext struct { - Message *protogen.Message - // EnumFields are the message's direct custom-enum fields (may be empty for pure wrappers). - EnumFields []*EnumFieldInfo - // NestedFields are singular/repeated message fields whose type transitively contains a - // custom-enum field, so they must be re-serialized via the child's custom marshaler. - NestedFields []*protogen.Field -} - -// customEnumForField returns the custom-value enum a field references (the map value's enum for -// map fields), regardless of Go package, or nil if the field does not reference a custom-value, -// string-encoded enum. NUMBER-encoded enums never carry custom string values. -func customEnumForField(field *protogen.Field) *protogen.Enum { - if annotations.GetEnumEncoding(field) == http.EnumEncoding_ENUM_ENCODING_NUMBER { - return nil - } - - switch { - case field.Desc.IsMap(): - if field.Desc.MapValue().Kind() != protoreflect.EnumKind { - return nil - } - valueEnum := field.Message.Fields[1].Enum - if valueEnum != nil && annotations.HasAnyEnumValueMapping(valueEnum) { - return valueEnum - } - case field.Desc.Kind() == protoreflect.EnumKind: - if field.Enum != nil && annotations.HasAnyEnumValueMapping(field.Enum) { - return field.Enum - } - } - return nil -} - -// nestedMessageChild returns the message type of a singular or repeated (non-map) message field, -// or nil if the field is not such a field. -func nestedMessageChild(field *protogen.Field) *protogen.Message { - if field.Desc.IsMap() || field.Desc.Kind() != protoreflect.MessageKind { - return nil - } - return field.Message -} - -// mapMessageValueChild returns the value message type of a map<_, message> field, or nil. -func mapMessageValueChild(field *protogen.Field) *protogen.Message { - if !field.Desc.IsMap() { - return nil - } - valueField := field.Message.Fields[1] - if valueField.Desc.Kind() != protoreflect.MessageKind { - return nil - } - return valueField.Message -} - -// messageTransitivelyHasCustomEnum reports whether msg, or any message it nests (singular, -// repeated, or map value) at any depth, has a direct custom enum_value field. The visited set -// guards against recursive message definitions. -func messageTransitivelyHasCustomEnum(msg *protogen.Message, visited map[string]bool) bool { - if msg == nil { - return false - } - key := string(msg.Desc.FullName()) - if visited[key] { - return false - } - visited[key] = true - - for _, field := range msg.Fields { - if customEnumForField(field) != nil { - return true - } - if child := nestedMessageChild(field); child != nil && - messageTransitivelyHasCustomEnum(child, visited) { - return true - } - if child := mapMessageValueChild(field); child != nil && - messageTransitivelyHasCustomEnum(child, visited) { - return true - } - } - return false -} - -// fieldTransitivelyHasCustomEnum reports whether a message field's type (singular/repeated) reaches -// a custom enum at any depth. -func fieldTransitivelyHasCustomEnum(field *protogen.Field) bool { - child := nestedMessageChild(field) - if child == nil { - return false - } - return messageTransitivelyHasCustomEnum(child, map[string]bool{}) -} - -// customEnumFieldInfo returns EnumFieldInfo for a direct custom-enum field whose enum is in the -// same Go package as the message (pkg), or nil otherwise. Cross-package direct enums are rejected -// by validateEnumFieldEncoding, since the marshaler references that package's private lookup maps. -func customEnumFieldInfo(field *protogen.Field, pkg protogen.GoImportPath) *EnumFieldInfo { - enum := customEnumForField(field) - if enum == nil || enum.GoIdent.GoImportPath != pkg { - return nil - } - - shape := enumShapeSingular - switch { - case field.Desc.IsMap(): - shape = enumShapeMap - case field.Desc.IsList(): - shape = enumShapeRepeated - } - return &EnumFieldInfo{Field: field, Enum: enum, Shape: shape} -} - -// getCustomEnumFields returns the direct custom-enum fields of a message. -func getCustomEnumFields(msg *protogen.Message) []*EnumFieldInfo { - pkg := msg.GoIdent.GoImportPath - var fields []*EnumFieldInfo - for _, field := range msg.Fields { - if info := customEnumFieldInfo(field, pkg); info != nil { - fields = append(fields, info) - } - } - return fields -} - -// getNestedEnumMessageFields returns the singular/repeated message fields of a message whose type -// transitively contains a custom enum (so they must be re-serialized through the child marshaler). -func getNestedEnumMessageFields(msg *protogen.Message) []*protogen.Field { - var fields []*protogen.Field - for _, field := range msg.Fields { - if nestedMessageChild(field) != nil && fieldTransitivelyHasCustomEnum(field) { - fields = append(fields, field) - } - } - return fields -} - -// hasCustomEnumFields reports whether a message directly carries any custom enum_value field. -func hasCustomEnumFields(msg *protogen.Message) bool { - return len(getCustomEnumFields(msg)) > 0 -} - -// validateEnumFieldEncoding fails loudly for cases the Go generator cannot encode: -// - a direct custom-enum field whose enum lives in a different Go package (the marshaler needs -// that package's private lookup maps); -// - a map<_, message> value whose message transitively carries a custom enum (nested re- -// serialization through map values is not yet supported). -// -// Failing loudly avoids silently emitting raw proto enum names, which would contradict the docs. -func validateEnumFieldEncoding(file *protogen.File) error { - return validateEnumFieldEncodingMessages(file.Messages) -} - -func validateEnumFieldEncodingMessages(messages []*protogen.Message) error { - for _, msg := range messages { - if msg.Desc.IsMapEntry() { - continue - } - if err := validateEnumFieldEncodingFields(msg); err != nil { - return err - } - if err := validateEnumFieldEncodingMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -func validateEnumFieldEncodingFields(msg *protogen.Message) error { - pkg := msg.GoIdent.GoImportPath - for _, field := range msg.Fields { - if enum := customEnumForField(field); enum != nil && - !field.Desc.IsMap() && enum.GoIdent.GoImportPath != pkg { - return fmt.Errorf( - "message %s field %q references enum %s with (sebuf.http.enum_value) mappings from a "+ - "different Go package (%s); cross-package custom enum JSON encoding is not supported "+ - "by the Go generator", - msg.GoIdent.GoName, field.Desc.Name(), enum.GoIdent.GoName, enum.GoIdent.GoImportPath, - ) - } - if child := mapMessageValueChild(field); child != nil && - messageTransitivelyHasCustomEnum(child, map[string]bool{}) { - return fmt.Errorf( - "message %s field %q is a map whose value message %s carries (sebuf.http.enum_value) "+ - "mappings; custom enum JSON encoding inside map values is not yet supported", - msg.GoIdent.GoName, field.Desc.Name(), child.GoIdent.GoName, - ) - } - } - return nil -} - -// collectEnumFieldEncodingContext gathers messages that need a custom enum-field marshaler. -func collectEnumFieldEncodingContext(file *protogen.File) []*EnumFieldEncodingContext { - var contexts []*EnumFieldEncodingContext - collectEnumFieldEncodingMessages(file.Messages, &contexts) - return contexts -} - -func collectEnumFieldEncodingMessages( - messages []*protogen.Message, - contexts *[]*EnumFieldEncodingContext, -) { - for _, msg := range messages { - if msg.Desc.IsMapEntry() { - continue - } - enumFields := getCustomEnumFields(msg) - nestedFields := getNestedEnumMessageFields(msg) - if len(enumFields) > 0 || len(nestedFields) > 0 { - *contexts = append(*contexts, &EnumFieldEncodingContext{ - Message: msg, - EnumFields: enumFields, - NestedFields: nestedFields, - }) - } - collectEnumFieldEncodingMessages(msg.Messages, contexts) - } -} - -// checkEnumMarshalJSONConflict returns an error if a message that needs a custom enum-field -// marshaler also carries another MarshalJSON-generating annotation. Only one feature can own a -// message's MarshalJSON/UnmarshalJSON methods, so combining them would produce duplicate method -// declarations. Fail fast with a clear message (matching flatten/oneof behavior). -func checkEnumMarshalJSONConflict(msg *protogen.Message) error { - var conflicts []string - - if hasInt64NumberFields(msg) { - conflicts = append(conflicts, "int64_encoding=NUMBER") - } - if hasBytesEncodingFields(msg) { - conflicts = append(conflicts, "bytes_encoding") - } - if hasNullableFields(msg) { - conflicts = append(conflicts, "nullable") - } - if hasEmptyBehaviorFields(msg) { - conflicts = append(conflicts, "empty_behavior") - } - if hasTimestampFormatFields(msg) { - conflicts = append(conflicts, "timestamp_format") - } - if hasFlattenFields(msg) { - conflicts = append(conflicts, "flatten") - } - if hasOneofDiscriminator(msg) { - conflicts = append(conflicts, "oneof_config") - } - if annotations.IsRootUnwrap(msg) { - conflicts = append(conflicts, "unwrap") - } - if nestsInt64NumberMessage(msg) { - conflicts = append(conflicts, "int64_encoding=NUMBER (nested)") - } - - if len(conflicts) > 0 { - return fmt.Errorf( - "message %s: enum_value requires MarshalJSON but conflicts with %s (also requires MarshalJSON) -- "+ - "only one MarshalJSON-generating feature is supported per message", - msg.GoIdent.GoName, strings.Join(conflicts, ", "), - ) - } - - return nil -} - -// nestsInt64NumberMessage reports whether msg nests a message that transitively reaches an -// int64 NUMBER field, which would make it an int64 wrapper (also generating MarshalJSONSebuf). -// This must stay in lockstep with collectWrapperMessages in encoding.go — if this check is -// narrower, a message that becomes an int64 wrapper can slip past the enum conflict check and -// produce duplicate method declarations. -func nestsInt64NumberMessage(msg *protogen.Message) bool { - for _, field := range msg.Fields { - if fieldTransitivelyHasInt64Number(field) { - return true - } - } - return false -} - -// generateEnumFieldEncodingFile generates the *_enum_field_encoding.pb.go file if needed. -// It emits message-level MarshalJSON/UnmarshalJSON that translate enum fields between the raw -// proto value names protojson uses and the custom enum_value strings (reusing the lookup maps in -// *_enum_encoding.pb.go), and re-serialize nested messages so custom strings propagate through -// the whole message tree. -func (g *Generator) generateEnumFieldEncodingFile(file *protogen.File) error { - if err := validateEnumFieldEncoding(file); err != nil { - return err - } - - contexts := collectEnumFieldEncodingContext(file) - if len(contexts) == 0 { - return nil - } - - for _, ctx := range contexts { - if err := checkEnumMarshalJSONConflict(ctx.Message); err != nil { - return err - } - } - - filename := file.GeneratedFilenamePrefix + "_enum_field_encoding.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeHeader(gf, file) - g.writeEnumFieldEncodingImports(gf) - - for _, ctx := range contexts { - g.generateEnumFieldMarshalJSON(gf, ctx) - g.generateEnumFieldUnmarshalJSON(gf, ctx) - } - - return nil -} - -func (g *Generator) writeEnumFieldEncodingImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P(`"encoding/json"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// enumFieldJSONKeys returns the Go slice-literal contents of the JSON keys a field may appear -// under: the camelCase JSON name and, when different, the proto (snake_case) name. protojson emits -// the proto name when MarshalOptions.UseProtoNames is set, so both must be patched. -func enumFieldJSONKeys(field *protogen.Field) string { - jsonName := field.Desc.JSONName() - protoName := string(field.Desc.Name()) - if jsonName == protoName { - return strconv.Quote(jsonName) - } - return strconv.Quote(jsonName) + ", " + strconv.Quote(protoName) -} - -// marshalerNames lists the direct enum fields and nested message fields a marshaler handles, -// for the method doc comment. -func marshalerNames(ctx *EnumFieldEncodingContext) string { - var names []string - for _, f := range ctx.EnumFields { - names = append(names, string(f.Field.Desc.Name())) - } - for _, f := range ctx.NestedFields { - names = append(names, string(f.Desc.Name())) - } - return strings.Join(names, ", ") -} - -// generateEnumFieldMarshalJSON emits MarshalJSONSebuf (+ MarshalJSON wrapper) that rewrites direct -// enum fields to their custom strings and re-serializes nested messages via their marshaler. -func (g *Generator) generateEnumFieldMarshalJSON(gf *protogen.GeneratedFile, ctx *EnumFieldEncodingContext) { - msgName := ctx.Message.GoIdent.GoName - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles enum_value fields and nested messages: ", marshalerNames(ctx)) - gf.P("func (x *", msgName, ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {") - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization (handles all other fields correctly)") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to rewrite enum fields and nested messages") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - for _, info := range ctx.EnumFields { - g.generateEnumFieldMarshal(gf, info) - } - for _, field := range ctx.NestedFields { - g.generateNestedMessageMarshal(gf, field) - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateEnumFieldMarshal emits the map-patching code for one enum field (proto name -> custom). -// It patches both the JSON name and proto name keys so UseProtoNames output is handled. -func (g *Generator) generateEnumFieldMarshal(gf *protogen.GeneratedFile, info *EnumFieldInfo) { - lower := annotations.LowerFirst(info.Enum.GoIdent.GoName) - toJSON := lower + "ToJSON" - fromJSON := lower + "FromJSON" - - gf.P("// Rewrite ", info.Field.Desc.Name(), " to custom enum_value strings") - gf.P("for _, k := range []string{", enumFieldJSONKeys(info.Field), "} {") - gf.P("v, ok := raw[k]") - gf.P("if !ok {") - gf.P("continue") - gf.P("}") - - switch info.Shape { - case enumShapeSingular: - gf.P("var s string") - gf.P("if err := json.Unmarshal(v, &s); err != nil {") - gf.P("continue") - gf.P("}") - gf.P("if e, ok := ", fromJSON, "[s]; ok {") - gf.P("raw[k], _ = json.Marshal(", toJSON, "[e])") - gf.P("}") - case enumShapeRepeated: - gf.P("var arr []string") - gf.P("if err := json.Unmarshal(v, &arr); err != nil {") - gf.P("continue") - gf.P("}") - gf.P("for i, s := range arr {") - gf.P("if e, ok := ", fromJSON, "[s]; ok {") - gf.P("arr[i] = ", toJSON, "[e]") - gf.P("}") - gf.P("}") - gf.P("raw[k], _ = json.Marshal(arr)") - case enumShapeMap: - gf.P("var m map[string]string") - gf.P("if err := json.Unmarshal(v, &m); err != nil {") - gf.P("continue") - gf.P("}") - gf.P("for mk, s := range m {") - gf.P("if e, ok := ", fromJSON, "[s]; ok {") - gf.P("m[mk] = ", toJSON, "[e]") - gf.P("}") - gf.P("}") - gf.P("raw[k], _ = json.Marshal(m)") - } - - gf.P("}") - gf.P() -} - -// generateNestedMessageMarshal re-serializes a nested message field through the child's -// MarshalJSONSebuf (forwarding opts) so custom enum strings propagate, mirroring the int64 wrapper. -// The result is written under whichever JSON key protojson emitted (camelCase, or the proto -// snake_case name under UseProtoNames). -func (g *Generator) generateNestedMessageMarshal(gf *protogen.GeneratedFile, field *protogen.Field) { - jsonName := field.Desc.JSONName() - keys := enumFieldJSONKeys(field) - - if field.Desc.IsList() { - gf.P("// Re-serialize repeated \"", jsonName, "\" forwarding opts to each element") - gf.P("if len(x.", field.GoName, ") > 0 {") - gf.P("items := make([]json.RawMessage, 0, len(x.", field.GoName, "))") - gf.P("for _, item := range x.", field.GoName, " {") - gf.P("if m, ok := any(item).(interface{ MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) }); ok {") - gf.P("itemData, itemErr := m.MarshalJSONSebuf(opts)") - gf.P("if itemErr != nil {") - gf.P("return nil, itemErr") - gf.P("}") - gf.P("items = append(items, itemData)") - gf.P("} else {") - gf.P("itemData, itemErr := opts.Marshal(item)") - gf.P("if itemErr != nil {") - gf.P("return nil, itemErr") - gf.P("}") - gf.P("items = append(items, itemData)") - gf.P("}") - gf.P("}") - gf.P("listData, listErr := json.Marshal(items)") - gf.P("if listErr != nil {") - gf.P("return nil, listErr") - gf.P("}") - gf.P("for _, k := range []string{", keys, "} {") - gf.P("if _, ok := raw[k]; ok {") - gf.P("raw[k] = listData") - gf.P("}") - gf.P("}") - gf.P("}") - gf.P() - return - } - - gf.P("// Re-serialize \"", jsonName, "\" forwarding opts when child supports MarshalJSONSebuf") - gf.P("if x.", field.GoName, " != nil {") - gf.P("if m, ok := any(x.", field.GoName, - ").(interface{ MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) }); ok {") - gf.P("childData, childErr := m.MarshalJSONSebuf(opts)") - gf.P("if childErr != nil {") - gf.P("return nil, childErr") - gf.P("}") - gf.P("for _, k := range []string{", keys, "} {") - gf.P("if _, ok := raw[k]; ok {") - gf.P("raw[k] = childData") - gf.P("}") - gf.P("}") - gf.P("}") - gf.P("}") - gf.P() -} - -// generateEnumFieldUnmarshalJSON emits UnmarshalJSONSebuf (+ UnmarshalJSON wrapper) that rewrites -// incoming custom enum_value strings back to proto value names and delegates nested message parsing. -func (g *Generator) generateEnumFieldUnmarshalJSON(gf *protogen.GeneratedFile, ctx *EnumFieldEncodingContext) { - msgName := ctx.Message.GoIdent.GoName - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles enum_value fields and nested messages: ", marshalerNames(ctx)) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// Parse the raw JSON to rewrite custom enum_value strings and nested messages") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - for _, info := range ctx.EnumFields { - g.generateEnumFieldUnmarshal(gf, info) - } - for _, field := range ctx.NestedFields { - g.generateNestedMessageUnmarshal(gf, field) - } - - gf.P("// Re-marshal with proto value names for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("// Use protojson to unmarshal the rest") - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateEnumFieldUnmarshal emits the map-patching code for one enum field (custom -> proto name). -// It patches both the JSON name and proto name keys (protojson.Unmarshal accepts either), and the -// lookup accepts both custom values and proto names, so it is idempotent for clients that already -// send proto names; numeric values fall through untouched. -func (g *Generator) generateEnumFieldUnmarshal(gf *protogen.GeneratedFile, info *EnumFieldInfo) { - lower := annotations.LowerFirst(info.Enum.GoIdent.GoName) - fromJSON := lower + "FromJSON" - - gf.P("// Rewrite ", info.Field.Desc.Name(), " from custom enum_value strings to proto names") - gf.P("for _, k := range []string{", enumFieldJSONKeys(info.Field), "} {") - gf.P("v, ok := raw[k]") - gf.P("if !ok {") - gf.P("continue") - gf.P("}") - - switch info.Shape { - case enumShapeSingular: - gf.P("var s string") - gf.P("if err := json.Unmarshal(v, &s); err != nil {") - gf.P("continue") - gf.P("}") - gf.P("if e, ok := ", fromJSON, "[s]; ok {") - gf.P("raw[k], _ = json.Marshal(e.String())") - gf.P("}") - case enumShapeRepeated: - gf.P("var arr []string") - gf.P("if err := json.Unmarshal(v, &arr); err != nil {") - gf.P("continue") - gf.P("}") - gf.P("for i, s := range arr {") - gf.P("if e, ok := ", fromJSON, "[s]; ok {") - gf.P("arr[i] = e.String()") - gf.P("}") - gf.P("}") - gf.P("raw[k], _ = json.Marshal(arr)") - case enumShapeMap: - gf.P("var m map[string]string") - gf.P("if err := json.Unmarshal(v, &m); err != nil {") - gf.P("continue") - gf.P("}") - gf.P("for mk, s := range m {") - gf.P("if e, ok := ", fromJSON, "[s]; ok {") - gf.P("m[mk] = e.String()") - gf.P("}") - gf.P("}") - gf.P("raw[k], _ = json.Marshal(m)") - } - - gf.P("}") - gf.P() -} - -// generateNestedMessageUnmarshal delegates nested message parsing to the child's UnmarshalJSONSebuf -// (forwarding opts), then converts back to protojson form, mirroring the int64 wrapper. It handles -// whichever JSON key the request used (camelCase or the proto snake_case name). -func (g *Generator) generateNestedMessageUnmarshal(gf *protogen.GeneratedFile, field *protogen.Field) { - jsonName := field.Desc.JSONName() - keys := enumFieldJSONKeys(field) - childIdent := gf.QualifiedGoIdent(field.Message.GoIdent) - - gf.P("// Handle \"", jsonName, "\" using its custom unmarshaler") - gf.P("for _, k := range []string{", keys, "} {") - gf.P("rawVal, ok := raw[k]") - gf.P("if !ok {") - gf.P("continue") - gf.P("}") - - if field.Desc.IsList() { - gf.P("var rawItems []json.RawMessage") - gf.P("if err := json.Unmarshal(rawVal, &rawItems); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("protoItems := make([]json.RawMessage, len(rawItems))") - gf.P("for i, itemRaw := range rawItems {") - gf.P("inner := &", childIdent, "{}") - gf.P("if u, ok := any(inner).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {") - gf.P("if err := u.UnmarshalJSONSebuf(itemRaw, opts); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("} else if err := json.Unmarshal(itemRaw, inner); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("itemJSON, marshalErr := protojson.Marshal(inner)") - gf.P("if marshalErr != nil {") - gf.P("return marshalErr") - gf.P("}") - gf.P("protoItems[i] = itemJSON") - gf.P("}") - gf.P("protoJSON, marshalErr := json.Marshal(protoItems)") - gf.P("if marshalErr != nil {") - gf.P("return marshalErr") - gf.P("}") - gf.P("raw[k] = protoJSON") - } else { - gf.P("inner := &", childIdent, "{}") - gf.P("if u, ok := any(inner).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {") - gf.P("if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("} else if err := json.Unmarshal(rawVal, inner); err != nil {") - gf.P("return err") - gf.P("}") - gf.P("innerJSON, marshalErr := protojson.Marshal(inner)") - gf.P("if marshalErr != nil {") - gf.P("return marshalErr") - gf.P("}") - gf.P("raw[k] = innerJSON") - } - - gf.P("}") - gf.P() -} diff --git a/internal/clientgen/enum_validation.go b/internal/clientgen/enum_validation.go new file mode 100644 index 00000000..9b2f8dfb --- /dev/null +++ b/internal/clientgen/enum_validation.go @@ -0,0 +1,47 @@ +package clientgen + +import ( + "fmt" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/SebastienMelki/sebuf/internal/annotations" +) + +func validateEnumAnnotations(field *protogen.Field) error { + if annotations.HasConflictingEnumAnnotations(field) { + return fmt.Errorf( + "field %s has both enum_encoding=NUMBER and enum_value annotations - this is not allowed", + field.Desc.Name(), + ) + } + return nil +} + +func (g *Generator) validateEnumAnnotationsInFile(file *protogen.File) error { + for _, msg := range file.Messages { + if err := g.validateEnumAnnotationsInMessage(msg); err != nil { + return err + } + } + return nil +} + +func (g *Generator) validateEnumAnnotationsInMessage(msg *protogen.Message) error { + for _, field := range msg.Fields { + if field.Desc.Kind() == protoreflect.EnumKind { + if err := validateEnumAnnotations(field); err != nil { + return err + } + } + } + + for _, nested := range msg.Messages { + if err := g.validateEnumAnnotationsInMessage(nested); err != nil { + return err + } + } + + return nil +} diff --git a/internal/clientgen/flatten.go b/internal/clientgen/flatten.go deleted file mode 100644 index e14709bd..00000000 --- a/internal/clientgen/flatten.go +++ /dev/null @@ -1,369 +0,0 @@ -package clientgen - -import ( - "fmt" - "strings" - - "google.golang.org/protobuf/compiler/protogen" - - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// FlattenContext holds information about messages that need custom JSON encoding -// for flatten-annotated fields. -type FlattenContext struct { - // Message is the message that needs custom marshal/unmarshal - Message *protogen.Message - // FlattenInfos holds all flatten-annotated fields with their prefixes - FlattenInfos []*FlattenFieldInfo -} - -// FlattenFieldInfo holds field info with its flatten prefix. -type FlattenFieldInfo struct { - Field *protogen.Field - Prefix string // flatten_prefix value (may be empty) -} - -// hasFlattenFields returns true if any field in the message has flatten annotation. -func hasFlattenFields(message *protogen.Message) bool { - return annotations.HasFlattenFields(message) -} - -// getFlattenFieldInfos returns all flatten fields with their prefixes. -func getFlattenFieldInfos(message *protogen.Message) []*FlattenFieldInfo { - var infos []*FlattenFieldInfo - for _, field := range message.Fields { - if annotations.IsFlattenField(field) { - infos = append(infos, &FlattenFieldInfo{ - Field: field, - Prefix: annotations.GetFlattenPrefix(field), - }) - } - } - return infos -} - -// collectFlattenContexts collects flatten contexts from all messages in a file. -func collectFlattenContexts(file *protogen.File) []*FlattenContext { - var contexts []*FlattenContext - collectFlattenMessages(file.Messages, &contexts) - return contexts -} - -// collectFlattenMessages recursively collects messages with flatten fields. -func collectFlattenMessages(messages []*protogen.Message, contexts *[]*FlattenContext) { - for _, msg := range messages { - if hasFlattenFields(msg) { - *contexts = append(*contexts, &FlattenContext{ - Message: msg, - FlattenInfos: getFlattenFieldInfos(msg), - }) - } - // Check nested messages - collectFlattenMessages(msg.Messages, contexts) - } -} - -// validateFlattenAnnotations validates all flatten annotations in a file. -// Checks field validity, name collisions, and MarshalJSON conflicts. -func validateFlattenAnnotations(file *protogen.File) error { - return validateFlattenInMessages(file.Messages) -} - -// validateFlattenInMessages recursively validates flatten annotations. -func validateFlattenInMessages(messages []*protogen.Message) error { - for _, msg := range messages { - // Validate each field - for _, field := range msg.Fields { - if err := annotations.ValidateFlattenField(field, msg.GoIdent.GoName); err != nil { - return err - } - } - - // Validate collisions for messages with flatten fields - if hasFlattenFields(msg) { - if err := annotations.ValidateFlattenCollisions(msg); err != nil { - return err - } - // Check for MarshalJSON conflicts - if err := validateFlattenMarshalJSONConflict(msg); err != nil { - return err - } - } - - // Validate nested messages - if err := validateFlattenInMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -// validateFlattenMarshalJSONConflict checks if a message with flatten also has other -// encoding features that generate MarshalJSON. Only one feature can own MarshalJSON. -func validateFlattenMarshalJSONConflict(msg *protogen.Message) error { - conflicts := detectMarshalJSONConflicts(msg) - if len(conflicts) > 0 { - return fmt.Errorf( - "message %s has both flatten and %s -- "+ - "only one MarshalJSON-generating feature is supported per message", - msg.GoIdent.GoName, strings.Join(conflicts, ", "), - ) - } - return nil -} - -// detectMarshalJSONConflicts returns a list of other MarshalJSON-generating features -// present on the same message. Returns nil if no conflicts. -func detectMarshalJSONConflicts(msg *protogen.Message) []string { - var conflicts []string - - for _, field := range msg.Fields { - if annotations.IsFlattenField(field) { - continue - } - if isInt64Type(field) && annotations.IsInt64NumberEncoding(field) { - conflicts = append(conflicts, "int64_encoding=NUMBER") - } - if annotations.IsNullableField(field) { - conflicts = append(conflicts, "nullable") - } - if annotations.HasEmptyBehaviorAnnotation(field) { - conflicts = append(conflicts, "empty_behavior") - } - if annotations.IsTimestampField(field) && annotations.HasTimestampFormatAnnotation(field) { - conflicts = append(conflicts, "timestamp_format") - } - if annotations.HasBytesEncodingAnnotation(field) { - conflicts = append(conflicts, "bytes_encoding") - } - } - - if hasCustomEnumFields(msg) { - conflicts = append(conflicts, "enum_value") - } - - return conflicts -} - -// generateFlattenFile generates the *_flatten.pb.go file if needed. -func (g *Generator) generateFlattenFile(file *protogen.File) error { - if err := validateFlattenAnnotations(file); err != nil { - return err - } - - contexts := collectFlattenContexts(file) - if len(contexts) == 0 { - return nil - } - - filename := file.GeneratedFilenamePrefix + "_flatten.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeHeader(gf, file) - g.writeFlattenImports(gf) - - for _, ctx := range contexts { - g.generateFlattenMarshalJSON(gf, ctx) - g.generateFlattenUnmarshalJSON(gf, ctx) - } - - return nil -} - -// writeFlattenImports writes the imports needed for flatten encoding. -func (g *Generator) writeFlattenImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P(`"encoding/json"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// generateFlattenMarshalJSON generates MarshalJSON that promotes flattened child fields to the parent level. -// -//nolint:dupl // Intentionally similar to oneof_discriminator MarshalJSON — both use protojson-then-manipulate pattern -func (g *Generator) generateFlattenMarshalJSON(gf *protogen.GeneratedFile, ctx *FlattenContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, info := range ctx.FlattenInfos { - fieldNames = append(fieldNames, string(info.Field.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles flatten fields: ", strings.Join(fieldNames, ", ")) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization (handles all other fields correctly)") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to promote flattened child fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - for _, info := range ctx.FlattenInfos { - g.generateFlattenFieldMarshal(gf, info) - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateFlattenFieldMarshal generates marshaling code for a single flattened field. -// Forwards opts to child's MarshalJSONSebuf when available (annotation composability), -// otherwise uses opts.Marshal so server-configured options reach plain messages too. -func (g *Generator) generateFlattenFieldMarshal(gf *protogen.GeneratedFile, info *FlattenFieldInfo) { - field := info.Field - goName := field.GoName - jsonName := field.Desc.JSONName() - prefix := info.Prefix - - gf.P("// Flatten field: ", field.Desc.Name()) - gf.P("if x.", goName, " != nil {") - gf.P(`delete(raw, "`, jsonName, `")`) - gf.P("// Forward opts to child's MarshalJSONSebuf when available (annotation composability)") - gf.P("var childData []byte") - gf.P("var childErr error") - gf.P( - "if m, ok := any(x.", - goName, - ").(interface{ MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) }); ok {", - ) - gf.P("childData, childErr = m.MarshalJSONSebuf(opts)") - gf.P("} else {") - gf.P("childData, childErr = opts.Marshal(x.", goName, ")") - gf.P("}") - gf.P("if childErr != nil {") - gf.P("return nil, childErr") - gf.P("}") - gf.P("var childRaw map[string]json.RawMessage") - gf.P("if childErr = json.Unmarshal(childData, &childRaw); childErr != nil {") - gf.P("return nil, childErr") - gf.P("}") - gf.P("for k, v := range childRaw {") - if prefix != "" { - gf.P(`raw["`, prefix, `" + k] = v`) - } else { - gf.P("raw[k] = v") - } - gf.P("}") - gf.P("}") - gf.P() -} - -// generateFlattenUnmarshalJSON generates UnmarshalJSON that extracts prefixed child fields -// and reconstructs the nested message. -func (g *Generator) generateFlattenUnmarshalJSON(gf *protogen.GeneratedFile, ctx *FlattenContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, info := range ctx.FlattenInfos { - fieldNames = append(fieldNames, string(info.Field.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles flatten fields: ", strings.Join(fieldNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - for _, info := range ctx.FlattenInfos { - g.generateFlattenFieldUnmarshal(gf, info) - } - - gf.P("// Re-marshal remaining fields for protojson") - gf.P("remaining, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("return opts.Unmarshal(remaining, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateFlattenFieldUnmarshal generates unmarshaling code for a single flattened field. -// It enumerates all child fields at generation time and extracts them from the parent map. -func (g *Generator) generateFlattenFieldUnmarshal(gf *protogen.GeneratedFile, info *FlattenFieldInfo) { - field := info.Field - goName := field.GoName - prefix := info.Prefix - - if field.Message == nil { - return - } - - childMsg := field.Message - childTypeName := childMsg.GoIdent.GoName - - gf.P("// Extract flattened child fields for: ", field.Desc.Name()) - gf.P("{") - gf.P("childRaw := make(map[string]json.RawMessage)") - - // Enumerate all child fields at generation time - for _, childField := range childMsg.Fields { - childJSONName := childField.Desc.JSONName() - flattenedKey := prefix + childJSONName - - gf.P(`if v, ok := raw["`, flattenedKey, `"]; ok {`) - gf.P(`childRaw["`, childJSONName, `"] = v`) - gf.P(`delete(raw, "`, flattenedKey, `")`) - gf.P("}") - } - - gf.P("if len(childRaw) > 0 {") - gf.P("childData, childErr := json.Marshal(childRaw)") - gf.P("if childErr != nil {") - gf.P("return childErr") - gf.P("}") - gf.P("x.", goName, " = &", childTypeName, "{}") - gf.P("// Forward opts to child's UnmarshalJSONSebuf if available (annotation composability)") - gf.P( - "if u, ok := any(x.", goName, - `).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {`, - ) - gf.P("if childErr = u.UnmarshalJSONSebuf(childData, opts); childErr != nil {") - gf.P("return childErr") - gf.P("}") - gf.P("} else if childErr = json.Unmarshal(childData, x.", goName, "); childErr != nil {") - gf.P("return childErr") - gf.P("}") - gf.P("}") - gf.P("}") - gf.P() -} diff --git a/internal/clientgen/forward_compat_test.go b/internal/clientgen/forward_compat_test.go index 6ade7a14..48d44d7d 100644 --- a/internal/clientgen/forward_compat_test.go +++ b/internal/clientgen/forward_compat_test.go @@ -8,29 +8,6 @@ import ( "testing" ) -// encodingFileSpec maps a golden encoding file to the messages it should contain. -type encodingFileSpec struct { - file string - msgNames []string -} - -// allEncodingFiles returns the spec for all annotated message encoding golden files. -func allEncodingFiles() []encodingFileSpec { - return []encodingFileSpec{ - {"int64_encoding_encoding.pb.go", []string{"Int64EncodingTest"}}, - { - "int64_nested_encoding_encoding.pb.go", - []string{"SensorReading", "GetSensorReadingResponse", "GetMultiSensorResponse"}, - }, - {"nullable_nullable.pb.go", []string{"User"}}, - {"timestamp_format_timestamp_format.pb.go", []string{"TimestampFormatTest"}}, - {"bytes_encoding_bytes_encoding.pb.go", []string{"BytesEncodingTest"}}, - {"empty_behavior_empty_behavior.pb.go", []string{"Response"}}, - {"flatten_flatten.pb.go", []string{"SimpleFlatten", "DualFlatten", "MixedFlatten"}}, - {"oneof_discriminator_oneof_discriminator.pb.go", []string{"FlattenedEvent", "NestedEvent"}}, - } -} - func readGolden(t *testing.T, name string) string { t.Helper() content, err := os.ReadFile(filepath.Join("testdata", "golden", name)) @@ -40,42 +17,6 @@ func readGolden(t *testing.T, name string) string { return string(content) } -// TestK36_AnnotatedMessagesHaveUnmarshalJSONSebuf verifies every annotated message -// gets the UnmarshalJSONSebuf method. -func TestK36_AnnotatedMessagesHaveUnmarshalJSONSebuf(t *testing.T) { - for _, ef := range allEncodingFiles() { - s := readGolden(t, ef.file) - for _, msg := range ef.msgNames { - pattern := "func (x *" + msg + - ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {" - if !strings.Contains(s, pattern) { - t.Errorf("%s: missing UnmarshalJSONSebuf for %s", ef.file, msg) - } - } - } -} - -// TestK37_UnmarshalJSONDelegatesToSebuf verifies every annotated message's UnmarshalJSON -// is a thin wrapper that delegates to UnmarshalJSONSebuf. -func TestK37_UnmarshalJSONDelegatesToSebuf(t *testing.T) { - for _, ef := range allEncodingFiles() { - s := readGolden(t, ef.file) - for _, msg := range ef.msgNames { - pattern := "return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})" - wrapperSig := "func (x *" + msg + ") UnmarshalJSON(data []byte) error {" - if !strings.Contains(s, wrapperSig) { - t.Errorf("%s: missing UnmarshalJSON wrapper for %s", ef.file, msg) - } - if !strings.Contains(s, pattern) { - t.Errorf( - "%s: UnmarshalJSON doesn't delegate to UnmarshalJSONSebuf for %s", - ef.file, msg, - ) - } - } - } -} - // TestSebufUnmarshalerInterfaceDefined verifies the interface is present in all client files. func TestSebufUnmarshalerInterfaceDefined(t *testing.T) { clientFiles := []string{ @@ -236,52 +177,6 @@ func TestSSEEventStreamDiscardSupport(t *testing.T) { } } -// TestOptsForwardingToChildren verifies wrapper, flatten, and oneof generators -// forward opts to nested children. -func TestOptsForwardingToChildren(t *testing.T) { - files := []struct { - file string - name string - }{ - {"int64_nested_encoding_encoding.pb.go", "wrapper"}, - {"flatten_flatten.pb.go", "flatten"}, - {"oneof_discriminator_oneof_discriminator.pb.go", "oneof_discriminator"}, - } - - for _, f := range files { - s := readGolden(t, f.file) - pattern := "UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error" - if !strings.Contains(s, pattern) { - t.Errorf("%s not checking child for UnmarshalJSONSebuf", f.name) - } - } -} - -// TestI32_UnmarshalJSONStillPresent verifies backward compat with json.Unmarshaler. -func TestI32_UnmarshalJSONStillPresent(t *testing.T) { - encodingFiles := []encodingFileSpec{ - {"int64_encoding_encoding.pb.go", []string{"Int64EncodingTest"}}, - {"nullable_nullable.pb.go", []string{"User"}}, - {"timestamp_format_timestamp_format.pb.go", []string{"TimestampFormatTest"}}, - {"bytes_encoding_bytes_encoding.pb.go", []string{"BytesEncodingTest"}}, - {"flatten_flatten.pb.go", []string{"SimpleFlatten", "DualFlatten", "MixedFlatten"}}, - {"oneof_discriminator_oneof_discriminator.pb.go", []string{"FlattenedEvent", "NestedEvent"}}, - } - - for _, ef := range encodingFiles { - s := readGolden(t, ef.file) - for _, msg := range ef.msgNames { - wrapperSig := "func (x *" + msg + ") UnmarshalJSON(data []byte) error {" - if !strings.Contains(s, wrapperSig) { - t.Errorf( - "%s: %s missing UnmarshalJSON (json.Unmarshaler compat)", - ef.file, msg, - ) - } - } - } -} - // TestRepeatedQueryParamsUseAddNotSet verifies that repeated fields (including // repeated enums) use for-range + queryParams.Add() instead of scalar zero-value // checks + queryParams.Set(). Regression test for #186. @@ -340,18 +235,6 @@ func TestRepeatedQueryParamsUseAddNotSet(t *testing.T) { } } -// TestEnumTypesNoUnmarshalJSONSebuf verifies enums don't get the new interface. -func TestEnumTypesNoUnmarshalJSONSebuf(t *testing.T) { - s := readGolden(t, "enum_encoding_enum_encoding.pb.go") - - if strings.Contains(s, "UnmarshalJSONSebuf") { - t.Error("enum encoding should NOT have UnmarshalJSONSebuf") - } - if !strings.Contains(s, "func (x *Status) UnmarshalJSON(data []byte) error {") { - t.Error("enum encoding missing UnmarshalJSON") - } -} - // TestForwardCompatIntegration is an end-to-end integration test that generates code, // creates a temporary Go module with httptest-based tests, and runs them. // This covers test groups A-J from the test matrix. diff --git a/internal/clientgen/generator.go b/internal/clientgen/generator.go index fae8595d..04fb490a 100644 --- a/internal/clientgen/generator.go +++ b/internal/clientgen/generator.go @@ -49,47 +49,11 @@ func (g *Generator) generateFile(file *protogen.File) error { return fmt.Errorf("enum annotation validation failed: %w", err) } - // Generate nullable encoding file if there are messages with nullable fields - if err := g.generateNullableEncodingFile(file); err != nil { - return err - } - - // Generate empty_behavior encoding file if there are messages with empty_behavior fields - if err := g.generateEmptyBehaviorEncodingFile(file); err != nil { - return err - } - - // Generate timestamp_format encoding file if there are messages with timestamp format annotations - if err := g.generateTimestampFormatEncodingFile(file); err != nil { - return err - } - - // Generate bytes_encoding file if there are messages with non-default bytes encoding - if err := g.generateBytesEncodingFile(file); err != nil { - return err - } - - // Generate flatten file if there are messages with flatten annotations - if err := g.generateFlattenFile(file); err != nil { - return err - } - - // Generate oneof_discriminator file if there are messages with oneof_config annotations - if err := g.generateOneofDiscriminatorFile(file); err != nil { - return err - } - - // Generate encoding file if there are messages with int64_encoding=NUMBER annotations. - // This must run before the services guard below: a message carrying the annotation is - // routinely declared in a service-less types file and imported by the file that defines - // the RPC. Skipping it there would leave the imported type without MarshalJSONSebuf, so - // the importing message's transitive wrapper falls back to protojson and the int64 is - // serialized as a quoted string again (issue #217). go-http already generates this file - // unconditionally. - if err := g.generateInt64EncodingFile(file); err != nil { - return err - } - + // Package-level JSON mapping methods are owned by protoc-gen-go-http. + // protoc-gen-go-client intentionally does not emit them: when both Go plugins target + // the same package, duplicate MarshalJSON/UnmarshalJSON methods and duplicate files + // break generation/compilation. The generated client still uses those methods when + // they are present from go-http, and otherwise falls back to protojson. if len(file.Services) == 0 { return nil } @@ -99,17 +63,6 @@ func (g *Generator) generateFile(file *protogen.File) error { return err } - // Generate enum encoding file if there are enums with custom enum_value annotations - if err := g.generateEnumEncodingFile(file); err != nil { - return err - } - - // Generate enum-field encoding file so the client sends/receives custom enum_value strings - // (protojson emits raw proto value names). Depends on the lookup maps emitted above. - if err := g.generateEnumFieldEncodingFile(file); err != nil { - return err - } - return nil } diff --git a/internal/clientgen/golden_test.go b/internal/clientgen/golden_test.go index 74c9ec23..76e6e4d6 100644 --- a/internal/clientgen/golden_test.go +++ b/internal/clientgen/golden_test.go @@ -70,7 +70,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "int64_encoding.proto", expectedFiles: []string{ "int64_encoding_client.pb.go", - "int64_encoding_encoding.pb.go", }, }, { @@ -78,7 +77,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "int64_nested_encoding.proto", expectedFiles: []string{ "int64_nested_encoding_client.pb.go", - "int64_nested_encoding_encoding.pb.go", }, }, { @@ -87,8 +85,6 @@ func TestClientGenGoldenFiles(t *testing.T) { extraProtoFiles: []string{"int64_cross_file_reading.proto"}, expectedFiles: []string{ "int64_cross_file_response_client.pb.go", - "int64_cross_file_response_encoding.pb.go", - "int64_cross_file_reading_encoding.pb.go", }, }, { @@ -96,7 +92,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "int64_deep_nested_encoding.proto", expectedFiles: []string{ "int64_deep_nested_encoding_client.pb.go", - "int64_deep_nested_encoding_encoding.pb.go", }, }, { @@ -104,8 +99,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "enum_encoding.proto", expectedFiles: []string{ "enum_encoding_client.pb.go", - "enum_encoding_enum_encoding.pb.go", - "enum_encoding_enum_field_encoding.pb.go", }, }, { @@ -113,8 +106,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "enum_nested.proto", expectedFiles: []string{ "enum_nested_client.pb.go", - "enum_nested_enum_encoding.pb.go", - "enum_nested_enum_field_encoding.pb.go", }, }, { @@ -122,7 +113,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "nullable.proto", expectedFiles: []string{ "nullable_client.pb.go", - "nullable_nullable.pb.go", }, }, { @@ -130,7 +120,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "empty_behavior.proto", expectedFiles: []string{ "empty_behavior_client.pb.go", - "empty_behavior_empty_behavior.pb.go", }, }, { @@ -145,7 +134,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "timestamp_format.proto", expectedFiles: []string{ "timestamp_format_client.pb.go", - "timestamp_format_timestamp_format.pb.go", }, }, { @@ -153,7 +141,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "bytes_encoding.proto", expectedFiles: []string{ "bytes_encoding_client.pb.go", - "bytes_encoding_bytes_encoding.pb.go", }, }, { @@ -161,7 +148,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "flatten.proto", expectedFiles: []string{ "flatten_client.pb.go", - "flatten_flatten.pb.go", }, }, { @@ -169,7 +155,6 @@ func TestClientGenGoldenFiles(t *testing.T) { protoFile: "oneof_discriminator.proto", expectedFiles: []string{ "oneof_discriminator_client.pb.go", - "oneof_discriminator_oneof_discriminator.pb.go", }, }, { @@ -345,6 +330,77 @@ func diffStrings(expected, actual string) string { // TestGeneratedClientCodeCompiles verifies that generated code compiles correctly. // This is an integration test that runs the actual compiler. +func TestCombinedGoHTTPAndGoClientGenerationDoesNotDuplicateJSONMappingFiles(t *testing.T) { + if _, lookErr := exec.LookPath("protoc"); lookErr != nil { + t.Skip("protoc not found, skipping combined generation test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + httpPluginPath := filepath.Join(projectRoot, "bin", "protoc-gen-go-http") + clientPluginPath := filepath.Join(projectRoot, "bin", "protoc-gen-go-client") + + for _, build := range []struct { + path string + pkg string + }{ + {httpPluginPath, "./cmd/protoc-gen-go-http"}, + {clientPluginPath, "./cmd/protoc-gen-go-client"}, + } { + buildCmd := exec.Command("go", "build", "-o", build.path, build.pkg) + buildCmd.Dir = projectRoot + if buildErr := buildCmd.Run(); buildErr != nil { + t.Fatalf("Failed to build %s: %v", build.pkg, buildErr) + } + } + + tempDir := t.TempDir() + cmd := exec.Command("protoc", + "--plugin=protoc-gen-go-http="+httpPluginPath, + "--plugin=protoc-gen-go-client="+clientPluginPath, + "--go_out="+tempDir, + "--go_opt=paths=source_relative", + "--go-http_out="+tempDir, + "--go-http_opt=paths=source_relative", + "--go-client_out="+tempDir, + "--go-client_opt=paths=source_relative", + "--proto_path="+protoDir, + "--proto_path="+filepath.Join(projectRoot, "proto"), + "int64_deep_nested_encoding.proto", + "enum_encoding.proto", + "flatten.proto", + ) + cmd.Dir = protoDir + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if runErr := cmd.Run(); runErr != nil { + t.Fatalf("combined go-http + go-client generation failed: %v\nstderr: %s", runErr, stderr.String()) + } + + unexpectedClientOwnedMappingFiles := []string{ + "int64_deep_nested_encoding_encoding.pb.go", + "enum_encoding_enum_encoding.pb.go", + "enum_encoding_enum_field_encoding.pb.go", + "flatten_flatten.pb.go", + } + for _, filename := range unexpectedClientOwnedMappingFiles { + content, readErr := os.ReadFile(filepath.Join(tempDir, filename)) + if readErr != nil { + t.Fatalf("expected go-http-owned JSON mapping file %s to exist: %v", filename, readErr) + } + if strings.Contains(string(content), "protoc-gen-go-client") { + t.Fatalf("JSON mapping file %s should be owned by protoc-gen-go-http, not protoc-gen-go-client", filename) + } + } +} + func TestGeneratedClientCodeCompiles(t *testing.T) { // Skip if protoc is not available if _, lookErr := exec.LookPath("protoc"); lookErr != nil { diff --git a/internal/clientgen/nullable.go b/internal/clientgen/nullable.go deleted file mode 100644 index de01cba5..00000000 --- a/internal/clientgen/nullable.go +++ /dev/null @@ -1,224 +0,0 @@ -package clientgen - -import ( - "strings" - - "google.golang.org/protobuf/compiler/protogen" - - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// NullableContext holds information about messages that need custom JSON encoding -// for nullable primitive fields. -type NullableContext struct { - Message *protogen.Message - NullableFields []*protogen.Field -} - -// hasNullableFields returns true if any field in the message has nullable=true. -func hasNullableFields(message *protogen.Message) bool { - for _, field := range message.Fields { - if annotations.IsNullableField(field) { - return true - } - } - return false -} - -// getNullableFields returns all fields with nullable=true annotation. -func getNullableFields(message *protogen.Message) []*protogen.Field { - var fields []*protogen.Field - for _, field := range message.Fields { - if annotations.IsNullableField(field) { - fields = append(fields, field) - } - } - return fields -} - -// collectNullableContext analyzes messages in a file and collects nullable field information. -func collectNullableContext(file *protogen.File) []*NullableContext { - var contexts []*NullableContext - collectNullableMessages(file.Messages, &contexts) - return contexts -} - -// collectNullableMessages recursively collects messages with nullable fields. -func collectNullableMessages(messages []*protogen.Message, contexts *[]*NullableContext) { - for _, msg := range messages { - if hasNullableFields(msg) { - *contexts = append(*contexts, &NullableContext{ - Message: msg, - NullableFields: getNullableFields(msg), - }) - } - // Check nested messages - collectNullableMessages(msg.Messages, contexts) - } -} - -// validateNullableAnnotations validates all nullable annotations in a file. -// Returns the first validation error encountered, or nil if all valid. -func validateNullableAnnotations(file *protogen.File) error { - return validateNullableInMessages(file.Messages) -} - -func validateNullableInMessages(messages []*protogen.Message) error { - for _, msg := range messages { - for _, field := range msg.Fields { - if err := annotations.ValidateNullableAnnotation(field, msg.GoIdent.GoName); err != nil { - return err - } - } - // Validate nested messages - if err := validateNullableInMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -// generateNullableEncodingFile generates the *_nullable.pb.go file if needed. -func (g *Generator) generateNullableEncodingFile(file *protogen.File) error { - // First validate all nullable annotations - if err := validateNullableAnnotations(file); err != nil { - return err - } - - contexts := collectNullableContext(file) - if len(contexts) == 0 { - return nil - } - - filename := file.GeneratedFilenamePrefix + "_nullable.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeHeader(gf, file) - g.writeNullableImports(gf) - - for _, ctx := range contexts { - g.generateNullableMarshalJSON(gf, ctx) - g.generateNullableUnmarshalJSON(gf, ctx) - } - - return nil -} - -func (g *Generator) writeNullableImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P(`"encoding/json"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// generateNullableMarshalJSON generates MarshalJSON that emits null for unset nullable fields. -func (g *Generator) generateNullableMarshalJSON(gf *protogen.GeneratedFile, ctx *NullableContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.NullableFields { - fieldNames = append(fieldNames, string(f.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles nullable fields: ", strings.Join(fieldNames, ", ")) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to handle nullable fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - // For each nullable field, emit null when not set - for _, field := range ctx.NullableFields { - jsonName := field.Desc.JSONName() - goName := field.GoName - - gf.P("// Handle nullable field: ", field.Desc.Name()) - gf.P("// proto3 optional + nullable=true: emit null when not set") - gf.P("if x.", goName, " == nil {") - gf.P(`raw["`, jsonName, `"] = []byte("null")`) - gf.P("}") - gf.P() - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateNullableUnmarshalJSON generates UnmarshalJSON that accepts null for nullable fields. -func (g *Generator) generateNullableUnmarshalJSON(gf *protogen.GeneratedFile, ctx *NullableContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.NullableFields { - fieldNames = append(fieldNames, string(f.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles nullable fields: ", strings.Join(fieldNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// Parse to check for explicit null values on nullable fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - // For nullable fields, remove explicit nulls before protojson unmarshal - // protojson doesn't handle null for scalar optionals, so we remove them - for _, field := range ctx.NullableFields { - jsonName := field.Desc.JSONName() - - gf.P("// Handle nullable field: ", field.Desc.Name()) - gf.P("// Remove explicit null so protojson leaves field unset") - gf.P(`if rawVal, ok := raw["`, jsonName, `"]; ok && string(rawVal) == "null" {`) - gf.P(`delete(raw, "`, jsonName, `")`) - gf.P("}") - gf.P() - } - - gf.P("// Re-marshal without nulls for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} diff --git a/internal/clientgen/oneof_discriminator.go b/internal/clientgen/oneof_discriminator.go deleted file mode 100644 index 4b18ce8b..00000000 --- a/internal/clientgen/oneof_discriminator.go +++ /dev/null @@ -1,431 +0,0 @@ -package clientgen - -import ( - "fmt" - "strings" - - "google.golang.org/protobuf/compiler/protogen" - - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// OneofDiscriminatorContext holds information about a message that needs custom JSON encoding -// for oneof fields with discriminator annotations. -type OneofDiscriminatorContext struct { - // Message is the message that needs custom marshal/unmarshal - Message *protogen.Message - // Oneofs are the annotated oneof fields with their resolved discriminator info - Oneofs []*annotations.OneofDiscriminatorInfo -} - -// hasOneofDiscriminator returns true if any oneof in the message has a discriminator annotation. -func hasOneofDiscriminator(message *protogen.Message) bool { - return annotations.HasOneofDiscriminator(message) -} - -// collectOneofDiscriminatorContext analyzes messages in a file and collects oneof discriminator info. -func collectOneofDiscriminatorContext(file *protogen.File) []*OneofDiscriminatorContext { - var contexts []*OneofDiscriminatorContext - collectOneofDiscriminatorMessages(file.Messages, &contexts) - return contexts -} - -// collectOneofDiscriminatorMessages recursively collects messages with oneof discriminator annotations. -func collectOneofDiscriminatorMessages(messages []*protogen.Message, contexts *[]*OneofDiscriminatorContext) { - for _, msg := range messages { - if hasOneofDiscriminator(msg) { - var oneofs []*annotations.OneofDiscriminatorInfo - for _, oneof := range msg.Oneofs { - info := annotations.GetOneofDiscriminatorInfo(oneof) - if info != nil { - oneofs = append(oneofs, info) - } - } - if len(oneofs) > 0 { - *contexts = append(*contexts, &OneofDiscriminatorContext{ - Message: msg, - Oneofs: oneofs, - }) - } - } - // Check nested messages - collectOneofDiscriminatorMessages(msg.Messages, contexts) - } -} - -// validateOneofDiscriminatorAnnotations validates all oneof discriminator annotations in a file. -// Returns the first validation error encountered, or nil if all valid. -func validateOneofDiscriminatorAnnotations(file *protogen.File) error { - return validateOneofDiscriminatorInMessages(file.Messages) -} - -// validateOneofDiscriminatorInMessages recursively validates oneof discriminator annotations. -func validateOneofDiscriminatorInMessages(messages []*protogen.Message) error { - for _, msg := range messages { - for _, oneof := range msg.Oneofs { - config := annotations.GetOneofConfig(oneof) - if config == nil { - continue - } - if err := annotations.ValidateOneofDiscriminator(msg, oneof, config); err != nil { - return err - } - } - if err := validateOneofDiscriminatorInMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -// checkMarshalJSONConflict checks whether a message that needs oneof MarshalJSON -// also requires MarshalJSON from another encoding feature (conflict = generation error). -func checkMarshalJSONConflict(message *protogen.Message) error { - var conflicts []string - - if hasInt64NumberFields(message) { - conflicts = append(conflicts, "int64_encoding=NUMBER") - } - if hasNullableFields(message) { - conflicts = append(conflicts, "nullable") - } - if hasEmptyBehaviorFields(message) { - conflicts = append(conflicts, "empty_behavior") - } - if hasTimestampFormatFields(message) { - conflicts = append(conflicts, "timestamp_format") - } - if hasBytesEncodingFields(message) { - conflicts = append(conflicts, "bytes_encoding") - } - if hasCustomEnumFields(message) { - conflicts = append(conflicts, "enum_value") - } - - if len(conflicts) > 0 { - return fmt.Errorf( - "message %s: oneof_config requires MarshalJSON but conflicts with %s (also requires MarshalJSON)", - message.GoIdent.GoName, strings.Join(conflicts, ", "), - ) - } - - return nil -} - -// generateOneofDiscriminatorFile generates the *_oneof_discriminator.pb.go file if needed. -// This is identical to the httpgen implementation to ensure server/client consistency. -// - -func (g *Generator) generateOneofDiscriminatorFile(file *protogen.File) error { - // Validate annotations first - if err := validateOneofDiscriminatorAnnotations(file); err != nil { - return err - } - - contexts := collectOneofDiscriminatorContext(file) - if len(contexts) == 0 { - return nil - } - - // Check for MarshalJSON conflicts - for _, ctx := range contexts { - if err := checkMarshalJSONConflict(ctx.Message); err != nil { - return err - } - } - - filename := file.GeneratedFilenamePrefix + "_oneof_discriminator.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeEncodingHeader(gf, file) - g.writeOneofDiscriminatorImports(gf) - - for _, ctx := range contexts { - g.generateOneofMarshalJSON(gf, ctx) - g.generateOneofUnmarshalJSON(gf, ctx) - } - - return nil -} - -// writeOneofDiscriminatorImports writes the imports needed for oneof discriminator encoding. -func (g *Generator) writeOneofDiscriminatorImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P(`"encoding/json"`) - gf.P(`"fmt"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// generateOneofMarshalJSON generates MarshalJSON that adds discriminator fields for annotated oneofs. -// This is identical to the httpgen implementation to ensure server/client consistency. -// -//nolint:dupl // Code generation patterns naturally have similar structure across encoding types -func (g *Generator) generateOneofMarshalJSON(gf *protogen.GeneratedFile, ctx *OneofDiscriminatorContext) { - msgName := ctx.Message.GoIdent.GoName - - var oneofNames []string - for _, info := range ctx.Oneofs { - oneofNames = append(oneofNames, string(info.Oneof.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles oneof discriminator fields: ", strings.Join(oneofNames, ", ")) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to add discriminator fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - for _, info := range ctx.Oneofs { - g.generateOneofMarshalVariants(gf, info) - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateOneofMarshalVariants generates the marshal switch logic for a single discriminated oneof. -func (g *Generator) generateOneofMarshalVariants(gf *protogen.GeneratedFile, info *annotations.OneofDiscriminatorInfo) { - oneofGoName := info.Oneof.GoName - - gf.P("// Handle oneof ", info.Oneof.Desc.Name(), " with discriminator \"", info.Discriminator, "\"") - gf.P("switch x.Get", oneofGoName, "().(type) {") - - for _, variant := range info.Variants { - wrapperType := variant.Field.GoIdent.GoName - gf.P("case *", wrapperType, ":") - - // Add discriminator value - gf.P(`raw["`, info.Discriminator, `"], _ = json.Marshal("`, variant.DiscriminatorVal, `")`) - - if info.Flatten && variant.IsMessage { - g.generateFlattenedMarshal(gf, variant) - } - // Non-flattened: protojson already puts variant under its field name, just add discriminator - } - - gf.P("default:") - gf.P("// Oneof not set: omit discriminator entirely") - gf.P("}") - gf.P() -} - -// generateFlattenedMarshal generates flattened marshal code for a single variant. -// It merges the variant's child fields into the parent map and removes the wrapper key. -func (g *Generator) generateFlattenedMarshal( - gf *protogen.GeneratedFile, - variant annotations.OneofVariant, -) { - fieldGoName := variant.Field.GoName - fieldJSONName := variant.Field.Desc.JSONName() - - gf.P("// Flatten: forward opts to variant via MarshalJSONSebuf when available") - gf.P("if inner := x.Get", fieldGoName, "(); inner != nil {") - gf.P("var variantData []byte") - gf.P("var varErr error") - gf.P( - "if m, ok := any(inner).(interface{ MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) }); ok {", - ) - gf.P("variantData, varErr = m.MarshalJSONSebuf(opts)") - gf.P("} else {") - gf.P("variantData, varErr = opts.Marshal(inner)") - gf.P("}") - gf.P("if varErr == nil {") - gf.P("var variantMap map[string]json.RawMessage") - gf.P("if json.Unmarshal(variantData, &variantMap) == nil {") - gf.P("// Merge variant fields into parent") - gf.P("for fk, fv := range variantMap {") - gf.P("raw[fk] = fv") - gf.P("}") - gf.P("}") - gf.P("}") - - // Remove the wrapper key that protojson added - gf.P("delete(raw, \"", fieldJSONName, "\")") - gf.P("}") -} - -// generateOneofUnmarshalJSON generates UnmarshalJSON that reads discriminator fields -// and routes to the correct variant. -// This is identical to the httpgen implementation to ensure server/client consistency. -// - -func (g *Generator) generateOneofUnmarshalJSON(gf *protogen.GeneratedFile, ctx *OneofDiscriminatorContext) { - msgName := ctx.Message.GoIdent.GoName - - var oneofNames []string - for _, info := range ctx.Oneofs { - oneofNames = append(oneofNames, string(info.Oneof.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles oneof discriminator fields: ", strings.Join(oneofNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// Parse into a map to read discriminator fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - for _, info := range ctx.Oneofs { - g.generateOneofUnmarshalVariants(gf, info) - } - - gf.P("// Remove discriminator fields before protojson unmarshal") - for _, info := range ctx.Oneofs { - gf.P(`delete(raw, "`, info.Discriminator, `")`) - } - gf.P() - - gf.P("// Re-marshal remaining fields for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateOneofUnmarshalVariants generates the unmarshal switch logic for a single discriminated oneof. -// - -func (g *Generator) generateOneofUnmarshalVariants( - gf *protogen.GeneratedFile, - info *annotations.OneofDiscriminatorInfo, -) { - gf.P("// Read discriminator for oneof ", info.Oneof.Desc.Name()) - gf.P(`if discRaw, ok := raw["`, info.Discriminator, `"]; ok {`) - gf.P("var disc string") - gf.P("if err := json.Unmarshal(discRaw, &disc); err != nil {") - gf.P(`return fmt.Errorf("invalid discriminator %q: %%w", "`, info.Discriminator, `", err)`) - gf.P("}") - gf.P() - - gf.P("switch disc {") - - for _, variant := range info.Variants { - gf.P(`case "`, variant.DiscriminatorVal, `":`) - - if info.Flatten && variant.IsMessage { - g.generateFlattenedUnmarshal(gf, variant, info) - } else if variant.IsMessage { - g.generateNestedUnmarshal(gf, variant, info) - } - // Scalar variants in non-flattened mode: protojson handles them - } - - gf.P("}") - gf.P("}") - gf.P() -} - -// generateFlattenedUnmarshal generates flattened unmarshal code for a single variant. -// It extracts variant fields from the flat map, constructs the variant, and sets it. -func (g *Generator) generateFlattenedUnmarshal( - gf *protogen.GeneratedFile, - variant annotations.OneofVariant, - info *annotations.OneofDiscriminatorInfo, -) { - fieldGoName := variant.Field.GoName - wrapperType := variant.Field.GoIdent.GoName - msgType := variant.Field.Message.GoIdent.GoName - fieldJSONName := variant.Field.Desc.JSONName() - - // Collect all child field JSON names for this variant - var childJSONNames []string - for _, childField := range variant.Field.Message.Fields { - childJSONNames = append(childJSONNames, childField.Desc.JSONName()) - } - - gf.P("// Flatten unmarshal: extract ", fieldGoName, " fields from flat map") - gf.P("variantMap := make(map[string]json.RawMessage)") - - // Move child fields from the parent map into the variant map - for _, childJSON := range childJSONNames { - gf.P(`if fv, exists := raw["`, childJSON, `"]; exists {`) - gf.P(`variantMap["`, childJSON, `"] = fv`) - gf.P(`delete(raw, "`, childJSON, `")`) - gf.P("}") - } - - gf.P("variantData, _ := json.Marshal(variantMap)") - gf.P("variant := &", msgType, "{}") - gf.P("if u, ok := any(variant).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {") - gf.P("if err := u.UnmarshalJSONSebuf(variantData, opts); err != nil {") - gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %%w", "`, fieldGoName, `", err)`) - gf.P("}") - gf.P("} else if err := json.Unmarshal(variantData, variant); err != nil {") - gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %%w", "`, fieldGoName, `", err)`) - gf.P("}") - gf.P("x.", info.Oneof.GoName, " = &", wrapperType, "{", fieldGoName, ": variant}") - - // Add the variant back to raw under its original field name for protojson - // (protojson expects the oneof wrapper format) - gf.P(`raw["`, fieldJSONName, `"], _ = json.Marshal(variant)`) -} - -// generateNestedUnmarshal generates non-flattened unmarshal code for a message variant. -// For non-flattened mode, the variant is already nested under its field name. -// We use json.Unmarshal to invoke the child's UnmarshalJSON if it has one. -func (g *Generator) generateNestedUnmarshal( - gf *protogen.GeneratedFile, - variant annotations.OneofVariant, - info *annotations.OneofDiscriminatorInfo, -) { - fieldGoName := variant.Field.GoName - fieldJSONName := variant.Field.Desc.JSONName() - wrapperType := variant.Field.GoIdent.GoName - msgType := variant.Field.Message.GoIdent.GoName - - gf.P("// Non-flattened unmarshal: forward opts to child's UnmarshalJSONSebuf if available") - gf.P(`if variantRaw, exists := raw["`, fieldJSONName, `"]; exists {`) - gf.P("variant := &", msgType, "{}") - gf.P("if u, ok := any(variant).(interface{ UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error }); ok {") - gf.P("if err := u.UnmarshalJSONSebuf(variantRaw, opts); err != nil {") - gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %%w", "`, fieldGoName, `", err)`) - gf.P("}") - gf.P("} else if err := json.Unmarshal(variantRaw, variant); err != nil {") - gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %%w", "`, fieldGoName, `", err)`) - gf.P("}") - gf.P("x.", info.Oneof.GoName, " = &", wrapperType, "{", fieldGoName, ": variant}") - gf.P("}") -} diff --git a/internal/clientgen/testdata/golden/bytes_encoding_bytes_encoding.pb.go b/internal/clientgen/testdata/golden/bytes_encoding_bytes_encoding.pb.go deleted file mode 100644 index f7eb7732..00000000 --- a/internal/clientgen/testdata/golden/bytes_encoding_bytes_encoding.pb.go +++ /dev/null @@ -1,127 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: bytes_encoding.proto - -package bytesencoding - -import ( - "encoding/base64" - "encoding/hex" - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for BytesEncodingTest. -// This method handles bytes_encoding fields: base64_raw_data, base64url_data, base64url_raw_data, hex_data -func (x *BytesEncodingTest) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify bytes-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Encode base64_raw_data with BYTES_ENCODING_BASE64_RAW - if len(x.Base64RawData) > 0 { - raw["base64RawData"], _ = json.Marshal(base64.RawStdEncoding.EncodeToString(x.Base64RawData)) - } - - // Encode base64url_data with BYTES_ENCODING_BASE64URL - if len(x.Base64UrlData) > 0 { - raw["base64urlData"], _ = json.Marshal(base64.URLEncoding.EncodeToString(x.Base64UrlData)) - } - - // Encode base64url_raw_data with BYTES_ENCODING_BASE64URL_RAW - if len(x.Base64UrlRawData) > 0 { - raw["base64urlRawData"], _ = json.Marshal(base64.RawURLEncoding.EncodeToString(x.Base64UrlRawData)) - } - - // Encode hex_data with BYTES_ENCODING_HEX - if len(x.HexData) > 0 { - raw["hexData"], _ = json.Marshal(hex.EncodeToString(x.HexData)) - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for BytesEncodingTest. -func (x *BytesEncodingTest) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for BytesEncodingTest. -// This method handles bytes_encoding fields: base64_raw_data, base64url_data, base64url_raw_data, hex_data -func (x *BytesEncodingTest) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse the raw JSON to extract bytes-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Decode base64_raw_data from BYTES_ENCODING_BASE64_RAW to standard base64 - if v, ok := raw["base64RawData"]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil { - decoded, decErr := base64.RawStdEncoding.DecodeString(s) - if decErr == nil { - raw["base64RawData"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded)) - } - } - } - - // Decode base64url_data from BYTES_ENCODING_BASE64URL to standard base64 - if v, ok := raw["base64urlData"]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil { - decoded, decErr := base64.URLEncoding.DecodeString(s) - if decErr == nil { - raw["base64urlData"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded)) - } - } - } - - // Decode base64url_raw_data from BYTES_ENCODING_BASE64URL_RAW to standard base64 - if v, ok := raw["base64urlRawData"]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil { - decoded, decErr := base64.RawURLEncoding.DecodeString(s) - if decErr == nil { - raw["base64urlRawData"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded)) - } - } - } - - // Decode hex_data from BYTES_ENCODING_HEX to standard base64 - if v, ok := raw["hexData"]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil { - decoded, decErr := hex.DecodeString(s) - if decErr == nil { - raw["hexData"], _ = json.Marshal(base64.StdEncoding.EncodeToString(decoded)) - } - } - } - - // Re-marshal with standard base64 values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for BytesEncodingTest. -func (x *BytesEncodingTest) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/empty_behavior_empty_behavior.pb.go b/internal/clientgen/testdata/golden/empty_behavior_empty_behavior.pb.go deleted file mode 100644 index 672fa0e3..00000000 --- a/internal/clientgen/testdata/golden/empty_behavior_empty_behavior.pb.go +++ /dev/null @@ -1,95 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: empty_behavior.proto - -package emptybehavior - -import ( - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" -) - -// MarshalJSONSebuf implements sebufMarshaler for Response. -// This method handles empty_behavior fields: metadata_preserve, metadata_null, metadata_omit, settings -func (x *Response) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to handle empty_behavior fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Handle empty_behavior for field: metadata_preserve - if x.MetadataPreserve != nil && proto.Size(x.MetadataPreserve) == 0 { - // EMPTY_BEHAVIOR_PRESERVE: keep as {} (default protojson behavior) - // No action needed - protojson already emits {} - } - - // Handle empty_behavior for field: metadata_null - if x.MetadataNull != nil && proto.Size(x.MetadataNull) == 0 { - // EMPTY_BEHAVIOR_NULL: serialize empty message as null - raw["metadataNull"] = []byte("null") - } - - // Handle empty_behavior for field: metadata_omit - if x.MetadataOmit != nil && proto.Size(x.MetadataOmit) == 0 { - // EMPTY_BEHAVIOR_OMIT: remove field when message is empty - delete(raw, "metadataOmit") - } - - // Handle empty_behavior for field: settings - if x.Settings != nil && proto.Size(x.Settings) == 0 { - // EMPTY_BEHAVIOR_NULL: serialize empty message as null - raw["settings"] = []byte("null") - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Response. -func (x *Response) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Response. -// This method handles empty_behavior fields: metadata_preserve, metadata_null, metadata_omit, settings -func (x *Response) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse to check for explicit null values on empty_behavior=NULL fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle empty_behavior=NULL: convert null to {} for protojson - if rawVal, ok := raw["metadataNull"]; ok && string(rawVal) == "null" { - raw["metadataNull"] = []byte("{}") - } - - // Handle empty_behavior=NULL: convert null to {} for protojson - if rawVal, ok := raw["settings"]; ok && string(rawVal) == "null" { - raw["settings"] = []byte("{}") - } - - // Re-marshal for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Response. -func (x *Response) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/enum_encoding_enum_encoding.pb.go b/internal/clientgen/testdata/golden/enum_encoding_enum_encoding.pb.go deleted file mode 100644 index 9497e814..00000000 --- a/internal/clientgen/testdata/golden/enum_encoding_enum_encoding.pb.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: enum_encoding.proto - -package enumencoding - -import ( - "encoding/json" - "fmt" -) - -var statusToJSON = map[Status]string{ - Status_STATUS_UNSPECIFIED: "unknown", - Status_STATUS_ACTIVE: "active", - Status_STATUS_INACTIVE: "inactive", -} - -var statusFromJSON = map[string]Status{ - "unknown": Status_STATUS_UNSPECIFIED, - "active": Status_STATUS_ACTIVE, - "inactive": Status_STATUS_INACTIVE, - "STATUS_UNSPECIFIED": Status_STATUS_UNSPECIFIED, - "STATUS_ACTIVE": Status_STATUS_ACTIVE, - "STATUS_INACTIVE": Status_STATUS_INACTIVE, -} - -func (x Status) MarshalJSON() ([]byte, error) { - if s, ok := statusToJSON[x]; ok { - return json.Marshal(s) - } - return json.Marshal(x.String()) -} - -func (x *Status) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err == nil { - if v, ok := statusFromJSON[s]; ok { - *x = v - return nil - } - return fmt.Errorf("unknown Status value: %q", s) - } - - var n int32 - if err := json.Unmarshal(data, &n); err == nil { - *x = Status(n) - return nil - } - - return fmt.Errorf("cannot unmarshal %s into Status", string(data)) -} diff --git a/internal/clientgen/testdata/golden/enum_encoding_enum_field_encoding.pb.go b/internal/clientgen/testdata/golden/enum_encoding_enum_field_encoding.pb.go deleted file mode 100644 index 8ceeadc2..00000000 --- a/internal/clientgen/testdata/golden/enum_encoding_enum_field_encoding.pb.go +++ /dev/null @@ -1,193 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: enum_encoding.proto - -package enumencoding - -import ( - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for EnumEncodingTest. -// This method handles enum_value fields and nested messages: status, status_list, optional_status, status_map -func (x *EnumEncodingTest) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to rewrite enum fields and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Rewrite status to custom enum_value strings - for _, k := range []string{"status"} { - v, ok := raw[k] - if !ok { - continue - } - var s string - if err := json.Unmarshal(v, &s); err != nil { - continue - } - if e, ok := statusFromJSON[s]; ok { - raw[k], _ = json.Marshal(statusToJSON[e]) - } - } - - // Rewrite status_list to custom enum_value strings - for _, k := range []string{"statusList", "status_list"} { - v, ok := raw[k] - if !ok { - continue - } - var arr []string - if err := json.Unmarshal(v, &arr); err != nil { - continue - } - for i, s := range arr { - if e, ok := statusFromJSON[s]; ok { - arr[i] = statusToJSON[e] - } - } - raw[k], _ = json.Marshal(arr) - } - - // Rewrite optional_status to custom enum_value strings - for _, k := range []string{"optionalStatus", "optional_status"} { - v, ok := raw[k] - if !ok { - continue - } - var s string - if err := json.Unmarshal(v, &s); err != nil { - continue - } - if e, ok := statusFromJSON[s]; ok { - raw[k], _ = json.Marshal(statusToJSON[e]) - } - } - - // Rewrite status_map to custom enum_value strings - for _, k := range []string{"statusMap", "status_map"} { - v, ok := raw[k] - if !ok { - continue - } - var m map[string]string - if err := json.Unmarshal(v, &m); err != nil { - continue - } - for mk, s := range m { - if e, ok := statusFromJSON[s]; ok { - m[mk] = statusToJSON[e] - } - } - raw[k], _ = json.Marshal(m) - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for EnumEncodingTest. -func (x *EnumEncodingTest) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for EnumEncodingTest. -// This method handles enum_value fields and nested messages: status, status_list, optional_status, status_map -func (x *EnumEncodingTest) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse the raw JSON to rewrite custom enum_value strings and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Rewrite status from custom enum_value strings to proto names - for _, k := range []string{"status"} { - v, ok := raw[k] - if !ok { - continue - } - var s string - if err := json.Unmarshal(v, &s); err != nil { - continue - } - if e, ok := statusFromJSON[s]; ok { - raw[k], _ = json.Marshal(e.String()) - } - } - - // Rewrite status_list from custom enum_value strings to proto names - for _, k := range []string{"statusList", "status_list"} { - v, ok := raw[k] - if !ok { - continue - } - var arr []string - if err := json.Unmarshal(v, &arr); err != nil { - continue - } - for i, s := range arr { - if e, ok := statusFromJSON[s]; ok { - arr[i] = e.String() - } - } - raw[k], _ = json.Marshal(arr) - } - - // Rewrite optional_status from custom enum_value strings to proto names - for _, k := range []string{"optionalStatus", "optional_status"} { - v, ok := raw[k] - if !ok { - continue - } - var s string - if err := json.Unmarshal(v, &s); err != nil { - continue - } - if e, ok := statusFromJSON[s]; ok { - raw[k], _ = json.Marshal(e.String()) - } - } - - // Rewrite status_map from custom enum_value strings to proto names - for _, k := range []string{"statusMap", "status_map"} { - v, ok := raw[k] - if !ok { - continue - } - var m map[string]string - if err := json.Unmarshal(v, &m); err != nil { - continue - } - for mk, s := range m { - if e, ok := statusFromJSON[s]; ok { - m[mk] = e.String() - } - } - raw[k], _ = json.Marshal(m) - } - - // Re-marshal with proto value names for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for EnumEncodingTest. -func (x *EnumEncodingTest) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/enum_nested_enum_encoding.pb.go b/internal/clientgen/testdata/golden/enum_nested_enum_encoding.pb.go deleted file mode 100644 index 60a2ba05..00000000 --- a/internal/clientgen/testdata/golden/enum_nested_enum_encoding.pb.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: enum_nested.proto - -package enumnested - -import ( - "encoding/json" - "fmt" -) - -var gradeToJSON = map[Grade]string{ - Grade_GRADE_UNSPECIFIED: "GRADE_UNSPECIFIED", - Grade_GRADE_A: "a", - Grade_GRADE_B: "b", -} - -var gradeFromJSON = map[string]Grade{ - "GRADE_UNSPECIFIED": Grade_GRADE_UNSPECIFIED, - "a": Grade_GRADE_A, - "b": Grade_GRADE_B, - "GRADE_A": Grade_GRADE_A, - "GRADE_B": Grade_GRADE_B, -} - -func (x Grade) MarshalJSON() ([]byte, error) { - if s, ok := gradeToJSON[x]; ok { - return json.Marshal(s) - } - return json.Marshal(x.String()) -} - -func (x *Grade) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err == nil { - if v, ok := gradeFromJSON[s]; ok { - *x = v - return nil - } - return fmt.Errorf("unknown Grade value: %q", s) - } - - var n int32 - if err := json.Unmarshal(data, &n); err == nil { - *x = Grade(n) - return nil - } - - return fmt.Errorf("cannot unmarshal %s into Grade", string(data)) -} diff --git a/internal/clientgen/testdata/golden/enum_nested_enum_field_encoding.pb.go b/internal/clientgen/testdata/golden/enum_nested_enum_field_encoding.pb.go deleted file mode 100644 index 190fe9e5..00000000 --- a/internal/clientgen/testdata/golden/enum_nested_enum_field_encoding.pb.go +++ /dev/null @@ -1,375 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: enum_nested.proto - -package enumnested - -import ( - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for Item. -// This method handles enum_value fields and nested messages: grade, grades -func (x *Item) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to rewrite enum fields and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Rewrite grade to custom enum_value strings - for _, k := range []string{"grade"} { - v, ok := raw[k] - if !ok { - continue - } - var s string - if err := json.Unmarshal(v, &s); err != nil { - continue - } - if e, ok := gradeFromJSON[s]; ok { - raw[k], _ = json.Marshal(gradeToJSON[e]) - } - } - - // Rewrite grades to custom enum_value strings - for _, k := range []string{"grades"} { - v, ok := raw[k] - if !ok { - continue - } - var arr []string - if err := json.Unmarshal(v, &arr); err != nil { - continue - } - for i, s := range arr { - if e, ok := gradeFromJSON[s]; ok { - arr[i] = gradeToJSON[e] - } - } - raw[k], _ = json.Marshal(arr) - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Item. -func (x *Item) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Item. -// This method handles enum_value fields and nested messages: grade, grades -func (x *Item) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse the raw JSON to rewrite custom enum_value strings and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Rewrite grade from custom enum_value strings to proto names - for _, k := range []string{"grade"} { - v, ok := raw[k] - if !ok { - continue - } - var s string - if err := json.Unmarshal(v, &s); err != nil { - continue - } - if e, ok := gradeFromJSON[s]; ok { - raw[k], _ = json.Marshal(e.String()) - } - } - - // Rewrite grades from custom enum_value strings to proto names - for _, k := range []string{"grades"} { - v, ok := raw[k] - if !ok { - continue - } - var arr []string - if err := json.Unmarshal(v, &arr); err != nil { - continue - } - for i, s := range arr { - if e, ok := gradeFromJSON[s]; ok { - arr[i] = e.String() - } - } - raw[k], _ = json.Marshal(arr) - } - - // Re-marshal with proto value names for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Item. -func (x *Item) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for ItemGroup. -// This method handles enum_value fields and nested messages: lead_item, item_list -func (x *ItemGroup) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to rewrite enum fields and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "leadItem" forwarding opts when child supports MarshalJSONSebuf - if x.LeadItem != nil { - if m, ok := any(x.LeadItem).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - childData, childErr := m.MarshalJSONSebuf(opts) - if childErr != nil { - return nil, childErr - } - for _, k := range []string{"leadItem", "lead_item"} { - if _, ok := raw[k]; ok { - raw[k] = childData - } - } - } - } - - // Re-serialize repeated "itemList" forwarding opts to each element - if len(x.ItemList) > 0 { - items := make([]json.RawMessage, 0, len(x.ItemList)) - for _, item := range x.ItemList { - if m, ok := any(item).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - itemData, itemErr := m.MarshalJSONSebuf(opts) - if itemErr != nil { - return nil, itemErr - } - items = append(items, itemData) - } else { - itemData, itemErr := opts.Marshal(item) - if itemErr != nil { - return nil, itemErr - } - items = append(items, itemData) - } - } - listData, listErr := json.Marshal(items) - if listErr != nil { - return nil, listErr - } - for _, k := range []string{"itemList", "item_list"} { - if _, ok := raw[k]; ok { - raw[k] = listData - } - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for ItemGroup. -func (x *ItemGroup) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for ItemGroup. -// This method handles enum_value fields and nested messages: lead_item, item_list -func (x *ItemGroup) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse the raw JSON to rewrite custom enum_value strings and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "leadItem" using its custom unmarshaler - for _, k := range []string{"leadItem", "lead_item"} { - rawVal, ok := raw[k] - if !ok { - continue - } - inner := &Item{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, marshalErr := protojson.Marshal(inner) - if marshalErr != nil { - return marshalErr - } - raw[k] = innerJSON - } - - // Handle "itemList" using its custom unmarshaler - for _, k := range []string{"itemList", "item_list"} { - rawVal, ok := raw[k] - if !ok { - continue - } - var rawItems []json.RawMessage - if err := json.Unmarshal(rawVal, &rawItems); err != nil { - return err - } - protoItems := make([]json.RawMessage, len(rawItems)) - for i, itemRaw := range rawItems { - inner := &Item{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(itemRaw, opts); err != nil { - return err - } - } else if err := json.Unmarshal(itemRaw, inner); err != nil { - return err - } - itemJSON, marshalErr := protojson.Marshal(inner) - if marshalErr != nil { - return marshalErr - } - protoItems[i] = itemJSON - } - protoJSON, marshalErr := json.Marshal(protoItems) - if marshalErr != nil { - return marshalErr - } - raw[k] = protoJSON - } - - // Re-marshal with proto value names for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for ItemGroup. -func (x *ItemGroup) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for GetItemsResponse. -// This method handles enum_value fields and nested messages: item_group -func (x *GetItemsResponse) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to rewrite enum fields and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "itemGroup" forwarding opts when child supports MarshalJSONSebuf - if x.ItemGroup != nil { - if m, ok := any(x.ItemGroup).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - childData, childErr := m.MarshalJSONSebuf(opts) - if childErr != nil { - return nil, childErr - } - for _, k := range []string{"itemGroup", "item_group"} { - if _, ok := raw[k]; ok { - raw[k] = childData - } - } - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for GetItemsResponse. -func (x *GetItemsResponse) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for GetItemsResponse. -// This method handles enum_value fields and nested messages: item_group -func (x *GetItemsResponse) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse the raw JSON to rewrite custom enum_value strings and nested messages - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "itemGroup" using its custom unmarshaler - for _, k := range []string{"itemGroup", "item_group"} { - rawVal, ok := raw[k] - if !ok { - continue - } - inner := &ItemGroup{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, marshalErr := protojson.Marshal(inner) - if marshalErr != nil { - return marshalErr - } - raw[k] = innerJSON - } - - // Re-marshal with proto value names for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for GetItemsResponse. -func (x *GetItemsResponse) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/flatten_flatten.pb.go b/internal/clientgen/testdata/golden/flatten_flatten.pb.go deleted file mode 100644 index 32d57543..00000000 --- a/internal/clientgen/testdata/golden/flatten_flatten.pb.go +++ /dev/null @@ -1,393 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: flatten.proto - -package flatten - -import ( - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for SimpleFlatten. -// This method handles flatten fields: address -func (x *SimpleFlatten) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to promote flattened child fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Flatten field: address - if x.Address != nil { - delete(raw, "address") - // Forward opts to child's MarshalJSONSebuf when available (annotation composability) - var childData []byte - var childErr error - if m, ok := any(x.Address).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - childData, childErr = m.MarshalJSONSebuf(opts) - } else { - childData, childErr = opts.Marshal(x.Address) - } - if childErr != nil { - return nil, childErr - } - var childRaw map[string]json.RawMessage - if childErr = json.Unmarshal(childData, &childRaw); childErr != nil { - return nil, childErr - } - for k, v := range childRaw { - raw[k] = v - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for SimpleFlatten. -func (x *SimpleFlatten) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for SimpleFlatten. -// This method handles flatten fields: address -func (x *SimpleFlatten) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Extract flattened child fields for: address - { - childRaw := make(map[string]json.RawMessage) - if v, ok := raw["street"]; ok { - childRaw["street"] = v - delete(raw, "street") - } - if v, ok := raw["city"]; ok { - childRaw["city"] = v - delete(raw, "city") - } - if v, ok := raw["zip"]; ok { - childRaw["zip"] = v - delete(raw, "zip") - } - if len(childRaw) > 0 { - childData, childErr := json.Marshal(childRaw) - if childErr != nil { - return childErr - } - x.Address = &Address{} - // Forward opts to child's UnmarshalJSONSebuf if available (annotation composability) - if u, ok := any(x.Address).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if childErr = u.UnmarshalJSONSebuf(childData, opts); childErr != nil { - return childErr - } - } else if childErr = json.Unmarshal(childData, x.Address); childErr != nil { - return childErr - } - } - } - - // Re-marshal remaining fields for protojson - remaining, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(remaining, x) -} - -// UnmarshalJSON implements json.Unmarshaler for SimpleFlatten. -func (x *SimpleFlatten) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for DualFlatten. -// This method handles flatten fields: billing, shipping -func (x *DualFlatten) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to promote flattened child fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Flatten field: billing - if x.Billing != nil { - delete(raw, "billing") - // Forward opts to child's MarshalJSONSebuf when available (annotation composability) - var childData []byte - var childErr error - if m, ok := any(x.Billing).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - childData, childErr = m.MarshalJSONSebuf(opts) - } else { - childData, childErr = opts.Marshal(x.Billing) - } - if childErr != nil { - return nil, childErr - } - var childRaw map[string]json.RawMessage - if childErr = json.Unmarshal(childData, &childRaw); childErr != nil { - return nil, childErr - } - for k, v := range childRaw { - raw["billing_"+k] = v - } - } - - // Flatten field: shipping - if x.Shipping != nil { - delete(raw, "shipping") - // Forward opts to child's MarshalJSONSebuf when available (annotation composability) - var childData []byte - var childErr error - if m, ok := any(x.Shipping).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - childData, childErr = m.MarshalJSONSebuf(opts) - } else { - childData, childErr = opts.Marshal(x.Shipping) - } - if childErr != nil { - return nil, childErr - } - var childRaw map[string]json.RawMessage - if childErr = json.Unmarshal(childData, &childRaw); childErr != nil { - return nil, childErr - } - for k, v := range childRaw { - raw["shipping_"+k] = v - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for DualFlatten. -func (x *DualFlatten) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for DualFlatten. -// This method handles flatten fields: billing, shipping -func (x *DualFlatten) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Extract flattened child fields for: billing - { - childRaw := make(map[string]json.RawMessage) - if v, ok := raw["billing_street"]; ok { - childRaw["street"] = v - delete(raw, "billing_street") - } - if v, ok := raw["billing_city"]; ok { - childRaw["city"] = v - delete(raw, "billing_city") - } - if v, ok := raw["billing_zip"]; ok { - childRaw["zip"] = v - delete(raw, "billing_zip") - } - if len(childRaw) > 0 { - childData, childErr := json.Marshal(childRaw) - if childErr != nil { - return childErr - } - x.Billing = &Address{} - // Forward opts to child's UnmarshalJSONSebuf if available (annotation composability) - if u, ok := any(x.Billing).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if childErr = u.UnmarshalJSONSebuf(childData, opts); childErr != nil { - return childErr - } - } else if childErr = json.Unmarshal(childData, x.Billing); childErr != nil { - return childErr - } - } - } - - // Extract flattened child fields for: shipping - { - childRaw := make(map[string]json.RawMessage) - if v, ok := raw["shipping_street"]; ok { - childRaw["street"] = v - delete(raw, "shipping_street") - } - if v, ok := raw["shipping_city"]; ok { - childRaw["city"] = v - delete(raw, "shipping_city") - } - if v, ok := raw["shipping_zip"]; ok { - childRaw["zip"] = v - delete(raw, "shipping_zip") - } - if len(childRaw) > 0 { - childData, childErr := json.Marshal(childRaw) - if childErr != nil { - return childErr - } - x.Shipping = &Address{} - // Forward opts to child's UnmarshalJSONSebuf if available (annotation composability) - if u, ok := any(x.Shipping).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if childErr = u.UnmarshalJSONSebuf(childData, opts); childErr != nil { - return childErr - } - } else if childErr = json.Unmarshal(childData, x.Shipping); childErr != nil { - return childErr - } - } - } - - // Re-marshal remaining fields for protojson - remaining, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(remaining, x) -} - -// UnmarshalJSON implements json.Unmarshaler for DualFlatten. -func (x *DualFlatten) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for MixedFlatten. -// This method handles flatten fields: address -func (x *MixedFlatten) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to promote flattened child fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Flatten field: address - if x.Address != nil { - delete(raw, "address") - // Forward opts to child's MarshalJSONSebuf when available (annotation composability) - var childData []byte - var childErr error - if m, ok := any(x.Address).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - childData, childErr = m.MarshalJSONSebuf(opts) - } else { - childData, childErr = opts.Marshal(x.Address) - } - if childErr != nil { - return nil, childErr - } - var childRaw map[string]json.RawMessage - if childErr = json.Unmarshal(childData, &childRaw); childErr != nil { - return nil, childErr - } - for k, v := range childRaw { - raw[k] = v - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for MixedFlatten. -func (x *MixedFlatten) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for MixedFlatten. -// This method handles flatten fields: address -func (x *MixedFlatten) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Extract flattened child fields for: address - { - childRaw := make(map[string]json.RawMessage) - if v, ok := raw["street"]; ok { - childRaw["street"] = v - delete(raw, "street") - } - if v, ok := raw["city"]; ok { - childRaw["city"] = v - delete(raw, "city") - } - if v, ok := raw["zip"]; ok { - childRaw["zip"] = v - delete(raw, "zip") - } - if len(childRaw) > 0 { - childData, childErr := json.Marshal(childRaw) - if childErr != nil { - return childErr - } - x.Address = &Address{} - // Forward opts to child's UnmarshalJSONSebuf if available (annotation composability) - if u, ok := any(x.Address).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if childErr = u.UnmarshalJSONSebuf(childData, opts); childErr != nil { - return childErr - } - } else if childErr = json.Unmarshal(childData, x.Address); childErr != nil { - return childErr - } - } - } - - // Re-marshal remaining fields for protojson - remaining, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(remaining, x) -} - -// UnmarshalJSON implements json.Unmarshaler for MixedFlatten. -func (x *MixedFlatten) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/int64_cross_file_reading_encoding.pb.go b/internal/clientgen/testdata/golden/int64_cross_file_reading_encoding.pb.go deleted file mode 100644 index 90b63983..00000000 --- a/internal/clientgen/testdata/golden/int64_cross_file_reading_encoding.pb.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: int64_cross_file_reading.proto - -package int64crossfile - -import ( - "encoding/json" - "strconv" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for SensorReading. -// This method handles int64_encoding=NUMBER fields: timestamp_ms -// Warning: int64 fields with NUMBER encoding may lose precision for values > 2^53 in JavaScript. -func (x *SensorReading) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify NUMBER-encoded int64 fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Convert TimestampMs from string to number - if x.TimestampMs != 0 { - raw["timestampMs"], _ = json.Marshal(x.TimestampMs) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "timestampMs") - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for SensorReading. -func (x *SensorReading) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for SensorReading. -// This method handles int64_encoding=NUMBER fields: timestamp_ms -func (x *SensorReading) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // First, parse the raw JSON to extract NUMBER-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Convert timestampMs from number to string for protojson - if rawVal, ok := raw["timestampMs"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["timestampMs"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Re-marshal to JSON with string values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for SensorReading. -func (x *SensorReading) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/int64_cross_file_response_encoding.pb.go b/internal/clientgen/testdata/golden/int64_cross_file_response_encoding.pb.go deleted file mode 100644 index bca40a98..00000000 --- a/internal/clientgen/testdata/golden/int64_cross_file_response_encoding.pb.go +++ /dev/null @@ -1,196 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: int64_cross_file_response.proto - -package int64crossfile - -import ( - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for GetSensorReadingResponse. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: reading -func (x *GetSensorReadingResponse) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "reading" forwarding opts when child supports MarshalJSONSebuf - if x.Reading != nil { - if m, ok := any(x.Reading).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["reading"], err = m.MarshalJSONSebuf(opts) - } else { - raw["reading"], err = opts.Marshal(x.Reading) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for GetSensorReadingResponse. -func (x *GetSensorReadingResponse) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for GetSensorReadingResponse. -// This method handles nested messages that have int64_encoding=NUMBER fields: reading -func (x *GetSensorReadingResponse) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "reading" using its custom unmarshaler - if rawVal, ok := raw["reading"]; ok { - inner := &SensorReading{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["reading"] = innerJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for GetSensorReadingResponse. -func (x *GetSensorReadingResponse) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for GetSensorReadingsResponse. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: readings -func (x *GetSensorReadingsResponse) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize repeated "readings" forwarding opts to each element - if len(x.Readings) > 0 { - items := make([]json.RawMessage, 0, len(x.Readings)) - for _, item := range x.Readings { - if m, ok := any(item).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - itemData, itemErr := m.MarshalJSONSebuf(opts) - if itemErr != nil { - return nil, itemErr - } - items = append(items, itemData) - } else { - itemData, itemErr := opts.Marshal(item) - if itemErr != nil { - return nil, itemErr - } - items = append(items, itemData) - } - } - raw["readings"], err = json.Marshal(items) - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for GetSensorReadingsResponse. -func (x *GetSensorReadingsResponse) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for GetSensorReadingsResponse. -// This method handles nested messages that have int64_encoding=NUMBER fields: readings -func (x *GetSensorReadingsResponse) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle repeated "readings" using its custom unmarshaler - if rawVal, ok := raw["readings"]; ok { - var rawItems []json.RawMessage - if err := json.Unmarshal(rawVal, &rawItems); err != nil { - return err - } - protoItems := make([]json.RawMessage, len(rawItems)) - for i, itemRaw := range rawItems { - inner := &SensorReading{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(itemRaw, opts); err != nil { - return err - } - } else if err := json.Unmarshal(itemRaw, inner); err != nil { - return err - } - itemJSON, marshalErr := protojson.Marshal(inner) - if marshalErr != nil { - return marshalErr - } - protoItems[i] = itemJSON - } - protoJSON, marshalErr := json.Marshal(protoItems) - if marshalErr != nil { - return marshalErr - } - raw["readings"] = protoJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for GetSensorReadingsResponse. -func (x *GetSensorReadingsResponse) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/int64_deep_nested_encoding_encoding.pb.go b/internal/clientgen/testdata/golden/int64_deep_nested_encoding_encoding.pb.go deleted file mode 100644 index efc3c1d0..00000000 --- a/internal/clientgen/testdata/golden/int64_deep_nested_encoding_encoding.pb.go +++ /dev/null @@ -1,529 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: int64_deep_nested_encoding.proto - -package int64deepnested - -import ( - "encoding/json" - "strconv" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for Leaf. -// This method handles int64_encoding=NUMBER fields: value -// Warning: int64 fields with NUMBER encoding may lose precision for values > 2^53 in JavaScript. -func (x *Leaf) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify NUMBER-encoded int64 fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Convert Value from string to number - if x.Value != 0 { - raw["value"], _ = json.Marshal(x.Value) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "value") - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Leaf. -func (x *Leaf) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Leaf. -// This method handles int64_encoding=NUMBER fields: value -func (x *Leaf) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // First, parse the raw JSON to extract NUMBER-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Convert value from number to string for protojson - if rawVal, ok := raw["value"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["value"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Re-marshal to JSON with string values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Leaf. -func (x *Leaf) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for Node. -// This method handles int64_encoding=NUMBER fields: id -// It also re-marshals nested messages that reach int64_encoding=NUMBER fields: child -// Warning: int64 fields with NUMBER encoding may lose precision for values > 2^53 in JavaScript. -func (x *Node) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify NUMBER-encoded int64 fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Convert Id from string to number - if x.Id != 0 { - raw["id"], _ = json.Marshal(x.Id) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "id") - } - - // Re-serialize "child" forwarding opts when child supports MarshalJSONSebuf - if x.Child != nil { - if m, ok := any(x.Child).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["child"], err = m.MarshalJSONSebuf(opts) - } else { - raw["child"], err = opts.Marshal(x.Child) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Node. -func (x *Node) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Node. -// This method handles int64_encoding=NUMBER fields: id -func (x *Node) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // First, parse the raw JSON to extract NUMBER-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Convert id from number to string for protojson - if rawVal, ok := raw["id"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["id"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Handle "child" using its custom unmarshaler - if rawVal, ok := raw["child"]; ok { - inner := &Node{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["child"] = innerJSON - } - - // Re-marshal to JSON with string values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Node. -func (x *Node) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for Middle. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: leaf -func (x *Middle) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "leaf" forwarding opts when child supports MarshalJSONSebuf - if x.Leaf != nil { - if m, ok := any(x.Leaf).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["leaf"], err = m.MarshalJSONSebuf(opts) - } else { - raw["leaf"], err = opts.Marshal(x.Leaf) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Middle. -func (x *Middle) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Middle. -// This method handles nested messages that have int64_encoding=NUMBER fields: leaf -func (x *Middle) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "leaf" using its custom unmarshaler - if rawVal, ok := raw["leaf"]; ok { - inner := &Leaf{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["leaf"] = innerJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Middle. -func (x *Middle) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for Outer. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: middle -func (x *Outer) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "middle" forwarding opts when child supports MarshalJSONSebuf - if x.Middle != nil { - if m, ok := any(x.Middle).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["middle"], err = m.MarshalJSONSebuf(opts) - } else { - raw["middle"], err = opts.Marshal(x.Middle) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Outer. -func (x *Outer) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Outer. -// This method handles nested messages that have int64_encoding=NUMBER fields: middle -func (x *Outer) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "middle" using its custom unmarshaler - if rawVal, ok := raw["middle"]; ok { - inner := &Middle{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["middle"] = innerJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Outer. -func (x *Outer) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for OuterList. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: middles -func (x *OuterList) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize repeated "middles" forwarding opts to each element - if len(x.Middles) > 0 { - items := make([]json.RawMessage, 0, len(x.Middles)) - for _, item := range x.Middles { - if m, ok := any(item).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - itemData, itemErr := m.MarshalJSONSebuf(opts) - if itemErr != nil { - return nil, itemErr - } - items = append(items, itemData) - } else { - itemData, itemErr := opts.Marshal(item) - if itemErr != nil { - return nil, itemErr - } - items = append(items, itemData) - } - } - raw["middles"], err = json.Marshal(items) - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for OuterList. -func (x *OuterList) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for OuterList. -// This method handles nested messages that have int64_encoding=NUMBER fields: middles -func (x *OuterList) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle repeated "middles" using its custom unmarshaler - if rawVal, ok := raw["middles"]; ok { - var rawItems []json.RawMessage - if err := json.Unmarshal(rawVal, &rawItems); err != nil { - return err - } - protoItems := make([]json.RawMessage, len(rawItems)) - for i, itemRaw := range rawItems { - inner := &Middle{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(itemRaw, opts); err != nil { - return err - } - } else if err := json.Unmarshal(itemRaw, inner); err != nil { - return err - } - itemJSON, marshalErr := protojson.Marshal(inner) - if marshalErr != nil { - return marshalErr - } - protoItems[i] = itemJSON - } - protoJSON, marshalErr := json.Marshal(protoItems) - if marshalErr != nil { - return marshalErr - } - raw["middles"] = protoJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for OuterList. -func (x *OuterList) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for NodeHolder. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: root -func (x *NodeHolder) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "root" forwarding opts when child supports MarshalJSONSebuf - if x.Root != nil { - if m, ok := any(x.Root).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["root"], err = m.MarshalJSONSebuf(opts) - } else { - raw["root"], err = opts.Marshal(x.Root) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for NodeHolder. -func (x *NodeHolder) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for NodeHolder. -// This method handles nested messages that have int64_encoding=NUMBER fields: root -func (x *NodeHolder) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "root" using its custom unmarshaler - if rawVal, ok := raw["root"]; ok { - inner := &Node{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["root"] = innerJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for NodeHolder. -func (x *NodeHolder) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/int64_encoding_encoding.pb.go b/internal/clientgen/testdata/golden/int64_encoding_encoding.pb.go deleted file mode 100644 index 51dda74d..00000000 --- a/internal/clientgen/testdata/golden/int64_encoding_encoding.pb.go +++ /dev/null @@ -1,192 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: int64_encoding.proto - -package int64encoding - -import ( - "encoding/json" - "strconv" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for Int64EncodingTest. -// This method handles int64_encoding=NUMBER fields: number_int64, number_uint64, number_sint64, number_sfixed64, number_fixed64, repeated_number_int64, optional_number_int64, commented_number_int64 -// Warning: int64 fields with NUMBER encoding may lose precision for values > 2^53 in JavaScript. -func (x *Int64EncodingTest) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify NUMBER-encoded int64 fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Convert NumberInt64 from string to number - if x.NumberInt64 != 0 { - raw["numberInt64"], _ = json.Marshal(x.NumberInt64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "numberInt64") - } - - // Convert NumberUint64 from string to number - if x.NumberUint64 != 0 { - raw["numberUint64"], _ = json.Marshal(x.NumberUint64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "numberUint64") - } - - // Convert NumberSint64 from string to number - if x.NumberSint64 != 0 { - raw["numberSint64"], _ = json.Marshal(x.NumberSint64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "numberSint64") - } - - // Convert NumberSfixed64 from string to number - if x.NumberSfixed64 != 0 { - raw["numberSfixed64"], _ = json.Marshal(x.NumberSfixed64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "numberSfixed64") - } - - // Convert NumberFixed64 from string to number - if x.NumberFixed64 != 0 { - raw["numberFixed64"], _ = json.Marshal(x.NumberFixed64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "numberFixed64") - } - - // Convert repeated RepeatedNumberInt64 from strings to numbers - if len(x.RepeatedNumberInt64) > 0 { - raw["repeatedNumberInt64"], _ = json.Marshal(x.RepeatedNumberInt64) - } - - // Convert OptionalNumberInt64 from string to number - if x.OptionalNumberInt64 != 0 { - raw["optionalNumberInt64"], _ = json.Marshal(x.OptionalNumberInt64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "optionalNumberInt64") - } - - // Convert CommentedNumberInt64 from string to number - if x.CommentedNumberInt64 != 0 { - raw["commentedNumberInt64"], _ = json.Marshal(x.CommentedNumberInt64) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "commentedNumberInt64") - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for Int64EncodingTest. -func (x *Int64EncodingTest) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for Int64EncodingTest. -// This method handles int64_encoding=NUMBER fields: number_int64, number_uint64, number_sint64, number_sfixed64, number_fixed64, repeated_number_int64, optional_number_int64, commented_number_int64 -func (x *Int64EncodingTest) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // First, parse the raw JSON to extract NUMBER-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Convert numberInt64 from number to string for protojson - if rawVal, ok := raw["numberInt64"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["numberInt64"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Convert numberUint64 from number to string for protojson - if rawVal, ok := raw["numberUint64"]; ok { - var num uint64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["numberUint64"], _ = json.Marshal(strconv.FormatUint(num, 10)) - } - } - - // Convert numberSint64 from number to string for protojson - if rawVal, ok := raw["numberSint64"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["numberSint64"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Convert numberSfixed64 from number to string for protojson - if rawVal, ok := raw["numberSfixed64"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["numberSfixed64"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Convert numberFixed64 from number to string for protojson - if rawVal, ok := raw["numberFixed64"]; ok { - var num uint64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["numberFixed64"], _ = json.Marshal(strconv.FormatUint(num, 10)) - } - } - - // Convert repeated repeatedNumberInt64 from numbers to strings for protojson - if rawVal, ok := raw["repeatedNumberInt64"]; ok { - var nums []int64 - if err := json.Unmarshal(rawVal, &nums); err == nil { - strs := make([]string, len(nums)) - for i, n := range nums { - strs[i] = strconv.FormatInt(n, 10) - } - raw["repeatedNumberInt64"], _ = json.Marshal(strs) - } - } - - // Convert optionalNumberInt64 from number to string for protojson - if rawVal, ok := raw["optionalNumberInt64"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["optionalNumberInt64"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Convert commentedNumberInt64 from number to string for protojson - if rawVal, ok := raw["commentedNumberInt64"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["commentedNumberInt64"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Re-marshal to JSON with string values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for Int64EncodingTest. -func (x *Int64EncodingTest) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/int64_nested_encoding_encoding.pb.go b/internal/clientgen/testdata/golden/int64_nested_encoding_encoding.pb.go deleted file mode 100644 index 47c1e03b..00000000 --- a/internal/clientgen/testdata/golden/int64_nested_encoding_encoding.pb.go +++ /dev/null @@ -1,291 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: int64_nested_encoding.proto - -package int64nestedencoding - -import ( - "encoding/json" - "strconv" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for SensorReading. -// This method handles int64_encoding=NUMBER fields: timestamp_ms, values -// Warning: int64 fields with NUMBER encoding may lose precision for values > 2^53 in JavaScript. -func (x *SensorReading) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify NUMBER-encoded int64 fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Convert TimestampMs from string to number - if x.TimestampMs != 0 { - raw["timestampMs"], _ = json.Marshal(x.TimestampMs) - } else { - // Remove the field if zero (proto3 default behavior) - delete(raw, "timestampMs") - } - - // Convert repeated Values from strings to numbers - if len(x.Values) > 0 { - raw["values"], _ = json.Marshal(x.Values) - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for SensorReading. -func (x *SensorReading) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for SensorReading. -// This method handles int64_encoding=NUMBER fields: timestamp_ms, values -func (x *SensorReading) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // First, parse the raw JSON to extract NUMBER-encoded fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Convert timestampMs from number to string for protojson - if rawVal, ok := raw["timestampMs"]; ok { - var num int64 - if err := json.Unmarshal(rawVal, &num); err == nil { - raw["timestampMs"], _ = json.Marshal(strconv.FormatInt(num, 10)) - } - } - - // Convert repeated values from numbers to strings for protojson - if rawVal, ok := raw["values"]; ok { - var nums []int64 - if err := json.Unmarshal(rawVal, &nums); err == nil { - strs := make([]string, len(nums)) - for i, n := range nums { - strs[i] = strconv.FormatInt(n, 10) - } - raw["values"], _ = json.Marshal(strs) - } - } - - // Re-marshal to JSON with string values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for SensorReading. -func (x *SensorReading) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for GetSensorReadingResponse. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: reading -func (x *GetSensorReadingResponse) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "reading" forwarding opts when child supports MarshalJSONSebuf - if x.Reading != nil { - if m, ok := any(x.Reading).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["reading"], err = m.MarshalJSONSebuf(opts) - } else { - raw["reading"], err = opts.Marshal(x.Reading) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for GetSensorReadingResponse. -func (x *GetSensorReadingResponse) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for GetSensorReadingResponse. -// This method handles nested messages that have int64_encoding=NUMBER fields: reading -func (x *GetSensorReadingResponse) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "reading" using its custom unmarshaler - if rawVal, ok := raw["reading"]; ok { - inner := &SensorReading{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["reading"] = innerJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for GetSensorReadingResponse. -func (x *GetSensorReadingResponse) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for GetMultiSensorResponse. -// This method re-marshals nested messages that have int64_encoding=NUMBER fields: primary, secondary -func (x *GetMultiSensorResponse) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to re-serialize nested messages with custom MarshalJSON - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Re-serialize "primary" forwarding opts when child supports MarshalJSONSebuf - if x.Primary != nil { - if m, ok := any(x.Primary).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["primary"], err = m.MarshalJSONSebuf(opts) - } else { - raw["primary"], err = opts.Marshal(x.Primary) - } - if err != nil { - return nil, err - } - } - - // Re-serialize "secondary" forwarding opts when child supports MarshalJSONSebuf - if x.Secondary != nil { - if m, ok := any(x.Secondary).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - raw["secondary"], err = m.MarshalJSONSebuf(opts) - } else { - raw["secondary"], err = opts.Marshal(x.Secondary) - } - if err != nil { - return nil, err - } - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for GetMultiSensorResponse. -func (x *GetMultiSensorResponse) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for GetMultiSensorResponse. -// This method handles nested messages that have int64_encoding=NUMBER fields: primary, secondary -func (x *GetMultiSensorResponse) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle "primary" using its custom unmarshaler - if rawVal, ok := raw["primary"]; ok { - inner := &SensorReading{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["primary"] = innerJSON - } - - // Handle "secondary" using its custom unmarshaler - if rawVal, ok := raw["secondary"]; ok { - inner := &SensorReading{} - if u, ok := any(inner).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(rawVal, opts); err != nil { - return err - } - } else if err := json.Unmarshal(rawVal, inner); err != nil { - return err - } - innerJSON, err := protojson.Marshal(inner) - if err != nil { - return err - } - raw["secondary"] = innerJSON - } - - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for GetMultiSensorResponse. -func (x *GetMultiSensorResponse) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/nullable_nullable.pb.go b/internal/clientgen/testdata/golden/nullable_nullable.pb.go deleted file mode 100644 index 48569881..00000000 --- a/internal/clientgen/testdata/golden/nullable_nullable.pb.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: nullable.proto - -package nullable - -import ( - "encoding/json" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for User. -// This method handles nullable fields: middle_name, age, is_verified -func (x *User) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to handle nullable fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Handle nullable field: middle_name - // proto3 optional + nullable=true: emit null when not set - if x.MiddleName == nil { - raw["middleName"] = []byte("null") - } - - // Handle nullable field: age - // proto3 optional + nullable=true: emit null when not set - if x.Age == nil { - raw["age"] = []byte("null") - } - - // Handle nullable field: is_verified - // proto3 optional + nullable=true: emit null when not set - if x.IsVerified == nil { - raw["isVerified"] = []byte("null") - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for User. -func (x *User) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for User. -// This method handles nullable fields: middle_name, age, is_verified -func (x *User) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse to check for explicit null values on nullable fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Handle nullable field: middle_name - // Remove explicit null so protojson leaves field unset - if rawVal, ok := raw["middleName"]; ok && string(rawVal) == "null" { - delete(raw, "middleName") - } - - // Handle nullable field: age - // Remove explicit null so protojson leaves field unset - if rawVal, ok := raw["age"]; ok && string(rawVal) == "null" { - delete(raw, "age") - } - - // Handle nullable field: is_verified - // Remove explicit null so protojson leaves field unset - if rawVal, ok := raw["isVerified"]; ok && string(rawVal) == "null" { - delete(raw, "isVerified") - } - - // Re-marshal without nulls for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for User. -func (x *User) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go b/internal/clientgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go deleted file mode 100644 index 574aebe1..00000000 --- a/internal/clientgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go +++ /dev/null @@ -1,298 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: oneof_discriminator.proto - -package oneofdiscriminator - -import ( - "encoding/json" - "fmt" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for FlattenedEvent. -// This method handles oneof discriminator fields: content -func (x *FlattenedEvent) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to add discriminator fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Handle oneof content with discriminator "type" - switch x.GetContent().(type) { - case *FlattenedEvent_Text: - raw["type"], _ = json.Marshal("text") - // Flatten: forward opts to variant via MarshalJSONSebuf when available - if inner := x.GetText(); inner != nil { - var variantData []byte - var varErr error - if m, ok := any(inner).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - variantData, varErr = m.MarshalJSONSebuf(opts) - } else { - variantData, varErr = opts.Marshal(inner) - } - if varErr == nil { - var variantMap map[string]json.RawMessage - if json.Unmarshal(variantData, &variantMap) == nil { - // Merge variant fields into parent - for fk, fv := range variantMap { - raw[fk] = fv - } - } - } - delete(raw, "text") - } - case *FlattenedEvent_Image: - raw["type"], _ = json.Marshal("img") - // Flatten: forward opts to variant via MarshalJSONSebuf when available - if inner := x.GetImage(); inner != nil { - var variantData []byte - var varErr error - if m, ok := any(inner).(interface { - MarshalJSONSebuf(protojson.MarshalOptions) ([]byte, error) - }); ok { - variantData, varErr = m.MarshalJSONSebuf(opts) - } else { - variantData, varErr = opts.Marshal(inner) - } - if varErr == nil { - var variantMap map[string]json.RawMessage - if json.Unmarshal(variantData, &variantMap) == nil { - // Merge variant fields into parent - for fk, fv := range variantMap { - raw[fk] = fv - } - } - } - delete(raw, "image") - } - default: - // Oneof not set: omit discriminator entirely - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for FlattenedEvent. -func (x *FlattenedEvent) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for FlattenedEvent. -// This method handles oneof discriminator fields: content -func (x *FlattenedEvent) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse into a map to read discriminator fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Read discriminator for oneof content - if discRaw, ok := raw["type"]; ok { - var disc string - if err := json.Unmarshal(discRaw, &disc); err != nil { - return fmt.Errorf("invalid discriminator %q: %%w", "type", err) - } - - switch disc { - case "text": - // Flatten unmarshal: extract Text fields from flat map - variantMap := make(map[string]json.RawMessage) - if fv, exists := raw["body"]; exists { - variantMap["body"] = fv - delete(raw, "body") - } - variantData, _ := json.Marshal(variantMap) - variant := &TextContent{} - if u, ok := any(variant).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(variantData, opts); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Text", err) - } - } else if err := json.Unmarshal(variantData, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Text", err) - } - x.Content = &FlattenedEvent_Text{Text: variant} - raw["text"], _ = json.Marshal(variant) - case "img": - // Flatten unmarshal: extract Image fields from flat map - variantMap := make(map[string]json.RawMessage) - if fv, exists := raw["url"]; exists { - variantMap["url"] = fv - delete(raw, "url") - } - if fv, exists := raw["width"]; exists { - variantMap["width"] = fv - delete(raw, "width") - } - if fv, exists := raw["height"]; exists { - variantMap["height"] = fv - delete(raw, "height") - } - variantData, _ := json.Marshal(variantMap) - variant := &ImageContent{} - if u, ok := any(variant).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(variantData, opts); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Image", err) - } - } else if err := json.Unmarshal(variantData, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Image", err) - } - x.Content = &FlattenedEvent_Image{Image: variant} - raw["image"], _ = json.Marshal(variant) - } - } - - // Remove discriminator fields before protojson unmarshal - delete(raw, "type") - - // Re-marshal remaining fields for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for FlattenedEvent. -func (x *FlattenedEvent) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} - -// MarshalJSONSebuf implements sebufMarshaler for NestedEvent. -// This method handles oneof discriminator fields: content -func (x *NestedEvent) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to add discriminator fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Handle oneof content with discriminator "kind" - switch x.GetContent().(type) { - case *NestedEvent_Text: - raw["kind"], _ = json.Marshal("text") - case *NestedEvent_Image: - raw["kind"], _ = json.Marshal("image") - case *NestedEvent_Video: - raw["kind"], _ = json.Marshal("vid") - default: - // Oneof not set: omit discriminator entirely - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for NestedEvent. -func (x *NestedEvent) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for NestedEvent. -// This method handles oneof discriminator fields: content -func (x *NestedEvent) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse into a map to read discriminator fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Read discriminator for oneof content - if discRaw, ok := raw["kind"]; ok { - var disc string - if err := json.Unmarshal(discRaw, &disc); err != nil { - return fmt.Errorf("invalid discriminator %q: %%w", "kind", err) - } - - switch disc { - case "text": - // Non-flattened unmarshal: forward opts to child's UnmarshalJSONSebuf if available - if variantRaw, exists := raw["text"]; exists { - variant := &TextContent{} - if u, ok := any(variant).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(variantRaw, opts); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Text", err) - } - } else if err := json.Unmarshal(variantRaw, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Text", err) - } - x.Content = &NestedEvent_Text{Text: variant} - } - case "image": - // Non-flattened unmarshal: forward opts to child's UnmarshalJSONSebuf if available - if variantRaw, exists := raw["image"]; exists { - variant := &ImageContent{} - if u, ok := any(variant).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(variantRaw, opts); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Image", err) - } - } else if err := json.Unmarshal(variantRaw, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Image", err) - } - x.Content = &NestedEvent_Image{Image: variant} - } - case "vid": - // Non-flattened unmarshal: forward opts to child's UnmarshalJSONSebuf if available - if variantRaw, exists := raw["video"]; exists { - variant := &VideoContent{} - if u, ok := any(variant).(interface { - UnmarshalJSONSebuf([]byte, protojson.UnmarshalOptions) error - }); ok { - if err := u.UnmarshalJSONSebuf(variantRaw, opts); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Video", err) - } - } else if err := json.Unmarshal(variantRaw, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Video", err) - } - x.Content = &NestedEvent_Video{Video: variant} - } - } - } - - // Remove discriminator fields before protojson unmarshal - delete(raw, "kind") - - // Re-marshal remaining fields for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for NestedEvent. -func (x *NestedEvent) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/testdata/golden/timestamp_format_timestamp_format.pb.go b/internal/clientgen/testdata/golden/timestamp_format_timestamp_format.pb.go deleted file mode 100644 index cfe16f48..00000000 --- a/internal/clientgen/testdata/golden/timestamp_format_timestamp_format.pb.go +++ /dev/null @@ -1,109 +0,0 @@ -// Code generated by protoc-gen-go-client. DO NOT EDIT. -// source: timestamp_format.proto - -package timestampformat - -import ( - "encoding/json" - "time" - - "google.golang.org/protobuf/encoding/protojson" -) - -// MarshalJSONSebuf implements sebufMarshaler for TimestampFormatTest. -// This method handles timestamp_format fields: unix_seconds_ts, unix_millis_ts, date_ts -func (x *TimestampFormatTest) MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) { - if x == nil { - return []byte("null"), nil - } - - // Use protojson for base serialization (handles all other fields correctly) - data, err := opts.Marshal(x) - if err != nil { - return nil, err - } - - // Parse into a map to modify timestamp format fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - - // Convert unix_seconds_ts to TIMESTAMP_FORMAT_UNIX_SECONDS format - if x.UnixSecondsTs != nil { - t := x.UnixSecondsTs.AsTime() - raw["unixSecondsTs"], _ = json.Marshal(t.Unix()) - } - - // Convert unix_millis_ts to TIMESTAMP_FORMAT_UNIX_MILLIS format - if x.UnixMillisTs != nil { - t := x.UnixMillisTs.AsTime() - raw["unixMillisTs"], _ = json.Marshal(t.UnixMilli()) - } - - // Convert date_ts to TIMESTAMP_FORMAT_DATE format - if x.DateTs != nil { - t := x.DateTs.AsTime() - raw["dateTs"], _ = json.Marshal(t.Format("2006-01-02")) - } - - return json.Marshal(raw) -} - -// MarshalJSON implements json.Marshaler for TimestampFormatTest. -func (x *TimestampFormatTest) MarshalJSON() ([]byte, error) { - return x.MarshalJSONSebuf(protojson.MarshalOptions{}) -} - -// UnmarshalJSONSebuf implements sebufUnmarshaler for TimestampFormatTest. -// This method handles timestamp_format fields: unix_seconds_ts, unix_millis_ts, date_ts -func (x *TimestampFormatTest) UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error { - // Parse the raw JSON to extract timestamp format fields - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Convert unixSecondsTs from TIMESTAMP_FORMAT_UNIX_SECONDS to RFC 3339 for protojson - if v, ok := raw["unixSecondsTs"]; ok { - var n int64 - if err := json.Unmarshal(v, &n); err == nil { - t := time.Unix(n, 0) - raw["unixSecondsTs"], _ = json.Marshal(t.Format(time.RFC3339Nano)) - } - } - - // Convert unixMillisTs from TIMESTAMP_FORMAT_UNIX_MILLIS to RFC 3339 for protojson - if v, ok := raw["unixMillisTs"]; ok { - var n int64 - if err := json.Unmarshal(v, &n); err == nil { - t := time.UnixMilli(n) - raw["unixMillisTs"], _ = json.Marshal(t.Format(time.RFC3339Nano)) - } - } - - // Convert dateTs from TIMESTAMP_FORMAT_DATE to RFC 3339 for protojson - if v, ok := raw["dateTs"]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil { - t, parseErr := time.Parse("2006-01-02", s) - if parseErr == nil { - raw["dateTs"], _ = json.Marshal(t.Format(time.RFC3339Nano)) - } - } - } - - // Re-marshal with RFC 3339 values for protojson - modified, err := json.Marshal(raw) - if err != nil { - return err - } - - // Use protojson to unmarshal the rest - return opts.Unmarshal(modified, x) -} - -// UnmarshalJSON implements json.Unmarshaler for TimestampFormatTest. -func (x *TimestampFormatTest) UnmarshalJSON(data []byte) error { - return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{}) -} diff --git a/internal/clientgen/timestamp_format.go b/internal/clientgen/timestamp_format.go deleted file mode 100644 index 9c9727df..00000000 --- a/internal/clientgen/timestamp_format.go +++ /dev/null @@ -1,289 +0,0 @@ -package clientgen - -import ( - "strings" - - "google.golang.org/protobuf/compiler/protogen" - - "github.com/SebastienMelki/sebuf/http" - "github.com/SebastienMelki/sebuf/internal/annotations" -) - -// TimestampFormatContext holds information about messages that need custom JSON encoding -// for Timestamp fields with non-default format annotations. -type TimestampFormatContext struct { - // Message is the message that needs custom marshal/unmarshal - Message *protogen.Message - // TimestampFields are fields with timestamp_format annotation (non-default) - TimestampFields []*TimestampFormatFieldInfo -} - -// TimestampFormatFieldInfo holds field info with its timestamp format setting. -type TimestampFormatFieldInfo struct { - Field *protogen.Field - Format http.TimestampFormat -} - -// hasTimestampFormatFields returns true if any Timestamp field in the message has a non-default format. -func hasTimestampFormatFields(message *protogen.Message) bool { - for _, field := range message.Fields { - if annotations.IsTimestampField(field) && annotations.HasTimestampFormatAnnotation(field) { - return true - } - } - return false -} - -// getTimestampFormatFields returns all Timestamp fields with non-default format annotations. -func getTimestampFormatFields(message *protogen.Message) []*TimestampFormatFieldInfo { - var fields []*TimestampFormatFieldInfo - for _, field := range message.Fields { - if annotations.IsTimestampField(field) && annotations.HasTimestampFormatAnnotation(field) { - fields = append(fields, &TimestampFormatFieldInfo{ - Field: field, - Format: annotations.GetTimestampFormat(field), - }) - } - } - return fields -} - -// collectTimestampFormatContext analyzes messages in a file and collects timestamp format info. -func collectTimestampFormatContext(file *protogen.File) []*TimestampFormatContext { - var contexts []*TimestampFormatContext - collectTimestampFormatMessages(file.Messages, &contexts) - return contexts -} - -// collectTimestampFormatMessages recursively collects messages with timestamp format fields. -func collectTimestampFormatMessages(messages []*protogen.Message, contexts *[]*TimestampFormatContext) { - for _, msg := range messages { - if hasTimestampFormatFields(msg) { - *contexts = append(*contexts, &TimestampFormatContext{ - Message: msg, - TimestampFields: getTimestampFormatFields(msg), - }) - } - // Check nested messages - collectTimestampFormatMessages(msg.Messages, contexts) - } -} - -// validateTimestampFormatAnnotations validates all timestamp_format annotations in a file. -// Returns the first validation error encountered, or nil if all valid. -func validateTimestampFormatAnnotations(file *protogen.File) error { - return validateTimestampFormatInMessages(file.Messages) -} - -// validateTimestampFormatInMessages recursively validates timestamp_format annotations. -func validateTimestampFormatInMessages(messages []*protogen.Message) error { - for _, msg := range messages { - for _, field := range msg.Fields { - if err := annotations.ValidateTimestampFormatAnnotation(field, msg.GoIdent.GoName); err != nil { - return err - } - } - if err := validateTimestampFormatInMessages(msg.Messages); err != nil { - return err - } - } - return nil -} - -// generateTimestampFormatEncodingFile generates the *_timestamp_format.pb.go file if needed. -func (g *Generator) generateTimestampFormatEncodingFile(file *protogen.File) error { - // First validate all timestamp_format annotations - if err := validateTimestampFormatAnnotations(file); err != nil { - return err - } - - contexts := collectTimestampFormatContext(file) - if len(contexts) == 0 { - return nil - } - - filename := file.GeneratedFilenamePrefix + "_timestamp_format.pb.go" - gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath) - - g.writeHeader(gf, file) - g.writeTimestampFormatImports(gf) - - for _, ctx := range contexts { - g.generateTimestampFormatMarshalJSON(gf, ctx) - g.generateTimestampFormatUnmarshalJSON(gf, ctx) - } - - return nil -} - -// writeTimestampFormatImports writes the imports needed for timestamp format encoding. -func (g *Generator) writeTimestampFormatImports(gf *protogen.GeneratedFile) { - gf.P("import (") - gf.P(`"encoding/json"`) - gf.P(`"time"`) - gf.P() - gf.P(`"google.golang.org/protobuf/encoding/protojson"`) - gf.P(")") - gf.P() -} - -// generateTimestampFormatMarshalJSON generates MarshalJSON that converts Timestamp fields to the specified format. -// -//nolint:dupl // Code generation patterns naturally have similar structure across encoding types -func (g *Generator) generateTimestampFormatMarshalJSON(gf *protogen.GeneratedFile, ctx *TimestampFormatContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.TimestampFields { - fieldNames = append(fieldNames, string(f.Field.Desc.Name())) - } - - gf.P("// MarshalJSONSebuf implements sebufMarshaler for ", msgName, ".") - gf.P("// This method handles timestamp_format fields: ", strings.Join(fieldNames, ", ")) - gf.P( - "func (x *", - msgName, - ") MarshalJSONSebuf(opts protojson.MarshalOptions) ([]byte, error) {", - ) - gf.P("if x == nil {") - gf.P("return []byte(\"null\"), nil") - gf.P("}") - gf.P() - - gf.P("// Use protojson for base serialization (handles all other fields correctly)") - gf.P("data, err := opts.Marshal(x)") - gf.P("if err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - gf.P("// Parse into a map to modify timestamp format fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return nil, err") - gf.P("}") - gf.P() - - for _, fieldInfo := range ctx.TimestampFields { - g.generateTimestampFieldMarshal(gf, fieldInfo) - } - - gf.P("return json.Marshal(raw)") - gf.P("}") - gf.P() - - // Backward-compatible MarshalJSON wrapper for stdlib encoding/json. - gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {") - gf.P("return x.MarshalJSONSebuf(protojson.MarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateTimestampFieldMarshal generates marshal code for a single Timestamp field. -// -//nolint:exhaustive // Only non-default formats need handling; default/RFC3339 are excluded by HasTimestampFormatAnnotation -func (g *Generator) generateTimestampFieldMarshal(gf *protogen.GeneratedFile, fieldInfo *TimestampFormatFieldInfo) { - field := fieldInfo.Field - goName := field.GoName - jsonName := field.Desc.JSONName() - format := fieldInfo.Format - - gf.P("// Convert ", field.Desc.Name(), " to ", format.String(), " format") - gf.P("if x.", goName, " != nil {") - gf.P("t := x.", goName, ".AsTime()") - - switch format { - case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_SECONDS: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(t.Unix())`) - case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_MILLIS: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(t.UnixMilli())`) - case http.TimestampFormat_TIMESTAMP_FORMAT_DATE: - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(t.Format("2006-01-02"))`) - } - - gf.P("}") - gf.P() -} - -// generateTimestampFormatUnmarshalJSON generates UnmarshalJSON that converts timestamp formats back to RFC 3339. -// -//nolint:dupl // Code generation patterns naturally have similar structure across encoding types -func (g *Generator) generateTimestampFormatUnmarshalJSON(gf *protogen.GeneratedFile, ctx *TimestampFormatContext) { - msgName := ctx.Message.GoIdent.GoName - - var fieldNames []string - for _, f := range ctx.TimestampFields { - fieldNames = append(fieldNames, string(f.Field.Desc.Name())) - } - - gf.P("// UnmarshalJSONSebuf implements sebufUnmarshaler for ", msgName, ".") - gf.P("// This method handles timestamp_format fields: ", strings.Join(fieldNames, ", ")) - gf.P("func (x *", msgName, ") UnmarshalJSONSebuf(data []byte, opts protojson.UnmarshalOptions) error {") - gf.P("// Parse the raw JSON to extract timestamp format fields") - gf.P("var raw map[string]json.RawMessage") - gf.P("if err := json.Unmarshal(data, &raw); err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - - for _, fieldInfo := range ctx.TimestampFields { - g.generateTimestampFieldUnmarshal(gf, fieldInfo) - } - - gf.P("// Re-marshal with RFC 3339 values for protojson") - gf.P("modified, err := json.Marshal(raw)") - gf.P("if err != nil {") - gf.P("return err") - gf.P("}") - gf.P() - gf.P("// Use protojson to unmarshal the rest") - gf.P("return opts.Unmarshal(modified, x)") - gf.P("}") - gf.P() - - // Backward-compatible UnmarshalJSON wrapper for stdlib encoding/json - gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".") - gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {") - gf.P("return x.UnmarshalJSONSebuf(data, protojson.UnmarshalOptions{})") - gf.P("}") - gf.P() -} - -// generateTimestampFieldUnmarshal generates unmarshal code for a single Timestamp field. -// -//nolint:exhaustive // Only non-default formats need handling; default/RFC3339 are excluded by HasTimestampFormatAnnotation -func (g *Generator) generateTimestampFieldUnmarshal(gf *protogen.GeneratedFile, fieldInfo *TimestampFormatFieldInfo) { - field := fieldInfo.Field - jsonName := field.Desc.JSONName() - format := fieldInfo.Format - - gf.P("// Convert ", jsonName, " from ", format.String(), " to RFC 3339 for protojson") - gf.P(`if v, ok := raw["`, jsonName, `"]; ok {`) - - switch format { - case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_SECONDS: - gf.P("var n int64") - gf.P("if err := json.Unmarshal(v, &n); err == nil {") - gf.P("t := time.Unix(n, 0)") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(t.Format(time.RFC3339Nano))`) - gf.P("}") - case http.TimestampFormat_TIMESTAMP_FORMAT_UNIX_MILLIS: - gf.P("var n int64") - gf.P("if err := json.Unmarshal(v, &n); err == nil {") - gf.P("t := time.UnixMilli(n)") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(t.Format(time.RFC3339Nano))`) - gf.P("}") - case http.TimestampFormat_TIMESTAMP_FORMAT_DATE: - gf.P("var s string") - gf.P("if err := json.Unmarshal(v, &s); err == nil {") - gf.P(`t, parseErr := time.Parse("2006-01-02", s)`) - gf.P("if parseErr == nil {") - gf.P(`raw["`, jsonName, `"], _ = json.Marshal(t.Format(time.RFC3339Nano))`) - gf.P("}") - gf.P("}") - } - - gf.P("}") - gf.P() -} diff --git a/internal/httpgen/bytes_encoding_consistency_test.go b/internal/httpgen/bytes_encoding_consistency_test.go index 7278b1dd..60171171 100644 --- a/internal/httpgen/bytes_encoding_consistency_test.go +++ b/internal/httpgen/bytes_encoding_consistency_test.go @@ -8,21 +8,6 @@ import ( "testing" ) -// TestGoGeneratorsProduceIdenticalBytesEncoding verifies go-http and go-client -// produce identical bytes_encoding MarshalJSON code. -func TestGoGeneratorsProduceIdenticalBytesEncoding(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "bytes_encoding_bytes_encoding.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "bytes_encoding_bytes_encoding.pb.go"), - "bytes_encoding", - ) -} - // TestBytesEncodingTypeScriptTypes verifies all bytes encoding variants produce // string type in TypeScript (bytes are always strings regardless of encoding). func TestBytesEncodingTypeScriptTypes(t *testing.T) { @@ -243,7 +228,6 @@ func TestBytesEncodingCrossGeneratorAgreement(t *testing.T) { // Verify all golden files exist for cross-generator coverage goldenFiles := []string{ filepath.Join(baseDir, "testdata", "golden", "bytes_encoding_bytes_encoding.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "bytes_encoding_bytes_encoding.pb.go"), filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "bytes_encoding_client.ts"), filepath.Join( baseDir, "..", "openapiv3", "testdata", "golden", "yaml", "BytesEncodingService.openapi.yaml", @@ -256,5 +240,5 @@ func TestBytesEncodingCrossGeneratorAgreement(t *testing.T) { } } - t.Log("All 4 generators agree on bytes encoding types and formats") + t.Log("Go, TypeScript, and OpenAPI agree on bytes encoding types and formats") } diff --git a/internal/httpgen/empty_behavior_consistency_test.go b/internal/httpgen/empty_behavior_consistency_test.go index 990d14ad..96f2dc9d 100644 --- a/internal/httpgen/empty_behavior_consistency_test.go +++ b/internal/httpgen/empty_behavior_consistency_test.go @@ -7,21 +7,6 @@ import ( "testing" ) -// TestEmptyBehaviorConsistencyGoHTTPvsGoClient verifies go-http and go-client -// produce identical empty_behavior MarshalJSON code. -func TestEmptyBehaviorConsistencyGoHTTPvsGoClient(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "empty_behavior_empty_behavior.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "empty_behavior_empty_behavior.pb.go"), - "empty_behavior", - ) -} - // TestEmptyBehaviorConsistencyOpenAPI verifies OpenAPI uses oneOf for empty_behavior=NULL fields. func TestEmptyBehaviorConsistencyOpenAPI(t *testing.T) { baseDir, baseErr := os.Getwd() @@ -110,8 +95,6 @@ func TestEmptyBehaviorConsistencyBackwardCompat(t *testing.T) { goldenFiles := []string{ // Go httpgen filepath.Join(baseDir, "testdata", "golden", "empty_behavior_empty_behavior.pb.go"), - // Go clientgen - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "empty_behavior_empty_behavior.pb.go"), // TypeScript filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "empty_behavior_client.ts"), // OpenAPI diff --git a/internal/httpgen/encoding_consistency_test.go b/internal/httpgen/encoding_consistency_test.go index 4365d7f8..6549ba0e 100644 --- a/internal/httpgen/encoding_consistency_test.go +++ b/internal/httpgen/encoding_consistency_test.go @@ -8,73 +8,6 @@ import ( "testing" ) -// TestGoGeneratorsProduceIdenticalInt64Encoding verifies go-http and go-client -// produce identical int64 MarshalJSON code. -func TestGoGeneratorsProduceIdenticalInt64Encoding(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - // Every int64 encoding golden both generators emit must match, not just the direct-field - // one. Comparing only int64_encoding_encoding.pb.go let the two drift on the wrapper - // emitters: go-client's transitive UnmarshalJSONSebuf never got the repeated-field branch - // go-http has, so it tried to decode a JSON array into a single message. - for _, golden := range []string{ - "int64_encoding_encoding.pb.go", - "int64_nested_encoding_encoding.pb.go", - "int64_cross_file_reading_encoding.pb.go", - "int64_cross_file_response_encoding.pb.go", - "int64_deep_nested_encoding_encoding.pb.go", - } { - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", golden), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", golden), - golden, - ) - } -} - -// TestGoGeneratorsProduceIdenticalEnumEncoding verifies go-http and go-client -// produce identical enum encoding code. -func TestGoGeneratorsProduceIdenticalEnumEncoding(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - httpgenFile := filepath.Join(baseDir, "testdata", "golden", "enum_encoding_enum_encoding.pb.go") - clientgenFile := filepath.Join( - baseDir, - "..", - "clientgen", - "testdata", - "golden", - "enum_encoding_enum_encoding.pb.go", - ) - - httpgenContent, httpErr := os.ReadFile(httpgenFile) - if httpErr != nil { - t.Fatalf("Failed to read httpgen enum encoding golden file: %v", httpErr) - } - - clientgenContent, clientErr := os.ReadFile(clientgenFile) - if clientErr != nil { - t.Fatalf("Failed to read clientgen enum encoding golden file: %v", clientErr) - } - - // Normalize the source comment (generator name differs) - httpgenNormalized := normalizeGeneratorComment(string(httpgenContent), "go-http") - clientgenNormalized := normalizeGeneratorComment(string(clientgenContent), "go-client") - - if httpgenNormalized != clientgenNormalized { - t.Errorf("go-http and go-client enum encoding code differs after normalization") - t.Logf("First difference:\n%s", findFirstDifference(httpgenNormalized, clientgenNormalized)) - } else { - t.Log("go-http and go-client produce identical enum encoding code") - } -} - // TestTypeScriptInt64TypesMatchGoEncoding verifies TypeScript types match Go encoding. func TestTypeScriptInt64TypesMatchGoEncoding(t *testing.T) { baseDir, baseErr := os.Getwd() @@ -462,9 +395,6 @@ func verifyCriterion6CrossGenerator(t *testing.T, baseDir string) { // Go httpgen filepath.Join(baseDir, "testdata", "golden", "int64_encoding_encoding.pb.go"), filepath.Join(baseDir, "testdata", "golden", "enum_encoding_enum_encoding.pb.go"), - // Go clientgen - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "int64_encoding_encoding.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "enum_encoding_enum_encoding.pb.go"), // TypeScript filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "int64_encoding_client.ts"), filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "enum_encoding_client.ts"), @@ -479,96 +409,7 @@ func verifyCriterion6CrossGenerator(t *testing.T, baseDir string) { } } - t.Log("PASS: Criterion 6 verified - All generators have encoding golden files") -} - -// normalizeGeneratorComment replaces the generator name in the "Code generated by" comment -// to allow comparison between go-http and go-client output. -func normalizeGeneratorComment(content, generatorName string) string { - content = strings.ReplaceAll(content, "protoc-gen-"+generatorName, "protoc-gen-NORMALIZED") - return content -} - -// extractMarshalJSON extracts all MarshalJSON function blocks from Go source code. -// This is used by consistency tests to compare the serialization logic between -// go-http and go-client generators while ignoring the intentional UnmarshalJSON -// divergence (go-client uses UnmarshalJSONSebuf with opts forwarding). -func extractMarshalJSON(content string) string { - var result strings.Builder - lines := strings.Split(content, "\n") - inMarshal := false - braceDepth := 0 - - for _, line := range lines { - if !inMarshal && strings.Contains(line, "MarshalJSON") && - !strings.Contains(line, "Unmarshal") { - inMarshal = true - braceDepth = 0 - } - - if inMarshal { - result.WriteString(line) - result.WriteString("\n") - braceDepth += strings.Count(line, "{") - strings.Count(line, "}") - if braceDepth <= 0 && strings.TrimSpace(line) == "}" { - inMarshal = false - result.WriteString("\n") - } - } - } - return result.String() -} - -// compareEncodingFiles compares go-http and go-client encoding golden files. -// MarshalJSON must be byte-identical (serialization correctness). -// UnmarshalJSON is allowed to differ because go-client uses UnmarshalJSONSebuf -// with opts forwarding for forward-compatibility. -func compareEncodingFiles(t *testing.T, httpgenFile, clientgenFile, label string) { - t.Helper() - - httpgenContent, httpErr := os.ReadFile(httpgenFile) - if httpErr != nil { - t.Fatalf("Failed to read httpgen %s golden file: %v", label, httpErr) - } - - clientgenContent, clientErr := os.ReadFile(clientgenFile) - if clientErr != nil { - t.Fatalf("Failed to read clientgen %s golden file: %v", label, clientErr) - } - - httpMarshal := extractMarshalJSON(string(httpgenContent)) - clientMarshal := extractMarshalJSON(string(clientgenContent)) - - if httpMarshal != clientMarshal { - t.Errorf("go-http and go-client %s MarshalJSON code differs", label) - t.Logf("First difference:\n%s", findFirstDifference(httpMarshal, clientMarshal)) - } else { - t.Logf("go-http and go-client produce identical %s MarshalJSON code", label) - } -} - -// findFirstDifference returns a description of the first difference between two strings. -func findFirstDifference(a, b string) string { - lines1 := strings.Split(a, "\n") - lines2 := strings.Split(b, "\n") - - for i := 0; i < len(lines1) && i < len(lines2); i++ { - if lines1[i] != lines2[i] { - return "Line " + formatLineNumber(i) + - ":\n Expected: " + lines1[i] + "\n Actual: " + lines2[i] - } - } - - if len(lines1) != len(lines2) { - return "Different number of lines: " + formatLineNumber(len(lines1)) + - " vs " + formatLineNumber(len(lines2)) - } - - return "No difference found" -} - -func formatLineNumber(n int) string { - return string(rune('0'+n/100)) + string(rune('0'+(n/10)%10)) + string(rune('0'+n%10)) + t.Log("PASS: Criterion 6 verified - Go, TypeScript, and OpenAPI encoding golden files exist") } // TestBackwardCompatibility verifies protos without encoding annotations are unchanged. diff --git a/internal/httpgen/enum_field_encoding_consistency_test.go b/internal/httpgen/enum_field_encoding_consistency_test.go index 687f8499..33d03cba 100644 --- a/internal/httpgen/enum_field_encoding_consistency_test.go +++ b/internal/httpgen/enum_field_encoding_consistency_test.go @@ -7,26 +7,6 @@ import ( "testing" ) -// TestGoGeneratorsProduceIdenticalEnumFieldEncoding verifies go-http and go-client -// emit identical message-level enum_value MarshalJSON/UnmarshalJSON code. -func TestGoGeneratorsProduceIdenticalEnumFieldEncoding(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "enum_encoding_enum_field_encoding.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "enum_encoding_enum_field_encoding.pb.go"), - "enum_field_encoding", - ) - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "enum_nested_enum_field_encoding.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "enum_nested_enum_field_encoding.pb.go"), - "enum_nested_enum_field_encoding", - ) -} - // TestEnumFieldEncodingTransitiveNesting verifies the generated marshaler propagates custom enum // strings through nested messages: a wrapper re-serializes its child via the child's marshaler, so // enums nested any number of levels below the marshaled message are still translated. diff --git a/internal/httpgen/flatten_consistency_test.go b/internal/httpgen/flatten_consistency_test.go index 1415e0bb..b3c6a7aa 100644 --- a/internal/httpgen/flatten_consistency_test.go +++ b/internal/httpgen/flatten_consistency_test.go @@ -8,21 +8,6 @@ import ( "testing" ) -// TestGoGeneratorsProduceIdenticalFlatten verifies go-http and go-client -// produce identical flatten MarshalJSON code. -func TestGoGeneratorsProduceIdenticalFlatten(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "flatten_flatten.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "flatten_flatten.pb.go"), - "flatten", - ) -} - // TestFlattenTypeScriptTypes verifies TypeScript types match Go serialization // for flatten fields (inlined child fields with prefixes). func TestFlattenTypeScriptTypes(t *testing.T) { @@ -236,7 +221,6 @@ func TestFlattenCrossGeneratorAgreement(t *testing.T) { // Verify all golden files exist for cross-generator coverage goldenFiles := []string{ filepath.Join(baseDir, "testdata", "golden", "flatten_flatten.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "flatten_flatten.pb.go"), filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "flatten_client.ts"), filepath.Join( baseDir, "..", "openapiv3", "testdata", "golden", "yaml", "FlattenService.openapi.yaml", @@ -249,7 +233,7 @@ func TestFlattenCrossGeneratorAgreement(t *testing.T) { } } - t.Log("All 4 generators agree on flatten structure and field names") + t.Log("Go, TypeScript, and OpenAPI agree on flatten structure and field names") } // verifyFlattenPresent checks that all 4 generators agree on a flattened message. diff --git a/internal/httpgen/nullable_consistency_test.go b/internal/httpgen/nullable_consistency_test.go index d4b2e614..e1175b74 100644 --- a/internal/httpgen/nullable_consistency_test.go +++ b/internal/httpgen/nullable_consistency_test.go @@ -7,21 +7,6 @@ import ( "testing" ) -// TestNullableConsistencyGoHTTPvsGoClient verifies go-http and go-client -// produce identical nullable MarshalJSON code. -func TestNullableConsistencyGoHTTPvsGoClient(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "nullable_nullable.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "nullable_nullable.pb.go"), - "nullable", - ) -} - // TestNullableConsistencyTypeScript verifies TypeScript uses T | null for nullable fields. func TestNullableConsistencyTypeScript(t *testing.T) { baseDir, baseErr := os.Getwd() @@ -149,8 +134,6 @@ func TestNullableConsistencyBackwardCompat(t *testing.T) { goldenFiles := []string{ // Go httpgen filepath.Join(baseDir, "testdata", "golden", "nullable_nullable.pb.go"), - // Go clientgen - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "nullable_nullable.pb.go"), // TypeScript filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "nullable_client.ts"), // OpenAPI diff --git a/internal/httpgen/oneof_discriminator.go b/internal/httpgen/oneof_discriminator.go index cbc08315..a72306d9 100644 --- a/internal/httpgen/oneof_discriminator.go +++ b/internal/httpgen/oneof_discriminator.go @@ -326,7 +326,7 @@ func (g *Generator) generateOneofUnmarshalVariants( gf.P(`if discRaw, ok := raw["`, info.Discriminator, `"]; ok {`) gf.P("var disc string") gf.P("if err := json.Unmarshal(discRaw, &disc); err != nil {") - gf.P(`return fmt.Errorf("invalid discriminator %q: %%w", "`, info.Discriminator, `", err)`) + gf.P(`return fmt.Errorf("invalid discriminator %q: %w", "`, info.Discriminator, `", err)`) gf.P("}") gf.P() @@ -380,7 +380,7 @@ func (g *Generator) generateFlattenedUnmarshal( gf.P("variantData, _ := json.Marshal(variantMap)") gf.P("variant := &", msgType, "{}") gf.P("if err := json.Unmarshal(variantData, variant); err != nil {") - gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %%w", "`, fieldGoName, `", err)`) + gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %w", "`, fieldGoName, `", err)`) gf.P("}") gf.P("x.", info.Oneof.GoName, " = &", wrapperType, "{", fieldGoName, ": variant}") @@ -406,7 +406,7 @@ func (g *Generator) generateNestedUnmarshal( gf.P(`if variantRaw, exists := raw["`, fieldJSONName, `"]; exists {`) gf.P("variant := &", msgType, "{}") gf.P("if err := json.Unmarshal(variantRaw, variant); err != nil {") - gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %%w", "`, fieldGoName, `", err)`) + gf.P(`return fmt.Errorf("failed to unmarshal variant %s: %w", "`, fieldGoName, `", err)`) gf.P("}") gf.P("x.", info.Oneof.GoName, " = &", wrapperType, "{", fieldGoName, ": variant}") gf.P("}") diff --git a/internal/httpgen/oneof_discriminator_consistency_test.go b/internal/httpgen/oneof_discriminator_consistency_test.go index e6b4c9e3..87346a3d 100644 --- a/internal/httpgen/oneof_discriminator_consistency_test.go +++ b/internal/httpgen/oneof_discriminator_consistency_test.go @@ -8,24 +8,6 @@ import ( "testing" ) -// TestGoGeneratorsProduceIdenticalOneofDiscriminator verifies go-http and go-client -// produce identical oneof_discriminator MarshalJSON code. -func TestGoGeneratorsProduceIdenticalOneofDiscriminator(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - httpgenFile := filepath.Join( - baseDir, "testdata", "golden", "oneof_discriminator_oneof_discriminator.pb.go", - ) - clientgenFile := filepath.Join( - baseDir, "..", "clientgen", "testdata", "golden", - "oneof_discriminator_oneof_discriminator.pb.go", - ) - compareEncodingFiles(t, httpgenFile, clientgenFile, "oneof_discriminator") -} - // TestOneofDiscriminatorTypeScriptTypes verifies TypeScript types match Go serialization // for oneof discriminator fields (discriminated union types). func TestOneofDiscriminatorTypeScriptTypes(t *testing.T) { @@ -111,6 +93,25 @@ func TestOneofDiscriminatorTypeScriptTypes(t *testing.T) { // TestOneofDiscriminatorOpenAPISchemas verifies OpenAPI schemas accurately document // oneof discriminator (oneOf + discriminator keyword). +func TestOneofDiscriminatorGeneratedErrorsWrapUnderlyingError(t *testing.T) { + baseDir, baseErr := os.Getwd() + if baseErr != nil { + t.Fatalf("Failed to get working directory: %v", baseErr) + } + + goFile := filepath.Join( + baseDir, "testdata", "golden", "oneof_discriminator_oneof_discriminator.pb.go", + ) + content, readErr := os.ReadFile(goFile) + if readErr != nil { + t.Fatalf("Failed to read Go oneof discriminator golden file: %v", readErr) + } + + if strings.Contains(string(content), "%%w") { + t.Fatalf("generated oneof discriminator errors should use %%w, not escaped %%%%w") + } +} + func TestOneofDiscriminatorOpenAPISchemas(t *testing.T) { baseDir, baseErr := os.Getwd() if baseErr != nil { @@ -272,10 +273,6 @@ func TestOneofDiscriminatorCrossGeneratorAgreement(t *testing.T) { filepath.Join( baseDir, "testdata", "golden", "oneof_discriminator_oneof_discriminator.pb.go", ), - filepath.Join( - baseDir, "..", "clientgen", "testdata", "golden", - "oneof_discriminator_oneof_discriminator.pb.go", - ), filepath.Join( baseDir, "..", "tsclientgen", "testdata", "golden", "oneof_discriminator_client.ts", ), @@ -291,10 +288,10 @@ func TestOneofDiscriminatorCrossGeneratorAgreement(t *testing.T) { } } - t.Log("All 4 generators agree on oneof discriminator structure and values") + t.Log("Go, TypeScript, and OpenAPI agree on oneof discriminator structure and values") } -// verifyOneofDiscriminatorPresent checks that all 4 generators agree on a discriminated message. +// verifyOneofDiscriminatorPresent checks that Go, TypeScript, and OpenAPI agree on a discriminated message. func verifyOneofDiscriminatorPresent( t *testing.T, message, discriminatorField string, diff --git a/internal/httpgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go b/internal/httpgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go index 8f71556e..b359b8a6 100644 --- a/internal/httpgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go +++ b/internal/httpgen/testdata/golden/oneof_discriminator_oneof_discriminator.pb.go @@ -104,7 +104,7 @@ func (x *FlattenedEvent) UnmarshalJSON(data []byte) error { if discRaw, ok := raw["type"]; ok { var disc string if err := json.Unmarshal(discRaw, &disc); err != nil { - return fmt.Errorf("invalid discriminator %q: %%w", "type", err) + return fmt.Errorf("invalid discriminator %q: %w", "type", err) } switch disc { @@ -118,7 +118,7 @@ func (x *FlattenedEvent) UnmarshalJSON(data []byte) error { variantData, _ := json.Marshal(variantMap) variant := &TextContent{} if err := json.Unmarshal(variantData, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Text", err) + return fmt.Errorf("failed to unmarshal variant %s: %w", "Text", err) } x.Content = &FlattenedEvent_Text{Text: variant} raw["text"], _ = json.Marshal(variant) @@ -140,7 +140,7 @@ func (x *FlattenedEvent) UnmarshalJSON(data []byte) error { variantData, _ := json.Marshal(variantMap) variant := &ImageContent{} if err := json.Unmarshal(variantData, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Image", err) + return fmt.Errorf("failed to unmarshal variant %s: %w", "Image", err) } x.Content = &FlattenedEvent_Image{Image: variant} raw["image"], _ = json.Marshal(variant) @@ -211,7 +211,7 @@ func (x *NestedEvent) UnmarshalJSON(data []byte) error { if discRaw, ok := raw["kind"]; ok { var disc string if err := json.Unmarshal(discRaw, &disc); err != nil { - return fmt.Errorf("invalid discriminator %q: %%w", "kind", err) + return fmt.Errorf("invalid discriminator %q: %w", "kind", err) } switch disc { @@ -220,7 +220,7 @@ func (x *NestedEvent) UnmarshalJSON(data []byte) error { if variantRaw, exists := raw["text"]; exists { variant := &TextContent{} if err := json.Unmarshal(variantRaw, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Text", err) + return fmt.Errorf("failed to unmarshal variant %s: %w", "Text", err) } x.Content = &NestedEvent_Text{Text: variant} } @@ -229,7 +229,7 @@ func (x *NestedEvent) UnmarshalJSON(data []byte) error { if variantRaw, exists := raw["image"]; exists { variant := &ImageContent{} if err := json.Unmarshal(variantRaw, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Image", err) + return fmt.Errorf("failed to unmarshal variant %s: %w", "Image", err) } x.Content = &NestedEvent_Image{Image: variant} } @@ -238,7 +238,7 @@ func (x *NestedEvent) UnmarshalJSON(data []byte) error { if variantRaw, exists := raw["video"]; exists { variant := &VideoContent{} if err := json.Unmarshal(variantRaw, variant); err != nil { - return fmt.Errorf("failed to unmarshal variant %s: %%w", "Video", err) + return fmt.Errorf("failed to unmarshal variant %s: %w", "Video", err) } x.Content = &NestedEvent_Video{Video: variant} } diff --git a/internal/httpgen/timestamp_format_consistency_test.go b/internal/httpgen/timestamp_format_consistency_test.go index f3097417..4e1c057d 100644 --- a/internal/httpgen/timestamp_format_consistency_test.go +++ b/internal/httpgen/timestamp_format_consistency_test.go @@ -8,21 +8,6 @@ import ( "testing" ) -// TestGoGeneratorsProduceIdenticalTimestampFormat verifies go-http and go-client -// produce identical timestamp_format MarshalJSON code. -func TestGoGeneratorsProduceIdenticalTimestampFormat(t *testing.T) { - baseDir, baseErr := os.Getwd() - if baseErr != nil { - t.Fatalf("Failed to get working directory: %v", baseErr) - } - - compareEncodingFiles(t, - filepath.Join(baseDir, "testdata", "golden", "timestamp_format_timestamp_format.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "timestamp_format_timestamp_format.pb.go"), - "timestamp_format", - ) -} - // TestTimestampFormatTypeScriptTypes verifies TypeScript types match Go serialization // for timestamp format fields. func TestTimestampFormatTypeScriptTypes(t *testing.T) { @@ -196,7 +181,6 @@ func TestTimestampFormatCrossGeneratorAgreement(t *testing.T) { // Verify all golden files exist for cross-generator coverage goldenFiles := []string{ filepath.Join(baseDir, "testdata", "golden", "timestamp_format_timestamp_format.pb.go"), - filepath.Join(baseDir, "..", "clientgen", "testdata", "golden", "timestamp_format_timestamp_format.pb.go"), filepath.Join(baseDir, "..", "tsclientgen", "testdata", "golden", "timestamp_format_client.ts"), filepath.Join( baseDir, "..", "openapiv3", "testdata", "golden", "yaml", "TimestampFormatService.openapi.yaml", @@ -209,5 +193,5 @@ func TestTimestampFormatCrossGeneratorAgreement(t *testing.T) { } } - t.Log("All 4 generators agree on timestamp format types and formats") + t.Log("Go, TypeScript, and OpenAPI agree on timestamp format types and formats") } diff --git a/internal/httpgen/transitive_int64_consistency_test.go b/internal/httpgen/transitive_int64_consistency_test.go index bec3660a..27469162 100644 --- a/internal/httpgen/transitive_int64_consistency_test.go +++ b/internal/httpgen/transitive_int64_consistency_test.go @@ -104,9 +104,6 @@ func TestDeepNestedInt64WrapperGenerated(t *testing.T) { // which also owns MarshalJSON via another annotation is rejected at generation time. Widening // wrapper detection widens the set of messages that can collide: a silent skip would serialize // the int64 as a quoted string, and a duplicate emit would not compile. -// -// Both Go generators carry their own copy of the check, so both are driven here — the two must -// never disagree about which protos they accept. func TestInt64WrapperMarshalJSONConflict(t *testing.T) { requireProtocForInt64Tests(t) @@ -118,21 +115,17 @@ func TestInt64WrapperMarshalJSONConflict(t *testing.T) { {"go-client", func(p *protogen.Plugin) error { return clientgen.New(p).Generate() }}, } - t.Run("conflict is rejected and names both features", func(t *testing.T) { - for _, gen := range generators { - t.Run(gen.name, func(t *testing.T) { - err := gen.run(buildInt64TestPlugin(t, []string{"int64_wrapper_conflict.proto"})) - if err == nil { - t.Fatal("expected generation to fail for a message that is both an int64 " + - "wrapper and carries flatten -- emitting both would declare " + - "MarshalJSONSebuf twice on the same Go type") - } - for _, want := range []string{"ConflictingResponse", "flatten", "only one MarshalJSON"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("conflict error should mention %q, got: %v", want, err) - } - } - }) + t.Run("go-http rejects conflicts and names both features", func(t *testing.T) { + err := New(buildInt64TestPlugin(t, []string{"int64_wrapper_conflict.proto"})).Generate() + if err == nil { + t.Fatal("expected go-http generation to fail for a message that is both an int64 " + + "wrapper and carries flatten -- emitting both would declare " + + "MarshalJSONSebuf twice on the same Go type") + } + for _, want := range []string{"ConflictingResponse", "flatten", "only one MarshalJSON"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("conflict error should mention %q, got: %v", want, err) + } } })