From 73e9596fee08710ef57275b2a70879ea6e768525 Mon Sep 17 00:00:00 2001 From: Jules Cisek Date: Wed, 25 Mar 2026 05:05:27 +0000 Subject: [PATCH 1/4] fix(codegen): implement type-driven form encoding across SDKs Transition from explicitly assigning Content-Type headers inside generated methods to using native language Wrapper Types (e.g. URLSearchParams, FormValues) that the Runtime Library automatically deserializes for application/x-www-form-urlencoded behaviors. Fixes #1673 --- csharp/rtl/Constants.cs | 7 +++ csharp/rtl/Transport.cs | 7 +++ go/rtl/auth.go | 50 +++++++++++++--------- packages/sdk-codegen/src/codeGen.ts | 15 ++++--- packages/sdk-codegen/src/csharp.gen.ts | 23 ++++++++-- packages/sdk-codegen/src/go.gen.ts | 22 ++++++++-- packages/sdk-codegen/src/kotlin.gen.ts | 14 ++++-- packages/sdk-codegen/src/python.gen.ts | 32 +++++++++++--- packages/sdk-codegen/src/swift.gen.ts | 18 +++++--- packages/sdk-codegen/src/typescript.gen.ts | 22 ++++++++-- packages/sdk-rtl/src/baseTransport.ts | 35 ++++++++++++++- python/looker_sdk/rtl/api_methods.py | 35 ++++++++++++--- python/looker_sdk/rtl/model.py | 4 ++ swift/looker/rtl/baseTransport.swift | 3 ++ swift/looker/rtl/constants.swift | 7 +++ 15 files changed, 233 insertions(+), 61 deletions(-) diff --git a/csharp/rtl/Constants.cs b/csharp/rtl/Constants.cs index 3db62650f..3f6ded638 100644 --- a/csharp/rtl/Constants.cs +++ b/csharp/rtl/Constants.cs @@ -80,4 +80,11 @@ public interface IValues : IDictionary public class Values : Dictionary, IValues { } + + /// + /// Type for explicitly specifying url-encoded form values + /// + public class FormValues : Values + { + } } \ No newline at end of file diff --git a/csharp/rtl/Transport.cs b/csharp/rtl/Transport.cs index 14e71e1df..2bc76d3f8 100644 --- a/csharp/rtl/Transport.cs +++ b/csharp/rtl/Transport.cs @@ -234,6 +234,13 @@ public async Task RawRequest( Encoding.UTF8, "application/x-www-form-urlencoded"); } + else if (body is FormValues) + { + var dict = ((FormValues)body) + .Where(k => k.Value != null) + .ToDictionary(k => k.Key, k => k.Value.ToString()); + request.Content = new FormUrlEncodedContent(dict); + } else { request.Content = diff --git a/go/rtl/auth.go b/go/rtl/auth.go index d993bec7f..0c7bfd001 100644 --- a/go/rtl/auth.go +++ b/go/rtl/auth.go @@ -87,6 +87,9 @@ func NewAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper } } +// Values is a map alias used for form urlencoded bodies +type Values map[string]interface{} + func (s *AuthSession) Do(result interface{}, method, ver, path string, reqPars map[string]interface{}, body interface{}, options *ApiSettings) error { // prepare URL @@ -97,29 +100,36 @@ func (s *AuthSession) Do(result interface{}, method, ver, path string, reqPars m // serialize body to string and determine request's Content-Type header if body != nil { - // get the `body`'s type - kind := reflect.TypeOf(body).Kind() - value := reflect.ValueOf(body) - - // check if it is pointer - if kind == reflect.Ptr { - // if so find the type it points to - kind = reflect.ValueOf(body).Elem().Kind() - value = reflect.ValueOf(body).Elem() - } - - if kind == reflect.String { - contentTypeHeader = "text/plain" - bodyString = fmt.Sprintf("%v", value) + if v, ok := body.(Values); ok { + contentTypeHeader = "application/x-www-form-urlencoded" + u := &url.URL{} + setQuery(u, map[string]interface{}(v)) + bodyString = u.RawQuery } else { - contentTypeHeader = "application/json" - serializedBody, err := json.Marshal(body) - - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "error serializing body: %v", err) + // get the `body`'s type + kind := reflect.TypeOf(body).Kind() + value := reflect.ValueOf(body) + + // check if it is pointer + if kind == reflect.Ptr { + // if so find the type it points to + kind = reflect.ValueOf(body).Elem().Kind() + value = reflect.ValueOf(body).Elem() } - bodyString = string(serializedBody) + if kind == reflect.String { + contentTypeHeader = "text/plain" + bodyString = fmt.Sprintf("%v", value) + } else { + contentTypeHeader = "application/json" + serializedBody, err := json.Marshal(body) + + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error serializing body: %v", err) + } + + bodyString = string(serializedBody) + } } } diff --git a/packages/sdk-codegen/src/codeGen.ts b/packages/sdk-codegen/src/codeGen.ts index 2353cae53..92f796093 100644 --- a/packages/sdk-codegen/src/codeGen.ts +++ b/packages/sdk-codegen/src/codeGen.ts @@ -1317,12 +1317,13 @@ export abstract class CodeGen implements ICodeGen { assignFormArgs( method: IMethod, body: string, - query: string - ): { body: string; query: string } { + query: string, + options: string + ): { body: string; query: string; options: string } { if (method.isFormUrlEncoded) { - return { body: query, query: this.nullStr }; + return { body: query, query: this.nullStr, options }; } - return { body, query }; + return { body, query, options }; } // build the http argument list from back to front, so trailing undefined arguments @@ -1338,9 +1339,13 @@ export abstract class CodeGen implements ICodeGen { result = this.argFill(result, this.argGroup(indent, method.headerArgs)); let body = method.bodyArg ? method.bodyArg : this.nullStr; let query = this.argGroup(indent, method.queryArgs); - const formArgs = this.assignFormArgs(method, body, query); + // Add 'options' since it might be modified for form encoding + let options = this.nullStr; + const formArgs = this.assignFormArgs(method, body, query, options); body = formArgs.body; query = formArgs.query; + options = formArgs.options; + result = this.argFill(result, options); result = this.argFill(result, body); result = this.argFill(result, query); return result; diff --git a/packages/sdk-codegen/src/csharp.gen.ts b/packages/sdk-codegen/src/csharp.gen.ts index 8d96b2593..35d197642 100644 --- a/packages/sdk-codegen/src/csharp.gen.ts +++ b/packages/sdk-codegen/src/csharp.gen.ts @@ -341,15 +341,30 @@ namespace Looker.SDK.API${this.apiRef} : this.nullStr; } + assignFormArgs( + method: IMethod, + body: string, + query: string, + options: string + ): { body: string; query: string; options: string } { + if (method.isFormUrlEncoded) { + // CSharp uses "new FormValues { ... }" to trigger RTL-level URL encoding. + query = query.replace(/new Values \{/g, 'new FormValues {'); + return { body: query, query: this.nullStr, options }; + } + return { body, query, options }; + } + httpArgs(indent: string, method: IMethod) { - let result = this.argFill('', 'options'); - // result = this.argFill(result, this.argGroup(indent, method.cookieArgs)) - // result = this.argFill(result, this.argGroup(indent, method.headerArgs)) + let options = 'options'; let body = method.bodyArg ? method.bodyArg : this.nullStr; let query = this.argGroup(indent, method.queryArgs); - const formArgs = this.assignFormArgs(method, body, query); + const formArgs = this.assignFormArgs(method, body, query, options); body = formArgs.body; query = formArgs.query; + options = formArgs.options; + + let result = this.argFill('', options); result = this.argFill(result, body); result = this.argFill(result, query); return result; diff --git a/packages/sdk-codegen/src/go.gen.ts b/packages/sdk-codegen/src/go.gen.ts index 05fdc4397..492e644b7 100644 --- a/packages/sdk-codegen/src/go.gen.ts +++ b/packages/sdk-codegen/src/go.gen.ts @@ -284,21 +284,35 @@ export class GoGen extends CodeGen { // add options at the end of the request calls. this will cause all other arguments to be // filled in but there's no way to avoid this for passing in the last optional parameter. // Fortunately, this code bloat is minimal and also hidden from the consumer. - let result = this.argFill('', 'options'); - // let result = this.argFill('', this.argGroup(indent, method.cookieArgs, request)) - // result = this.argFill(result, this.argGroup(indent, method.headerArgs, request)) + let options = 'options'; let body = method.bodyArg ? this.accessor(method.bodyArg, request) : this.nullStr; let query = this.argGroup(indent, method.queryArgs, request); - const formArgs = this.assignFormArgs(method, body, query); + const formArgs = this.assignFormArgs(method, body, query, options); body = formArgs.body; query = formArgs.query; + options = formArgs.options; + + let result = this.argFill('', options); result = this.argFill(result, body); result = this.argFill(result, query); return result; } + assignFormArgs( + method: IMethod, + body: string, + query: string, + options: string + ): { body: string; query: string; options: string } { + if (method.isFormUrlEncoded) { + body = query.replace('map[string]interface{}', 'rtl.Values'); + query = this.nullStr; + } + return { body, query, options }; + } + argGroup(_indent: string, args: Arg[], prefix?: string) { if (!args || args.length === 0) return this.nullStr; const hash: string[] = []; diff --git a/packages/sdk-codegen/src/kotlin.gen.ts b/packages/sdk-codegen/src/kotlin.gen.ts index ead3bdfa0..2a6139b71 100644 --- a/packages/sdk-codegen/src/kotlin.gen.ts +++ b/packages/sdk-codegen/src/kotlin.gen.ts @@ -458,12 +458,16 @@ ${props.join(this.propDelimiter)} assignFormArgs( method: IMethod, body: string, - query: string - ): { body: string; query: string } { - const result = super.assignFormArgs(method, body, query); + query: string, + options: string + ): { body: string; query: string; options: string } { + const result = super.assignFormArgs(method, body, query, options); if (result.query === this.nullStr) { result.query = 'mapOf()'; } + if (method.isFormUrlEncoded) { + result.body = `encodeValues(${result.body})`; + } return result; } @@ -483,11 +487,13 @@ ${props.join(this.propDelimiter)} // let result = this.argFill('', 'options') // let result = this.argFill('', this.argGroup(indent, method.cookieArgs, request)) // result = this.argFill(result, this.argGroup(indent, method.headerArgs, request)) + let options = 'options'; let body = method.bodyArg ? `${request}${method.bodyArg}` : this.nullStr; let query = this.argGroup(indent, method.queryArgs, request); - const formArgs = this.assignFormArgs(method, body, query); + const formArgs = this.assignFormArgs(method, body, query, options); body = formArgs.body; query = formArgs.query; + options = formArgs.options; // Kotlin generator ignores options down the line currently let result = this.argFill('', body); result = this.argFill(result, query); return result; diff --git a/packages/sdk-codegen/src/python.gen.ts b/packages/sdk-codegen/src/python.gen.ts index 0813fb135..97e0883eb 100644 --- a/packages/sdk-codegen/src/python.gen.ts +++ b/packages/sdk-codegen/src/python.gen.ts @@ -105,6 +105,7 @@ import warnings from . import models as mdls from ${this.packagePath}.rtl import api_methods +from ${this.packagePath}.rtl import model from ${this.packagePath}.rtl import transport class ${this.packageName}(api_methods.APIMethods): @@ -371,16 +372,22 @@ ${this.hooks.join('\n')} httpArgs(callerIndent: string, method: IMethod): string { const currIndent = this.bumper(callerIndent); - let args = ''; - args = this.argFill( - args, - `${currIndent}transport_options=transport_options` - ); const body = method.bodyArg ? method.bodyArg : this.nullStr; const query = method.queryArgs.length ? this.argGroup('', method.queryArgs) : this.nullStr; - const formArgs = this.assignFormArgs(method, body, query); + const formArgs = this.assignFormArgs( + method, + body, + query, + 'transport_options' + ); + + let args = ''; + args = this.argFill( + args, + `${currIndent}transport_options=${formArgs.options}` + ); if (formArgs.body !== this.nullStr) { args = this.argFill(args, `${currIndent}body=${formArgs.body}`); @@ -438,6 +445,19 @@ ${this.hooks.join('\n')} return encodings; } + assignFormArgs( + method: IMethod, + body: string, + query: string, + options: string + ): { body: string; query: string; options: string } { + if (method.isFormUrlEncoded) { + body = `model.URLSearchParams(${query})`; + query = this.nullStr; + } + return { body, query, options }; + } + declareMethod(indent: string, method: IMethod) { const bump = this.bumper(indent); diff --git a/packages/sdk-codegen/src/swift.gen.ts b/packages/sdk-codegen/src/swift.gen.ts index b63c88b93..419cd4e4e 100644 --- a/packages/sdk-codegen/src/swift.gen.ts +++ b/packages/sdk-codegen/src/swift.gen.ts @@ -724,16 +724,22 @@ ${indent}}\n`; // add options at the end of the request calls. this will cause all other arguments to be // filled in but there's no way to avoid this for passing in the last optional parameter. // Fortunately, this code bloat is minimal and also hidden from the consumer. - let result = this.argFill('', 'options'); - // let result = this.argFill('', this.argGroup(indent, method.cookieArgs, request)) - // result = this.argFill(result, this.argGroup(indent, method.headerArgs, request)) + let options = 'options'; let body = method.bodyArg ? `try! self.encode(${request}${method.bodyArg})` : this.nullStr; let query = this.queryGroup(indent, method, request); - const formArgs = this.assignFormArgs(method, body, query); - body = formArgs.body; - query = formArgs.query; + if (method.isFormUrlEncoded) { + body = `FormValues(${query})`; + query = this.nullStr; + } else { + const formArgs = this.assignFormArgs(method, body, query, options); + body = formArgs.body; + query = formArgs.query; + options = formArgs.options; + } + + let result = this.argFill('', options); result = this.argFill(result, body); result = this.argFill(result, query); return result; diff --git a/packages/sdk-codegen/src/typescript.gen.ts b/packages/sdk-codegen/src/typescript.gen.ts index b47698f53..30f275a55 100644 --- a/packages/sdk-codegen/src/typescript.gen.ts +++ b/packages/sdk-codegen/src/typescript.gen.ts @@ -792,6 +792,19 @@ ${indent}})`; return `${args}${current ? this.argDelimiter : ''}${current}`; } + assignFormArgs( + method: IMethod, + body: string, + query: string, + options: string + ): { body: string; query: string; options: string } { + if (method.isFormUrlEncoded) { + body = `new URLSearchParams(${query} as unknown as Record)`; + query = this.nullStr; + } + return { body, query, options }; + } + // build the http argument list from back to front, so trailing undefined arguments // can be omitted. Path arguments are resolved as part of the path parameter to general // purpose API method call @@ -805,16 +818,17 @@ ${indent}})`; // add options at the end of the request calls. this will cause all other arguments to be // filled in but there's no way to avoid this for passing in the last optional parameter. // Fortunately, this code bloat is minimal and also hidden from the consumer. - let result = this.argFill('', 'options'); - // let result = this.argFill('', this.argGroup(indent, method.cookieArgs, request)) - // result = this.argFill(result, this.argGroup(indent, method.headerArgs, request)) + let options = 'options'; let body = method.bodyArg ? this.accessor(method.bodyArg, request) : this.nullStr; let query = this.argGroup(indent, method.queryArgs, request); - const formArgs = this.assignFormArgs(method, body, query); + const formArgs = this.assignFormArgs(method, body, query, options); body = formArgs.body; query = formArgs.query; + options = formArgs.options; + + let result = this.argFill('', options); result = this.argFill(result, body); result = this.argFill(result, query); return result; diff --git a/packages/sdk-rtl/src/baseTransport.ts b/packages/sdk-rtl/src/baseTransport.ts index 250ffb992..ca87609a2 100644 --- a/packages/sdk-rtl/src/baseTransport.ts +++ b/packages/sdk-rtl/src/baseTransport.ts @@ -165,8 +165,39 @@ export abstract class BaseTransport implements ITransport { body = undefined; } else { if (typeof body !== 'string') { - body = JSON.stringify(body); - headers['Content-Type'] = 'application/json'; + let isUrlEncoded = body instanceof URLSearchParams; + if (!isUrlEncoded) { + isUrlEncoded = Object.keys(headers).some( + k => + k.toLowerCase() === 'content-type' && + headers[k] + ?.toString() + .includes('application/x-www-form-urlencoded') + ); + } + + if (isUrlEncoded) { + if (body instanceof URLSearchParams) { + body = body.toString(); + } else { + const params = new URLSearchParams(); + Object.entries(body as any).forEach(([k, v]) => { + if (v !== undefined && v !== null) { + params.append( + k, + Array.isArray(v) ? JSON.stringify(v) : v.toString() + ); + } + }); + body = params.toString(); + } + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + } else { + body = JSON.stringify(body); + headers['Content-Type'] = 'application/json'; + } } } diff --git a/python/looker_sdk/rtl/api_methods.py b/python/looker_sdk/rtl/api_methods.py index e64f679b0..7560388fe 100644 --- a/python/looker_sdk/rtl/api_methods.py +++ b/python/looker_sdk/rtl/api_methods.py @@ -23,9 +23,10 @@ """Functionality for making authenticated API calls """ import datetime -import json -from typing import Any, MutableMapping, Optional, Sequence, Type, Union +import re import urllib.parse +import json +from typing import Any, MutableMapping, Optional, Sequence, Tuple, Type, Union from looker_sdk import error from looker_sdk.rtl import model @@ -156,10 +157,14 @@ def get( ) return self._return(response, structure) - def _get_serialized(self, body: TBody) -> Optional[bytes]: + def _get_serialized( + self, body: TBody, transport_options: Optional[transport.TransportOptions] = None + ) -> Optional[bytes]: serialized: Optional[bytes] if isinstance(body, str): serialized = body.encode("utf-8") + elif isinstance(body, model.URLSearchParams): + serialized = urllib.parse.urlencode(body).encode("utf-8") elif isinstance(body, (list, dict, model.Model)): serialized = self.serialize(api_model=body) # type: ignore else: @@ -176,7 +181,13 @@ def post( ) -> TReturn: """POST method""" params = self._convert_query_params(query_params) if query_params else None - serialized = self._get_serialized(body) + if isinstance(body, model.URLSearchParams): + if transport_options is None: + transport_options = {} + if "headers" not in transport_options: + transport_options["headers"] = {} + transport_options["headers"]["Content-Type"] = "application/x-www-form-urlencoded" + serialized = self._get_serialized(body, transport_options) response = self.transport.request( transport.HttpMethod.POST, self._path(path), @@ -197,7 +208,13 @@ def patch( ) -> TReturn: """PATCH method""" params = self._convert_query_params(query_params) if query_params else None - serialized = self._get_serialized(body) + if isinstance(body, model.URLSearchParams): + if transport_options is None: + transport_options = {} + if "headers" not in transport_options: + transport_options["headers"] = {} + transport_options["headers"]["Content-Type"] = "application/x-www-form-urlencoded" + serialized = self._get_serialized(body, transport_options) response = self.transport.request( transport.HttpMethod.PATCH, self._path(path), @@ -218,7 +235,13 @@ def put( ) -> TReturn: """PUT method""" params = self._convert_query_params(query_params) if query_params else None - serialized = self._get_serialized(body) + if isinstance(body, model.URLSearchParams): + if transport_options is None: + transport_options = {} + if "headers" not in transport_options: + transport_options["headers"] = {} + transport_options["headers"]["Content-Type"] = "application/x-www-form-urlencoded" + serialized = self._get_serialized(body, transport_options) response = self.transport.request( transport.HttpMethod.PUT, self._path(path), diff --git a/python/looker_sdk/rtl/model.py b/python/looker_sdk/rtl/model.py index c2de8531a..45916d206 100644 --- a/python/looker_sdk/rtl/model.py +++ b/python/looker_sdk/rtl/model.py @@ -228,3 +228,7 @@ def __str__(self): f"{self.separator.join(str(d) for d in self.data)}" f"{self.suffix}" ) + +class URLSearchParams(dict): + pass + diff --git a/swift/looker/rtl/baseTransport.swift b/swift/looker/rtl/baseTransport.swift index 26b57c06c..18bf71658 100644 --- a/swift/looker/rtl/baseTransport.swift +++ b/swift/looker/rtl/baseTransport.swift @@ -156,6 +156,9 @@ open class BaseTransport : ITransport { if (body is String) { req.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") req.httpBody = (body as! String).data(using: .utf8) + } else if (body is FormValues) { + req.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + req.httpBody = encodeParams((body as! FormValues).values).data(using: .utf8) } else { /// Only string and encoded JSON (iow`Data`) can be provided for the body req.httpBody = body as? Data diff --git a/swift/looker/rtl/constants.swift b/swift/looker/rtl/constants.swift index 5f940af37..1b9a61f54 100644 --- a/swift/looker/rtl/constants.swift +++ b/swift/looker/rtl/constants.swift @@ -88,6 +88,13 @@ public typealias ValueDictionary = Dictionary public typealias Values = [String: Any?] +public struct FormValues { + public var values: Values + public init(_ values: Values) { + self.values = values + } +} + /// Extension for converting a `String` to `Bool` public extension String { var bool: Bool? { From 7ddacd600bed347ce2229c3d78e7fe797c8be1b6 Mon Sep 17 00:00:00 2001 From: Jules Cisek Date: Wed, 25 Mar 2026 05:52:08 +0000 Subject: [PATCH 2/4] fix(base): strictly serialize form-encoded nested objects and types * explicitly JSON.stringify nested properties and arrays during form-encoding * directly parse dates down to .toISOString() and .isoformat() to keep uniform timezone and formatting integrity over the wire * added jest assertion in browserTransport tests --- csharp/rtl/Transport.cs | 7 +++- kotlin/src/main/com/looker/rtl/Transport.kt | 4 +- packages/sdk-rtl/src/baseTransport.ts | 6 ++- packages/sdk-rtl/src/browserTransport.spec.ts | 40 +++++++++++++++++++ python/looker_sdk/rtl/api_methods.py | 10 ++++- swift/looker/rtl/transport.swift | 8 +++- 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/csharp/rtl/Transport.cs b/csharp/rtl/Transport.cs index 2bc76d3f8..35b05ee1a 100644 --- a/csharp/rtl/Transport.cs +++ b/csharp/rtl/Transport.cs @@ -238,7 +238,12 @@ public async Task RawRequest( { var dict = ((FormValues)body) .Where(k => k.Value != null) - .ToDictionary(k => k.Key, k => k.Value.ToString()); + .ToDictionary(k => k.Key, k => + { + if (k.Value is DateTime dt) return dt.ToString("o"); + if (k.Value is string || k.Value.GetType().IsPrimitive) return k.Value.ToString(); + return JsonSerializer.Serialize(k.Value, new JsonSerializerOptions { IgnoreNullValues = true }); + }); request.Content = new FormUrlEncodedContent(dict); } else diff --git a/kotlin/src/main/com/looker/rtl/Transport.kt b/kotlin/src/main/com/looker/rtl/Transport.kt index b068272b5..4230414a9 100644 --- a/kotlin/src/main/com/looker/rtl/Transport.kt +++ b/kotlin/src/main/com/looker/rtl/Transport.kt @@ -217,8 +217,10 @@ fun encodeParam(value: Any?): String { value.toOffsetDateTime().format(utcFormat) } else if (value is Date) { value.toInstant().atZone(ZoneOffset.UTC).format(utcFormat) - } else { + } else if (value is String || value is Number || value is Boolean) { "$value" + } else { + GSON.toJson(value) } try { val decoded = URLDecoder.decode(encoded, utf8) diff --git a/packages/sdk-rtl/src/baseTransport.ts b/packages/sdk-rtl/src/baseTransport.ts index ca87609a2..78a893a1f 100644 --- a/packages/sdk-rtl/src/baseTransport.ts +++ b/packages/sdk-rtl/src/baseTransport.ts @@ -185,7 +185,11 @@ export abstract class BaseTransport implements ITransport { if (v !== undefined && v !== null) { params.append( k, - Array.isArray(v) ? JSON.stringify(v) : v.toString() + v instanceof Date + ? v.toISOString() + : typeof v === 'object' + ? JSON.stringify(v) + : v.toString() ); } }); diff --git a/packages/sdk-rtl/src/browserTransport.spec.ts b/packages/sdk-rtl/src/browserTransport.spec.ts index f7876ab7a..0d533d693 100644 --- a/packages/sdk-rtl/src/browserTransport.spec.ts +++ b/packages/sdk-rtl/src/browserTransport.spec.ts @@ -296,4 +296,44 @@ describe('BrowserTransport', () => { expect((typed as any).string4).toBe('4'); expect((typed as any).num4).toBe(4); }); + + it('serializes form-urlencoded nested objects and dates cleanly', async () => { + class TestTransport extends BrowserTransport { + public async testInitRequest( + method: any, + path: string, + body?: any, + authenticator?: any, + options?: any + ) { + return this.initRequest(method, path, body, authenticator, options); + } + } + const xp = new TestTransport({} as any); + const date = new Date('2026-01-01T00:00:00.000Z'); + const body = { + simple: 'string', + date: date, + nested: { foo: 'bar' }, + array: [1, 2, 3], + }; + + const req = await xp.testInitRequest( + 'POST', + 'http://dummy.com', + body, + undefined, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + expect(req.body).toEqual( + `simple=string&date=${encodeURIComponent( + '2026-01-01T00:00:00.000Z' + )}&nested=${encodeURIComponent( + '{"foo":"bar"}' + )}&array=${encodeURIComponent('[1,2,3]')}` + ); + }); }); diff --git a/python/looker_sdk/rtl/api_methods.py b/python/looker_sdk/rtl/api_methods.py index 7560388fe..f07a16e9f 100644 --- a/python/looker_sdk/rtl/api_methods.py +++ b/python/looker_sdk/rtl/api_methods.py @@ -164,7 +164,15 @@ def _get_serialized( if isinstance(body, str): serialized = body.encode("utf-8") elif isinstance(body, model.URLSearchParams): - serialized = urllib.parse.urlencode(body).encode("utf-8") + processed = {} + for k, v in body.items(): + if isinstance(v, (dict, list, model.Model)): + processed[k] = self.serialize(api_model=v).decode("utf-8") + elif isinstance(v, (datetime.datetime, datetime.date)): + processed[k] = v.isoformat() + else: + processed[k] = str(v) + serialized = urllib.parse.urlencode(processed).encode("utf-8") elif isinstance(body, (list, dict, model.Model)): serialized = self.serialize(api_model=body) # type: ignore else: diff --git a/swift/looker/rtl/transport.swift b/swift/looker/rtl/transport.swift index 7cba9fdbc..19650f404 100644 --- a/swift/looker/rtl/transport.swift +++ b/swift/looker/rtl/transport.swift @@ -332,7 +332,13 @@ public func encodeParam(_ value: Any?) -> String { encoded = DateFormatter.iso8601Full.string(from: x) break default: - encoded = "\(val)" + if JSONSerialization.isValidJSONObject(val), + let json = try? JSONSerialization.data(withJSONObject: val, options: []), + let jsonString = String(data: json, encoding: .utf8) { + encoded = jsonString + } else { + encoded = "\(val)" + } } // if val is Array { // let a = val as! Array From 6af303c903ede680951d6491f2e4aef8d35ae53c Mon Sep 17 00:00:00 2001 From: Jules Cisek Date: Wed, 25 Mar 2026 05:55:58 +0000 Subject: [PATCH 3/4] test(python): update gen method prologue expecting imported model --- packages/sdk-codegen/src/python.gen.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk-codegen/src/python.gen.spec.ts b/packages/sdk-codegen/src/python.gen.spec.ts index 06405ef9e..072a81394 100644 --- a/packages/sdk-codegen/src/python.gen.spec.ts +++ b/packages/sdk-codegen/src/python.gen.spec.ts @@ -81,6 +81,7 @@ import warnings from . import models as mdls from looker_sdk.rtl import api_methods +from looker_sdk.rtl import model from looker_sdk.rtl import transport class LookerSDK(api_methods.APIMethods): From 9c0e7626d506afbf1d5e6a45b261dfdfec418310 Mon Sep 17 00:00:00 2001 From: Jules Cisek Date: Wed, 25 Mar 2026 06:04:30 +0000 Subject: [PATCH 4/4] fix(python): suppress mypy kwargs error for serialize() invocation --- python/looker_sdk/rtl/api_methods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/looker_sdk/rtl/api_methods.py b/python/looker_sdk/rtl/api_methods.py index f07a16e9f..2b82b7100 100644 --- a/python/looker_sdk/rtl/api_methods.py +++ b/python/looker_sdk/rtl/api_methods.py @@ -167,7 +167,7 @@ def _get_serialized( processed = {} for k, v in body.items(): if isinstance(v, (dict, list, model.Model)): - processed[k] = self.serialize(api_model=v).decode("utf-8") + processed[k] = self.serialize(api_model=v).decode("utf-8") # type: ignore elif isinstance(v, (datetime.datetime, datetime.date)): processed[k] = v.isoformat() else: