Skip to content
Merged
Show file tree
Hide file tree
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
66 changes: 66 additions & 0 deletions codegen/src/resource.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
8 changes: 7 additions & 1 deletion codegen/src/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 6 additions & 14 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
{
Expand Down
5 changes: 1 addition & 4 deletions sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("&");
Expand Down
4 changes: 2 additions & 2 deletions sdk/src/resources/memberships/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};

Expand Down
48 changes: 46 additions & 2 deletions sdk/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
});
});

Expand Down Expand Up @@ -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
Expand Down
Loading