From e947c66ae2ab3f5d1ad0c0f0558343ecb090b6dc Mon Sep 17 00:00:00 2001 From: appscisumup Date: Thu, 24 Sep 2026 23:19:13 +0000 Subject: [PATCH 1/3] chore: synced local 'openapi.json' with remote 'specs/openapi31.json' --- openapi.json | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/openapi.json b/openapi.json index 0c914234..8f2f6618 100644 --- a/openapi.json +++ b/openapi.json @@ -3454,27 +3454,19 @@ { "name": "resource.parent.id", "in": "query", - "description": "Filter memberships by the parent of the resource the membership is in.\nWhen filtering by parent both `resource.parent.id` and `resource.parent.type` must be present. Pass explicit null to filter for resources without a parent.", + "allowEmptyValue": true, + "description": "Filter memberships by the parent of the resource the membership is in.\nOmit both `resource.parent.id` and `resource.parent.type` to skip parent filtering. When filtering by parent, both parameters must be present. To select resources without a parent, set each parameter to an empty value. Otherwise, both parameters must identify a parent.", "schema": { - "type": [ - "string", - "null" - ] + "type": "string" } }, { "name": "resource.parent.type", "in": "query", - "description": "Filter memberships by the parent of the resource the membership is in.\nWhen filtering by parent both `resource.parent.id` and `resource.parent.type` must be present. Pass explicit null to filter for resources without a parent.", + "allowEmptyValue": true, + "description": "Filter memberships by the parent of the resource the membership is in.\nOmit both `resource.parent.id` and `resource.parent.type` to skip parent filtering. When filtering by parent, both parameters must be present. To select resources without a parent, set each parameter to an empty value. Otherwise, both parameters must identify a parent.", "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ResourceType" - }, - { - "type": "null" - } - ] + "$ref": "#/components/schemas/ResourceType" } }, { From bb067df6a9337688041ea47d30363430129d128c Mon Sep 17 00:00:00 2001 From: "sumup-release-bot[bot]" <241716704+sumup-release-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:19:38 +0000 Subject: [PATCH 2/3] chore: generate code --- sdk/src/resources/memberships/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/resources/memberships/index.ts b/sdk/src/resources/memberships/index.ts index 1b4468c9..aee89247 100644 --- a/sdk/src/resources/memberships/index.ts +++ b/sdk/src/resources/memberships/index.ts @@ -15,8 +15,8 @@ export type ListMembershipsQueryParams = { "resource.attributes.sandbox"?: boolean; "resource.name"?: string; "resource.id"?: string; - "resource.parent.id"?: string | null; - "resource.parent.type"?: ResourceType | null; + "resource.parent.id"?: string; + "resource.parent.type"?: ResourceType; roles?: string[]; }; From 1a9a58cd9b200016dae8caa124de7e8be75cc898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Fri, 25 Sep 2026 10:42:35 +0200 Subject: [PATCH 3/3] chore: cleanup param handling --- codegen/src/resource.test.ts | 66 ++++++++++++++++++++++++++ codegen/src/resource.ts | 8 +++- sdk/src/client.ts | 5 +- sdk/src/resources/memberships/index.ts | 4 +- sdk/tests/client.test.ts | 48 ++++++++++++++++++- 5 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 codegen/src/resource.test.ts diff --git a/codegen/src/resource.test.ts b/codegen/src/resource.test.ts new file mode 100644 index 00000000..1e70148f --- /dev/null +++ b/codegen/src/resource.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import type { Document } from "./openapi"; +import { generateResource } from "./resource"; + +test("allowEmptyValue adds an empty string independently of the query schema", async () => { + const dir = await mkdtemp(join(tmpdir(), "sumup-resource-")); + const spec: Document = { + openapi: "3.1.0", + info: { title: "Test", version: "1" }, + components: { + schemas: { Kind: { type: "string", enum: ["merchant"] } }, + }, + paths: { + "/resources": { + get: { + operationId: "listResources", + tags: ["Resources"], + parameters: [ + { + in: "query", + name: "kind", + allowEmptyValue: true, + schema: { $ref: "#/components/schemas/Kind" }, + }, + { + in: "query", + name: "count", + required: true, + allowEmptyValue: true, + schema: { type: "integer" }, + }, + { + in: "query", + name: "strict", + allowEmptyValue: false, + schema: { type: "string", enum: ["merchant"] }, + }, + { + in: "query", + name: "default", + schema: { $ref: "#/components/schemas/Kind" }, + }, + ], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }; + + try { + await generateResource({ name: "Resources" }, spec, dir); + const generated = await readFile( + join(dir, "resources/resources/index.ts"), + "utf8", + ); + expect(generated).toContain('kind?: Kind | "";'); + expect(generated).toContain('count: number | "";'); + expect(generated).toContain('strict?: "merchant";'); + expect(generated).toContain("default?: Kind;"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/codegen/src/resource.ts b/codegen/src/resource.ts index b9c9d055..e0a6f841 100644 --- a/codegen/src/resource.ts +++ b/codegen/src/resource.ts @@ -210,7 +210,13 @@ export async function generateResource( writer.w(` '${queryParameterName(param)}'`); if (!param.required) writer.w0("?"); writer.w0(": "); - schemaToTypes(param.schema, writer); + // Empty query values are allowed independently of the value schema. + schemaToTypes( + param.allowEmptyValue + ? { anyOf: [param.schema, { const: "" }] } + : param.schema, + writer, + ); writer.w(","); } writer.w("}\n"); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index dec54285..912120cb 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -228,16 +228,13 @@ export class HTTPClient { ) { return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; } - if (value === null) { - return `${encodeURIComponent(key)}=`; - } if (Array.isArray(value)) { return value .map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`) .join("&"); } throw new Error( - `Cannot stringify type ${typeof value}; Expected string, number, boolean, or null.`, + `Cannot stringify type ${typeof value}; Expected string, number, boolean, or array.`, ); }) .join("&"); diff --git a/sdk/src/resources/memberships/index.ts b/sdk/src/resources/memberships/index.ts index aee89247..9354c41f 100644 --- a/sdk/src/resources/memberships/index.ts +++ b/sdk/src/resources/memberships/index.ts @@ -15,8 +15,8 @@ export type ListMembershipsQueryParams = { "resource.attributes.sandbox"?: boolean; "resource.name"?: string; "resource.id"?: string; - "resource.parent.id"?: string; - "resource.parent.type"?: ResourceType; + "resource.parent.id"?: string | ""; + "resource.parent.type"?: ResourceType | ""; roles?: string[]; }; diff --git a/sdk/tests/client.test.ts b/sdk/tests/client.test.ts index 41b6ee8b..07ba06ae 100644 --- a/sdk/tests/client.test.ts +++ b/sdk/tests/client.test.ts @@ -39,10 +39,18 @@ describe("query string", () => { stringifyQuery({ "a": "b", "foo": false, - "x": null, + "x": "", + "omitted": undefined, + "zero": 0, "include": ["1", "2"], }), - ).toEqual("a=b&foo=false&x=&include=1&include=2"); + ).toEqual("a=b&foo=false&x=&zero=0&include=1&include=2"); + }); + + it("rejects null instead of treating it as an empty value", () => { + expect(() => stringifyQuery({ x: null })).toThrow( + "Cannot stringify type object; Expected string, number, boolean, or array.", + ); }); }); @@ -163,6 +171,42 @@ describe("request options", () => { }); describe("generated signatures", () => { + it.each(["list", "listWithResponse"] as const)( + "%s preserves empty membership parent filters and omits undefined filters", + async (method) => { + const fetchMock = rs.fn().mockImplementation((_url: URL | RequestInfo) => + Promise.resolve( + new Response(JSON.stringify({ items: [], total_count: 0 }), { + headers: { "content-type": "application/json" }, + }), + ), + ); + rs.stubGlobal("fetch", fetchMock); + const client = new SumUp(); + + await client.memberships[method]({ + "resource.parent.id": "", + "resource.parent.type": "", + }); + await client.memberships[method]({ + "resource.parent.id": undefined, + "resource.parent.type": undefined, + }); + await client.memberships[method]({ + "resource.parent.id": "parent & id", + "resource.parent.type": "merchant", + }); + + const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url))); + expect(urls[0]?.pathname).toBe("/v0.1/memberships"); + expect(urls.map((url) => url.search)).toEqual([ + "?resource.parent.id=&resource.parent.type=", + "", + "?resource.parent.id=parent%20%26%20id&resource.parent.type=merchant", + ]); + }, + ); + it("maps ergonomic repeated query parameter names to their wire names", () => { const client = new SumUp(); const getSpy = rs