Skip to content

Allow JSON lowerCamelCase names #299

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 14, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clientcompat/internal/clientcompat/clientcompat.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions clientcompat/internal/clientcompat/clientcompat.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion example/service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion example/service.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/empty_service/empty_service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions internal/twirptest/empty_service/empty_service.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/google_protobuf_imports/service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion internal/twirptest/google_protobuf_imports/service.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/importable/importable.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion internal/twirptest/importable/importable.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/importer/importer.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion internal/twirptest/importer/importer.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/importer_local/importer_local.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion internal/twirptest/importer_local/importer_local.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/importmapping/x/x.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion internal/twirptest/importmapping/x/x.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/twirptest/importmapping/y/y.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 55 additions & 7 deletions internal/twirptest/json_serialization/json_serialization_test.go
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import (
bytes "bytes"
"context"
json "encoding/json"
io "io"
"net/http"
"net/http/httptest"
"testing"
@@ -58,13 +59,8 @@ func TestJSONSerializationServiceWithDefaults(t *testing.T) {
if resp.StatusCode != 200 {
t.Fatalf("manual EchoJSON invalid status, have=%d, want=200", resp.StatusCode)
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
var objmap map[string]json.RawMessage
err = json.Unmarshal(buf.Bytes(), &objmap)
if err != nil {
t.Fatalf("json.Unmarshal err=%q", err)
}

objmap := readJSONAsMap(t, resp.Body)
for _, field := range []string{"query", "page_number", "hell", "foobar", "snippets", "all_empty"} {
if _, ok := objmap[field]; !ok {
t.Fatalf("expected JSON response to include field %q", field)
@@ -213,3 +209,55 @@ func TestJSONSerializationServiceSkipDefaults(t *testing.T) {
t.Fatalf("invalid msg2.Snippets[0], have=%v, want=%v", have, want)
}
}

func TestJSONSerializationCamelCase(t *testing.T) {
s := httptest.NewServer(
NewJSONSerializationServer(
&JSONSerializationService{},
twirp.WithServerJSONCamelCaseNames(true),
),
)
defer s.Close()

reqBody := bytes.NewBuffer([]byte(`{"pageNumber": 123}`))
req, _ := http.NewRequest("POST", s.URL+"/twirp/JSONSerialization/EchoJSON", reqBody)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("manual EchoJSON err=%q", err)
}
if resp.StatusCode != 200 {
t.Fatalf("manual EchoJSON invalid status, have=%d, want=200", resp.StatusCode)
}

objmap := readJSONAsMap(t, resp.Body)

// response includes camelCase names
for _, field := range []string{"query", "pageNumber", "hell", "foobar", "snippets", "allEmpty"} {
if _, ok := objmap[field]; !ok {
t.Fatalf("expected JSON response to include camelCase field %q", field)
}
}

// response does not include original snake_case names
for _, field := range []string{"page_number", "all_empty"} {
if _, ok := objmap[field]; ok {
t.Fatalf("expected JSON response to NOT include snake_case field %q", field)
}
}
}

//
// Test helpers
//

func readJSONAsMap(t *testing.T, body io.Reader) map[string]json.RawMessage {
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(body)
var objmap map[string]json.RawMessage
err := json.Unmarshal(buf.Bytes(), &objmap)
if err != nil {
t.Fatalf("json.Unmarshal err=%q", err)
}
return objmap
}
2 changes: 1 addition & 1 deletion internal/twirptest/multiple/multiple1.pb.go
6 changes: 5 additions & 1 deletion internal/twirptest/multiple/multiple1.twirp.go
2 changes: 1 addition & 1 deletion internal/twirptest/multiple/multiple2.pb.go
8 changes: 6 additions & 2 deletions internal/twirptest/multiple/multiple2.twirp.go
2 changes: 1 addition & 1 deletion internal/twirptest/no_package_name/no_package_name.pb.go
6 changes: 5 additions & 1 deletion internal/twirptest/no_package_name/no_package_name.twirp.go
2 changes: 1 addition & 1 deletion internal/twirptest/service.pb.go
6 changes: 5 additions & 1 deletion internal/twirptest/service.twirp.go
2 changes: 1 addition & 1 deletion internal/twirptest/snake_case_names/snake_case_names.pb.go
6 changes: 5 additions & 1 deletion protoc-gen-twirp/generator.go
Original file line number Diff line number Diff line change
@@ -1151,6 +1151,7 @@ func (t *twirp) generateServer(file *descriptor.FileDescriptorProto, service *de
t.P(` hooks *`, t.pkgs["twirp"], `.ServerHooks`)
t.P(` pathPrefix string // prefix for routing`)
t.P(` jsonSkipDefaults bool // do not include unpopulated fields (default values) in the response`)
t.P(` jsonCamelCase bool // JSON fields are serialized as lowerCamelCase rather than keeping the original proto names`)
t.P(`}`)
t.P()

@@ -1164,6 +1165,8 @@ func (t *twirp) generateServer(file *descriptor.FileDescriptorProto, service *de
t.P(` // Using ReadOpt allows backwards and forwads compatibility with new options in the future`)
t.P(` jsonSkipDefaults := false`)
t.P(` _ = serverOpts.ReadOpt("jsonSkipDefaults", &jsonSkipDefaults)`)
t.P(` jsonCamelCase := false`)
t.P(` _ = serverOpts.ReadOpt("jsonCamelCase", &jsonCamelCase)`)
t.P(` var pathPrefix string`)
t.P(` if ok := serverOpts.ReadOpt("pathPrefix", &pathPrefix); !ok {`)
t.P(` pathPrefix = "/twirp" // default prefix`)
@@ -1175,6 +1178,7 @@ func (t *twirp) generateServer(file *descriptor.FileDescriptorProto, service *de
t.P(` interceptor: `, t.pkgs["twirp"], `.ChainInterceptors(serverOpts.Interceptors...),`)
t.P(` pathPrefix: pathPrefix,`)
t.P(` jsonSkipDefaults: jsonSkipDefaults,`)
t.P(` jsonCamelCase: jsonCamelCase,`)
t.P(` }`)
t.P(`}`)
t.P()
@@ -1358,7 +1362,7 @@ func (t *twirp) generateServerJSONMethod(service *descriptor.ServiceDescriptorPr
t.P()
t.P(` ctx = callResponsePrepared(ctx, s.hooks)`)
t.P()
t.P(` marshaler := &`, t.pkgs["protojson"], `.MarshalOptions{UseProtoNames: true, EmitUnpopulated: !s.jsonSkipDefaults}`)
t.P(` marshaler := &`, t.pkgs["protojson"], `.MarshalOptions{UseProtoNames: !s.jsonCamelCase, EmitUnpopulated: !s.jsonSkipDefaults}`)
t.P(` respBytes, err := marshaler.Marshal(respContent)`)
t.P(` if err != nil {`)
t.P(` s.writeError(ctx, resp, wrapInternal(err, "failed to marshal json response"))`)
12 changes: 12 additions & 0 deletions server_options.go
Original file line number Diff line number Diff line change
@@ -61,6 +61,18 @@ func WithServerJSONSkipDefaults(skipDefaults bool) ServerOption {
}
}

// WithServerJSONCamelCaseNames configures JSON serialization to use
// lowerCamelCase field names rather than the original proto field names.
// It is disabled by default, because JSON is commonly used for manual
// debugging, but sometimes converting to lowerCamelCase is needed
// to match the default canonical encoding on other proto-json parsers.
// See: https://developers.google.com/protocol-buffers/docs/proto3#json
func WithServerJSONCamelCaseNames(jsonCamelCase bool) ServerOption {
return func(opts *ServerOptions) {
opts.setOpt("jsonCamelCase", jsonCamelCase)
}
}

// ServerHooks is a container for callbacks that can instrument a
// Twirp-generated server. These callbacks all accept a context and return a
// context. They can use this to add to the request context as it threads