Skip to content

Commit 70bd64e

Browse files
fern-supportjsklanclaudedevin-ai-integration[bot]
authored
fix(go): support form-urlencoded request body encoding (#11426)
* fix(go): support form-urlencoded request body encoding OAuth token requests and other endpoints with `application/x-www-form-urlencoded` content type were failing because the Go SDK always serialized request bodies as JSON, regardless of the Content-Type header. This fix modifies the internal caller to: - Detect form-urlencoded content type from endpoint headers - Use `url.Values.Encode()` for form-urlencoded requests instead of JSON marshaling - Properly handle struct fields using json tags for field names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * feat(go): add wire tests for OAuth client credentials fixture - Add OAuthWireTestGenerator to generate OAuth-specific wire tests - Enable wire tests for oauth-client-credentials fixture in seed.yml - Add content-type: application/x-www-form-urlencoded to OAuth token endpoints - Tests validate form URL encoded body encoding and custom header propagation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix(go): make OAuth wire tests dynamic to support different SDK structures The OAuth wire test generator now dynamically extracts service structure from the IR instead of hardcoding assumptions. This allows the tests to work with different OAuth configurations: - Service accessor path (e.g., OAuth2 vs Auth) - Method name (e.g., GetToken vs GetTokenWithClientCredentials) - Field names (e.g., ClientID vs ClientId) - Field types (optional pointer vs required string) - Test file location based on actual service package 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * Update version * chore: update oauth-client-credentials test definition Co-Authored-By: [email protected] <[email protected]> * chore: remove oauth-client-credentials from seed-groups.json files Co-Authored-By: [email protected] <[email protected]> * refactor(go): improve OAuthWireTestGenerator to follow canonical patterns - Use context.getClientFileLocation() instead of custom getPackageDir() logic - Use context.getFieldName() for field names instead of custom goExportedFieldName - Implement dynamic request type derivation matching ClientGenerator pattern - Add helper methods for request property field names and optionality checks Co-Authored-By: [email protected] <[email protected]> * test(go): add unit tests for form URL encoding in caller - Add TestNewFormURLEncodedBody for map-based form encoding - Add TestNewFormURLEncodedRequestBody for struct-based form encoding - Add TestNewRequestBodyFormURLEncoded for content-type selection logic - Include tests for special characters, omitempty handling, and pointer fields Co-Authored-By: [email protected] <[email protected]> * Update new test and confirm tests pass in fixture * Update go sdk output * fix(go): use unique docker-compose project names to prevent parallel test conflicts Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: jsklan <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 9a2e55e commit 70bd64e

File tree

24 files changed

+1735
-38
lines changed

24 files changed

+1735
-38
lines changed

.github/workflow-resource-files/seed-groups/go-sdk-seed-groups.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
"totalTestTimeSeconds": 8426,
33
"groups": [
44
{
5-
"fixtures": [
6-
"version",
7-
"simple-api",
8-
"oauth-client-credentials",
9-
"imdb:deep-package-path",
5+
"fixtures": [
6+
"version",
7+
"simple-api",
8+
"imdb:deep-package-path",
109
"imdb:package-path",
1110
"inferred-auth-explicit",
1211
"package-yml:no-custom-config",

.github/workflow-resource-files/seed-groups/php-sdk-seed-groups.json

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
"totalTestTimeSeconds": 8014,
33
"groups": [
44
{
5-
"fixtures": [
6-
"query-parameters-openapi-as-objects",
7-
"oauth-client-credentials-nested-root",
8-
"pagination:no-custom-config",
9-
"property-access",
10-
"mixed-case",
11-
"inferred-auth-implicit-no-expiry",
12-
"simple-fhir",
13-
"oauth-client-credentials",
14-
"websocket-bearer-auth",
5+
"fixtures": [
6+
"query-parameters-openapi-as-objects",
7+
"oauth-client-credentials-nested-root",
8+
"pagination:no-custom-config",
9+
"property-access",
10+
"mixed-case",
11+
"inferred-auth-implicit-no-expiry",
12+
"simple-fhir",
13+
"websocket-bearer-auth",
1514
"cross-package-type-names",
1615
"examples:readme-config"
1716
],

generators/go-v2/base/src/asIs/internal/caller.go_

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import (
1717

1818
const (
1919
// contentType specifies the JSON Content-Type header value.
20-
contentType = "application/json"
21-
contentTypeHeader = "Content-Type"
20+
contentType = "application/json"
21+
contentTypeHeader = "Content-Type"
22+
contentTypeFormURLEncoded = "application/x-www-form-urlencoded"
2223
)
2324

2425
// Caller calls APIs and deserializes their response, if any.
@@ -180,7 +181,14 @@ func newRequest(
180181
request interface{},
181182
bodyProperties map[string]interface{},
182183
) (*http.Request, error) {
183-
requestBody, err := newRequestBody(request, bodyProperties)
184+
// Determine the content type from headers, defaulting to JSON.
185+
reqContentType := contentType
186+
if endpointHeaders != nil {
187+
if ct := endpointHeaders.Get(contentTypeHeader); ct != "" {
188+
reqContentType = ct
189+
}
190+
}
191+
requestBody, err := newRequestBody(request, bodyProperties, reqContentType)
184192
if err != nil {
185193
return nil, err
186194
}
@@ -189,19 +197,22 @@ func newRequest(
189197
return nil, err
190198
}
191199
req = req.WithContext(ctx)
192-
req.Header.Set(contentTypeHeader, contentType)
200+
req.Header.Set(contentTypeHeader, reqContentType)
193201
for name, values := range endpointHeaders {
194202
req.Header[name] = values
195203
}
196204
return req, nil
197205
}
198206

199207
// newRequestBody returns a new io.Reader that represents the HTTP request body.
200-
func newRequestBody(request interface{}, bodyProperties map[string]interface{}) (io.Reader, error) {
208+
func newRequestBody(request interface{}, bodyProperties map[string]interface{}, reqContentType string) (io.Reader, error) {
201209
if isNil(request) {
202210
if len(bodyProperties) == 0 {
203211
return nil, nil
204212
}
213+
if reqContentType == contentTypeFormURLEncoded {
214+
return newFormURLEncodedBody(bodyProperties), nil
215+
}
205216
requestBytes, err := json.Marshal(bodyProperties)
206217
if err != nil {
207218
return nil, err
@@ -211,13 +222,82 @@ func newRequestBody(request interface{}, bodyProperties map[string]interface{})
211222
if body, ok := request.(io.Reader); ok {
212223
return body, nil
213224
}
225+
// Handle form URL encoded content type.
226+
if reqContentType == contentTypeFormURLEncoded {
227+
return newFormURLEncodedRequestBody(request, bodyProperties)
228+
}
214229
requestBytes, err := MarshalJSONWithExtraProperties(request, bodyProperties)
215230
if err != nil {
216231
return nil, err
217232
}
218233
return bytes.NewReader(requestBytes), nil
219234
}
220235

236+
// newFormURLEncodedBody returns a new io.Reader that represents a form URL encoded body
237+
// from the given body properties map.
238+
func newFormURLEncodedBody(bodyProperties map[string]interface{}) io.Reader {
239+
values := url.Values{}
240+
for key, val := range bodyProperties {
241+
values.Set(key, fmt.Sprintf("%v", val))
242+
}
243+
return strings.NewReader(values.Encode())
244+
}
245+
246+
// newFormURLEncodedRequestBody returns a new io.Reader that represents a form URL encoded body
247+
// from the given request struct and body properties.
248+
func newFormURLEncodedRequestBody(request interface{}, bodyProperties map[string]interface{}) (io.Reader, error) {
249+
values := url.Values{}
250+
// Use reflection to extract fields from the request struct.
251+
val := reflect.ValueOf(request)
252+
if val.Kind() == reflect.Ptr {
253+
val = val.Elem()
254+
}
255+
if val.Kind() == reflect.Struct {
256+
typ := val.Type()
257+
for i := 0; i < val.NumField(); i++ {
258+
field := typ.Field(i)
259+
fieldVal := val.Field(i)
260+
// Get the json tag to use as the key.
261+
jsonTag := field.Tag.Get("json")
262+
if jsonTag == "" || jsonTag == "-" {
263+
continue
264+
}
265+
// Parse the json tag to get the field name (handles "name,omitempty").
266+
tagName := strings.Split(jsonTag, ",")[0]
267+
if tagName == "" {
268+
continue
269+
}
270+
// Skip zero values for optional fields (those with omitempty).
271+
if strings.Contains(jsonTag, "omitempty") && isZeroValue(fieldVal) {
272+
continue
273+
}
274+
// Handle pointer types by dereferencing.
275+
if fieldVal.Kind() == reflect.Ptr {
276+
if fieldVal.IsNil() {
277+
continue
278+
}
279+
fieldVal = fieldVal.Elem()
280+
}
281+
values.Set(tagName, fmt.Sprintf("%v", fieldVal.Interface()))
282+
}
283+
}
284+
// Add any extra body properties.
285+
for key, val := range bodyProperties {
286+
values.Set(key, fmt.Sprintf("%v", val))
287+
}
288+
return strings.NewReader(values.Encode()), nil
289+
}
290+
291+
// isZeroValue checks if the given reflect.Value is the zero value for its type.
292+
func isZeroValue(v reflect.Value) bool {
293+
switch v.Kind() {
294+
case reflect.Ptr, reflect.Interface, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func:
295+
return v.IsNil()
296+
default:
297+
return v.IsZero()
298+
}
299+
}
300+
221301
// decodeError decodes the error from the given HTTP response. Note that
222302
// it's the caller's responsibility to close the response body.
223303
func decodeError(response *http.Response, errorDecoder ErrorDecoder) error {

0 commit comments

Comments
 (0)