Skip to content

Commit b2a186c

Browse files
authored
feat(sidekick): rust+disco bytes (de)serialization (#2428)
In discovery doc based services bytes are serialized with the url-safe alphabet. The Rust codec needs to take this into account when generating code.
1 parent 1a2aaa0 commit b2a186c

File tree

5 files changed

+279
-0
lines changed

5 files changed

+279
-0
lines changed

internal/sidekick/internal/rust/annotate.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,9 @@ func (c *codec) primitiveSerdeAs(field *api.Field) string {
950950
case api.DOUBLE_TYPE:
951951
return "wkt::internal::F64"
952952
case api.BYTES_TYPE:
953+
if c.bytesUseUrlSafeAlphabet {
954+
return "serde_with::base64::Base64<serde_with::base64::UrlSafe>"
955+
}
953956
return "serde_with::base64::Base64"
954957
default:
955958
return ""
@@ -973,6 +976,9 @@ func (c *codec) mapValueSerdeAs(field *api.Field) string {
973976
func (c *codec) messageFieldSerdeAs(field *api.Field) string {
974977
switch field.TypezID {
975978
case ".google.protobuf.BytesValue":
979+
if c.bytesUseUrlSafeAlphabet {
980+
return "serde_with::base64::Base64<serde_with::base64::UrlSafe>"
981+
}
976982
return "serde_with::base64::Base64"
977983
case ".google.protobuf.UInt64Value":
978984
return "wkt::internal::U64"

internal/sidekick/internal/rust/annotate_field_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,55 @@ func TestPrimitiveFieldAnnotations(t *testing.T) {
217217
}
218218
}
219219

220+
func TestBytesAnnotations(t *testing.T) {
221+
for _, test := range []struct {
222+
sourceSpecification string
223+
wantType string
224+
wantSerdeAs string
225+
}{
226+
{"protobuf", "::bytes::Bytes", "serde_with::base64::Base64"},
227+
{"openapi", "::bytes::Bytes", "serde_with::base64::Base64"},
228+
{"disco", "::bytes::Bytes", "serde_with::base64::Base64<serde_with::base64::UrlSafe>"},
229+
} {
230+
singular_field := &api.Field{
231+
Name: "singular_field",
232+
JSONName: "singularField",
233+
ID: ".test.Message.singular_field",
234+
Typez: api.BYTES_TYPE,
235+
TypezID: "bytes",
236+
}
237+
message := &api.Message{
238+
Name: "TestMessage",
239+
Package: "test",
240+
ID: ".test.TestMessage",
241+
Documentation: "A test message.",
242+
Fields: []*api.Field{singular_field},
243+
}
244+
model := api.NewTestAPI([]*api.Message{message}, []*api.Enum{}, []*api.Service{})
245+
api.CrossReference(model)
246+
api.LabelRecursiveFields(model)
247+
codec, err := newCodec(test.sourceSpecification, map[string]string{})
248+
if err != nil {
249+
t.Fatal(err)
250+
}
251+
annotateModel(model, codec)
252+
253+
wantField := &fieldAnnotations{
254+
FieldName: "singular_field",
255+
SetterName: "singular_field",
256+
BranchName: "SingularField",
257+
FQMessageName: "crate::model::TestMessage",
258+
FieldType: test.wantType,
259+
PrimitiveFieldType: test.wantType,
260+
SerdeAs: test.wantSerdeAs,
261+
AddQueryParameter: `let builder = builder.query(&[("singularField", &req.singular_field)]);`,
262+
}
263+
if diff := cmp.Diff(wantField, singular_field.Codec); diff != "" {
264+
t.Errorf("mismatch in field annotations (-want, +got)\n:%s", diff)
265+
}
266+
}
267+
}
268+
220269
func TestWrapperFieldAnnotations(t *testing.T) {
221270
for _, test := range []struct {
222271
wantType string
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package rust
16+
17+
import (
18+
"fmt"
19+
"testing"
20+
21+
"github.com/googleapis/librarian/internal/sidekick/internal/api"
22+
)
23+
24+
func TestMapKeyAnnotations(t *testing.T) {
25+
for _, test := range []struct {
26+
wantSerdeAs string
27+
typez api.Typez
28+
}{
29+
{"wkt::internal::I32", api.INT32_TYPE},
30+
{"wkt::internal::I32", api.SFIXED32_TYPE},
31+
{"wkt::internal::I32", api.SINT32_TYPE},
32+
{"wkt::internal::I64", api.INT64_TYPE},
33+
{"wkt::internal::I64", api.SFIXED64_TYPE},
34+
{"wkt::internal::I64", api.SINT64_TYPE},
35+
{"wkt::internal::U32", api.UINT32_TYPE},
36+
{"wkt::internal::U32", api.FIXED32_TYPE},
37+
{"wkt::internal::U64", api.UINT64_TYPE},
38+
{"wkt::internal::U64", api.FIXED64_TYPE},
39+
{"serde_with::DisplayFromStr", api.BOOL_TYPE},
40+
} {
41+
mapMessage := &api.Message{
42+
Name: "$map<unused, unused>",
43+
ID: "$map<unused, unused>",
44+
Package: "$",
45+
IsMap: true,
46+
Fields: []*api.Field{
47+
{
48+
Name: "key",
49+
ID: "$map<unused, unused>.key",
50+
Typez: test.typez,
51+
TypezID: "unused",
52+
},
53+
{
54+
Name: "value",
55+
ID: "$map<unused, unused>.value",
56+
Typez: api.STRING_TYPE,
57+
TypezID: "unused",
58+
},
59+
},
60+
}
61+
field := &api.Field{
62+
Name: "field",
63+
JSONName: "field",
64+
ID: ".test.Message.field",
65+
Typez: api.MESSAGE_TYPE,
66+
TypezID: "$map<unused, unused>",
67+
}
68+
message := &api.Message{
69+
Name: "TestMessage",
70+
Package: "test",
71+
ID: ".test.TestMessage",
72+
Documentation: "A test message.",
73+
Fields: []*api.Field{field},
74+
}
75+
model := api.NewTestAPI([]*api.Message{message, mapMessage}, []*api.Enum{}, []*api.Service{})
76+
api.CrossReference(model)
77+
api.LabelRecursiveFields(model)
78+
codec, err := newCodec("protobuf", map[string]string{})
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
annotateModel(model, codec)
83+
84+
got := field.Codec.(*fieldAnnotations).SerdeAs
85+
want := fmt.Sprintf("std::collections::HashMap<%s, serde_with::Same>", test.wantSerdeAs)
86+
if got != want {
87+
t.Errorf("mismatch for %s, want=%q, got=%q", test.wantSerdeAs, want, got)
88+
}
89+
}
90+
}
91+
92+
func TestMapValueAnnotations(t *testing.T) {
93+
for _, test := range []struct {
94+
spec string
95+
typez api.Typez
96+
typezID string
97+
wantSerdeAs string
98+
}{
99+
{"protobuf", api.STRING_TYPE, "unused", "serde_with::Same"},
100+
{"disco", api.STRING_TYPE, "unused", "serde_with::Same"},
101+
{"protobuf", api.BYTES_TYPE, "unused", "serde_with::base64::Base64"},
102+
{"disco", api.BYTES_TYPE, "unused", "serde_with::base64::Base64<serde_with::base64::UrlSafe>"},
103+
{"protobuf", api.MESSAGE_TYPE, ".google.protobuf.BytesValue", "serde_with::base64::Base64"},
104+
{"disco", api.MESSAGE_TYPE, ".google.protobuf.BytesValue", "serde_with::base64::Base64<serde_with::base64::UrlSafe>"},
105+
106+
{"protobuf", api.BOOL_TYPE, "unused", "serde_with::Same"},
107+
{"protobuf", api.INT32_TYPE, "unused", "wkt::internal::I32"},
108+
{"protobuf", api.SFIXED32_TYPE, "unused", "wkt::internal::I32"},
109+
{"protobuf", api.SINT32_TYPE, "unused", "wkt::internal::I32"},
110+
{"protobuf", api.INT64_TYPE, "unused", "wkt::internal::I64"},
111+
{"protobuf", api.SFIXED64_TYPE, "unused", "wkt::internal::I64"},
112+
{"protobuf", api.SINT64_TYPE, "unused", "wkt::internal::I64"},
113+
{"protobuf", api.UINT32_TYPE, "unused", "wkt::internal::U32"},
114+
{"protobuf", api.FIXED32_TYPE, "unused", "wkt::internal::U32"},
115+
{"protobuf", api.UINT64_TYPE, "unused", "wkt::internal::U64"},
116+
{"protobuf", api.FIXED64_TYPE, "unused", "wkt::internal::U64"},
117+
118+
{"protobuf", api.MESSAGE_TYPE, ".google.protobuf.UInt64Value", "wkt::internal::U64"},
119+
{"protobuf", api.MESSAGE_TYPE, ".test.Message", "serde_with::Same"},
120+
} {
121+
mapMessage := &api.Message{
122+
Name: "$map<unused, unused>",
123+
ID: "$map<unused, unused>",
124+
Package: "$",
125+
IsMap: true,
126+
Fields: []*api.Field{
127+
{
128+
Name: "key",
129+
ID: "$map<unused, unused>.key",
130+
Typez: api.INT32_TYPE,
131+
TypezID: "unused",
132+
},
133+
{
134+
Name: "value",
135+
ID: "$map<unused, unused>.value",
136+
Typez: test.typez,
137+
TypezID: test.typezID,
138+
},
139+
},
140+
}
141+
field := &api.Field{
142+
Name: "field",
143+
JSONName: "field",
144+
ID: ".test.Message.field",
145+
Typez: api.MESSAGE_TYPE,
146+
TypezID: "$map<unused, unused>",
147+
}
148+
message := &api.Message{
149+
Name: "Message",
150+
Package: "test",
151+
ID: ".test.Message",
152+
Documentation: "A test message.",
153+
Fields: []*api.Field{field},
154+
}
155+
model := api.NewTestAPI([]*api.Message{message, mapMessage}, []*api.Enum{}, []*api.Service{})
156+
api.CrossReference(model)
157+
api.LabelRecursiveFields(model)
158+
codec, err := newCodec(test.spec, map[string]string{})
159+
if err != nil {
160+
t.Fatal(err)
161+
}
162+
annotateModel(model, codec)
163+
164+
got := field.Codec.(*fieldAnnotations).SerdeAs
165+
want := fmt.Sprintf("std::collections::HashMap<wkt::internal::I32, %s>", test.wantSerdeAs)
166+
if got != want {
167+
t.Errorf("mismatch for %v, want=%q, got=%q", test, want, got)
168+
}
169+
}
170+
}
171+
172+
// A map without any SerdeAs mapping receives a special annotation.
173+
func TestMapAnnotationsSameSame(t *testing.T) {
174+
mapMessage := &api.Message{
175+
Name: "$map<string, string>",
176+
ID: "$map<string, string>",
177+
Package: "$",
178+
IsMap: true,
179+
Fields: []*api.Field{
180+
{
181+
Name: "key",
182+
ID: "$map<string, string>.key",
183+
Typez: api.STRING_TYPE,
184+
TypezID: "unused",
185+
},
186+
{
187+
Name: "value",
188+
ID: "$map<string, string>.value",
189+
Typez: api.STRING_TYPE,
190+
},
191+
},
192+
}
193+
field := &api.Field{
194+
Name: "field",
195+
JSONName: "field",
196+
ID: ".test.Message.field",
197+
Typez: api.MESSAGE_TYPE,
198+
TypezID: "$map<unused, unused>",
199+
}
200+
message := &api.Message{
201+
Name: "Message",
202+
Package: "test",
203+
ID: ".test.Message",
204+
Documentation: "A test message.",
205+
Fields: []*api.Field{field},
206+
}
207+
model := api.NewTestAPI([]*api.Message{message, mapMessage}, []*api.Enum{}, []*api.Service{})
208+
api.CrossReference(model)
209+
api.LabelRecursiveFields(model)
210+
codec, err := newCodec("protobuf", map[string]string{})
211+
if err != nil {
212+
t.Fatal(err)
213+
}
214+
annotateModel(model, codec)
215+
216+
got := field.Codec.(*fieldAnnotations).SerdeAs
217+
if got != "" {
218+
t.Errorf("mismatch for %v, got=%q", mapMessage, got)
219+
}
220+
}

internal/sidekick/internal/rust/codec.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func newCodec(specificationFormat string, options map[string]string) (*codec, er
7373
releaseLevel: "preview",
7474
systemParameters: sysParams,
7575
serializeEnumsAsStrings: specificationFormat != "protobuf",
76+
bytesUseUrlSafeAlphabet: specificationFormat == "disco",
7677
}
7778

7879
for key, definition := range options {
@@ -251,6 +252,8 @@ type codec struct {
251252
systemParameters []systemParameter
252253
// If true, enums are serialized as strings.
253254
serializeEnumsAsStrings bool
255+
// If true, bytes are serialized using the url-safe alphabet.
256+
bytesUseUrlSafeAlphabet bool
254257
// Overrides the template subdirectory.
255258
templateOverride string
256259
// If true, this includes gRPC-only methods, such as methods without HTTP

internal/sidekick/internal/rust/codec_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ func TestParseOptionsTemplateOverride(t *testing.T) {
168168
{Name: "$alt", Value: "json"},
169169
},
170170
serializeEnumsAsStrings: true,
171+
bytesUseUrlSafeAlphabet: true,
171172
templateOverride: "templates/fancy-templates",
172173
}
173174
sort.Slice(want.extraPackages, func(i, j int) bool {

0 commit comments

Comments
 (0)