diff --git a/codegen/src/api.ts b/codegen/src/api.ts index aa63db35..9302ccec 100644 --- a/codegen/src/api.ts +++ b/codegen/src/api.ts @@ -27,7 +27,7 @@ export async function generateIndex( export type { APIConfig } from './client' export { APIError, SumUpError } from './core' - export type { FetchParams } from './core' + export type { RequestOptions } from './core' export * from './types' `); diff --git a/codegen/src/core.ts b/codegen/src/core.ts index 6a788f0c..ba31029a 100644 --- a/codegen/src/core.ts +++ b/codegen/src/core.ts @@ -33,20 +33,31 @@ export class APIResource { // biome-ignore lint/suspicious/noExplicitAny: any, but only for tests type QueryParams = Record; +type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** - * Params that get passed to \`fetch\`. This ends up as an optional second - * argument to each generated request method. Properties are a subset of - * \`RequestInit\`. + * SDK-specific options that can be passed to any generated request method. */ -export type FetchParams = Omit; +export type RequestOptions = { + /** + * Optional bearer authorization value to apply to the request, for example + * \`Bearer \`. When provided, it overrides any default + * Authorization header configured on the client. + */ + authorization?: string; + headers?: HeadersInit; + host?: string; + maxRetries?: number; + signal?: AbortSignal | null; + timeout?: number; +}; /** All arguments to \`request()\` */ -export type FullParams = FetchParams & { +export type FullRequestOptions = RequestOptions & { path: string; query?: QueryParams; body?: unknown; - host?: string; - method?: string; + method?: HTTPMethod; }; export class SumUpError extends Error {} diff --git a/codegen/src/resource.ts b/codegen/src/resource.ts index 1f6c67cf..1e5e0608 100644 --- a/codegen/src/resource.ts +++ b/codegen/src/resource.ts @@ -9,7 +9,6 @@ import { iterPathConfig, responseSchema, } from "./base"; -import type { FileWriter } from "./io"; import { fileWriter } from "./io"; import { schemaNameToTypeName, schemaToTypes } from "./schema"; import { @@ -66,7 +65,7 @@ export async function generateResource( const collectorWriter = { w() {}, w0() {}, - } as unknown as FileWriter; + }; const resolveResponseObject = ( response: OpenAPIV3_1.ResponseObject | OpenAPIV3_1.ReferenceObject, @@ -180,11 +179,10 @@ export async function generateResource( ); } - writer.w(` -// Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. - -import { APIResource, type APIPromise, type FetchParams } from "../../core"; -`); + writer.w("// Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT.\n"); + writer.w( + 'import { APIResource, type APIPromise, type RequestOptions } from "../../core";', + ); const sortedSharedTypes = [...usedSharedTypes].sort((a, b) => a > b ? 1 : -1, @@ -316,6 +314,7 @@ export class ${resourceClassName} extends APIResource {`); writer.w(comment); } + const body = getRequestBody(opId, methodSpec); writer.w0(`${methodName}(`); if (pathParams.length > 0) { @@ -327,7 +326,6 @@ export class ${resourceClassName} extends APIResource {`); } } - const body = getRequestBody(opId, methodSpec); if (body) { writer.w0(`body${body.required ? "" : "?"}: ${body.typeName}, `); } @@ -338,7 +336,7 @@ export class ${resourceClassName} extends APIResource {`); writer.w0(`: ${queryParamsType(methodNameType)}, `); } - writer.w(`params?: FetchParams): APIPromise<${successType}, ${errorTypeName}> { + writer.w(`options?: RequestOptions): APIPromise<${successType}, ${errorTypeName}> { return this._client.${method}<${successType}, ${errorTypeName}>({ path: ${pathToTemplateStr(path)},`); if (methodSpec.requestBody) { @@ -347,7 +345,7 @@ export class ${resourceClassName} extends APIResource {`); if (queryParams.length > 0) { writer.w(" query,"); } - writer.w(` ...params, + writer.w(` ...options, }) }\n`); } diff --git a/examples/checkout/index.ts b/examples/checkout/index.ts index 3b74bae4..fdaa8e1c 100644 --- a/examples/checkout/index.ts +++ b/examples/checkout/index.ts @@ -5,12 +5,6 @@ const client = new SumUp({ }); async function main() { - const merchant = await client.merchant.get(); - console.info({ merchant }); - - const merchant2 = await client.merchant.get().withResponse(); - console.info({ merchant2 }); - const merchantCode = process.env.SUMUP_MERCHANT_CODE; if (!merchantCode) { console.warn( @@ -19,6 +13,12 @@ async function main() { return; } + const merchant = await client.merchants.get(merchantCode); + console.info({ merchant }); + + const merchant2 = await client.merchants.get(merchantCode).withResponse(); + console.info({ merchant2 }); + const request: CheckoutCreateRequest = { amount: 19, checkout_reference: "CO746453", diff --git a/sdk/README.md b/sdk/README.md index 4fa2ad00..3db44b4a 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -46,8 +46,8 @@ const sumup = require('@sumup/sdk')({ apiKey: 'sup_sk_MvxmLOl0...' }); -sumup.merchant.get() - .then(merchant => console.info(merchant)) +sumup.checkouts.list() + .then(checkouts => console.info(checkouts)) .catch(error => console.error(error)); ``` @@ -60,10 +60,29 @@ const client = new SumUp({ apiKey: 'sup_sk_MvxmLOl0...', }); -const merchant = await client.merchant.get(); +const merchantCode = process.env.SUMUP_MERCHANT_CODE!; +const merchant = await client.merchants.get(merchantCode); console.info(merchant); ``` +Per-request options are available as the last argument to any SDK call. For +example, you can override authorization, timeout, retries, or headers for a +single request: + +```ts +await client.checkouts.list(undefined, { + timeout: 5_000, +}); + +await client.merchants.get(merchantCode, { + authorization: `Bearer ${accessToken}`, + headers: { + "x-request-id": "req_123", + }, + maxRetries: 1, +}); +``` + ## Examples Examples require an API key and your merchant code. You can run the examples using: diff --git a/sdk/src/client.ts b/sdk/src/client.ts index f6b7723e..90df6e96 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -6,19 +6,23 @@ import { VERSION } from "./version"; export type APIConfig = { apiKey?: string; - baseParams?: Core.FetchParams; host?: string; + baseParams?: Core.RequestOptions; + maxRetries?: number; + timeout?: number; }; export class HTTPClient { host: string; apiKey?: string; - baseParams: Core.FetchParams; + baseParams: Core.RequestOptions; constructor({ apiKey, host = "https://api.sumup.com", baseParams = {}, + maxRetries = 0, + timeout, }: APIConfig = {}) { this.host = host; this.apiKey = apiKey; @@ -32,12 +36,12 @@ export class HTTPClient { if (apiKey) { headers.append("Authorization", `Bearer ${apiKey}`); } - this.baseParams = mergeParams({ headers }, baseParams); + this.baseParams = mergeParams({ headers, maxRetries, timeout }, baseParams); } public get>({ ...params - }: Omit): Core.APIPromise { + }: Omit): Core.APIPromise { return this.request({ method: "GET", ...params, @@ -46,7 +50,7 @@ export class HTTPClient { public post>({ ...params - }: Omit): Core.APIPromise { + }: Omit): Core.APIPromise { return this.request({ method: "POST", ...params, @@ -55,16 +59,16 @@ export class HTTPClient { public put>({ ...params - }: Omit): Core.APIPromise { + }: Omit): Core.APIPromise { return this.request({ - method: "put", + method: "PUT", ...params, }); } public patch>({ ...params - }: Omit): Core.APIPromise { + }: Omit): Core.APIPromise { return this.request({ method: "PATCH", ...params, @@ -73,7 +77,7 @@ export class HTTPClient { public delete>({ ...params - }: Omit): Core.APIPromise { + }: Omit): Core.APIPromise { return this.request({ method: "DELETE", ...params, @@ -85,8 +89,8 @@ export class HTTPClient { path, query, host: hostOverride, - ...fetchParams - }: Core.FullParams): Core.APIPromise { + ...requestOptions + }: Core.FullRequestOptions): Core.APIPromise { const host = hostOverride || this.host; const url = new URL( host + @@ -95,27 +99,70 @@ export class HTTPClient { if (typeof query === "object" && query && !Array.isArray(query)) { url.search = this.stringifyQuery(query as Record); } + const mergedOptions = mergeParams(this.baseParams, requestOptions); + const { maxRetries, timeout, ...fetchParams } = mergedOptions; const init = { - ...mergeParams(this.baseParams, fetchParams), + ...fetchParams, body: JSON.stringify(body), }; - return new Core.APIPromise(this.do(url, init)); + return new Core.APIPromise( + this.do(url, init, { + maxRetries, + signal: fetchParams.signal, + timeout, + }), + ); } - protected async do(input: URL, init: RequestInit): Promise { - const res = await fetch(input, init); + protected async do( + input: URL, + init: RequestInit, + options: { + maxRetries?: number; + signal?: AbortSignal | null; + timeout?: number; + }, + ): Promise { + const maxRetries = options.maxRetries ?? 0; - if (!res.ok) { - const contentType = res.headers.get("content-type"); - const isJSON = contentType?.includes("json"); - throw new Core.APIError( - res.status, - isJSON ? ((await res.json()) as E) : await res.text(), - res, + for (let attempt = 0; ; attempt++) { + const { cleanup, didTimeout, signal } = withTimeoutSignal( + options.signal, + options.timeout, ); - } - return res; + try { + const res = await fetch(input, { ...init, signal }); + + if (!res.ok) { + if (attempt < maxRetries && isRetryableStatus(res.status)) { + continue; + } + + const contentType = res.headers.get("content-type"); + const isJSON = contentType?.includes("json"); + throw new Core.APIError( + res.status, + isJSON ? ((await res.json()) as E) : await res.text(), + res, + ); + } + + return res; + } catch (error) { + if (attempt < maxRetries && isRetryableError(error, options.signal)) { + continue; + } + if (didTimeout()) { + throw new Core.SumUpError( + `Request timed out after ${options.timeout}ms.`, + ); + } + throw error; + } finally { + cleanup(); + } + } } protected stringifyQuery(query: Record): string { @@ -146,14 +193,102 @@ export class HTTPClient { } export function mergeParams( - a: Core.FetchParams, - b: Core.FetchParams, -): Core.FetchParams { + a: Core.RequestOptions, + b: Core.RequestOptions, +): Core.RequestOptions { + const { + authorization: defaultAuthorization, + headers: defaultHeaders, + ...defaultParams + } = a; + const { + authorization: overrideAuthorization, + headers: overrideHeaders, + ...overrideParams + } = b; // calling `new Headers()` normalizes `HeadersInit`, which could be a Headers // object, a plain object, or an array of tuples - const headers = new Headers(a.headers); - for (const [key, value] of new Headers(b.headers).entries()) { + const headers = new Headers(defaultHeaders); + for (const [key, value] of new Headers(overrideHeaders).entries()) { headers.set(key, value); } - return { ...a, ...b, headers }; + const authorization = overrideAuthorization ?? defaultAuthorization; + if (authorization) { + headers.set("Authorization", authorization); + } + + return { ...defaultParams, ...overrideParams, headers }; +} + +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 409 || status === 429 || status >= 500; +} + +function isRetryableError( + error: unknown, + signal?: AbortSignal | null, +): boolean { + if (signal?.aborted) { + return false; + } + return error instanceof TypeError || isAbortError(error); +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === "AbortError" + : error instanceof Error && error.name === "AbortError"; +} + +function withTimeoutSignal( + signal?: AbortSignal | null, + timeout?: number, +): { + cleanup: () => void; + didTimeout: () => boolean; + signal: AbortSignal | undefined; +} { + if (!signal && typeof timeout === "undefined") { + return { + cleanup: () => {}, + didTimeout: () => false, + signal: undefined, + }; + } + + const controller = new AbortController(); + let timedOut = false; + + const onAbort = () => { + controller.abort(signal?.reason); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + const timeoutId = + typeof timeout === "number" + ? setTimeout(() => { + timedOut = true; + controller.abort( + new Core.SumUpError(`Request timed out after ${timeout}ms.`), + ); + }, timeout) + : undefined; + + return { + cleanup: () => { + if (typeof timeoutId !== "undefined") { + clearTimeout(timeoutId); + } + signal?.removeEventListener("abort", onAbort); + }, + didTimeout: () => timedOut, + signal: controller.signal, + }; } diff --git a/sdk/src/core.ts b/sdk/src/core.ts index 7eee2b1f..a56c7727 100644 --- a/sdk/src/core.ts +++ b/sdk/src/core.ts @@ -14,20 +14,31 @@ export class APIResource { // biome-ignore lint/suspicious/noExplicitAny: any, but only for tests type QueryParams = Record; +type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** - * Params that get passed to `fetch`. This ends up as an optional second - * argument to each generated request method. Properties are a subset of - * `RequestInit`. + * SDK-specific options that can be passed to any generated request method. */ -export type FetchParams = Omit; +export type RequestOptions = { + /** + * Optional bearer authorization value to apply to the request, for example + * `Bearer `. When provided, it overrides any default + * Authorization header configured on the client. + */ + authorization?: string; + headers?: HeadersInit; + host?: string; + maxRetries?: number; + signal?: AbortSignal | null; + timeout?: number; +}; /** All arguments to `request()` */ -export type FullParams = FetchParams & { +export type FullRequestOptions = RequestOptions & { path: string; query?: QueryParams; body?: unknown; - host?: string; - method?: string; + method?: HTTPMethod; }; export class SumUpError extends Error {} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 894693e5..3f6f5e8e 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -3,7 +3,7 @@ import { HTTPClient } from "./client"; export type { APIConfig } from "./client"; -export type { FetchParams } from "./core"; +export type { RequestOptions } from "./core"; export { APIError, SumUpError } from "./core"; export * from "./resources/checkouts"; export * from "./types"; diff --git a/sdk/src/resources/checkouts/index.ts b/sdk/src/resources/checkouts/index.ts index 762e3642..acfb170d 100644 --- a/sdk/src/resources/checkouts/index.ts +++ b/sdk/src/resources/checkouts/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { Checkout, CheckoutAccepted, @@ -47,12 +46,12 @@ export class Checkouts extends APIResource { listAvailablePaymentMethods( merchantCode: string, query?: GetPaymentMethodsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/payment-methods`, query, - ...params, + ...options, }); } @@ -61,12 +60,12 @@ export class Checkouts extends APIResource { */ list( query?: ListCheckoutsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/checkouts`, query, - ...params, + ...options, }); } @@ -80,7 +79,7 @@ export class Checkouts extends APIResource { */ create( body: CheckoutCreateRequest, - params?: FetchParams, + options?: RequestOptions, ): APIPromise< Checkout, ErrorExtended | Problem | ErrorForbidden | ErrorBody @@ -91,7 +90,7 @@ export class Checkouts extends APIResource { >({ path: `/v0.1/checkouts`, body, - ...params, + ...options, }); } @@ -100,11 +99,11 @@ export class Checkouts extends APIResource { */ get( id: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/checkouts/${id}`, - ...params, + ...options, }); } @@ -117,7 +116,7 @@ export class Checkouts extends APIResource { process( id: string, body: ProcessCheckout, - params?: FetchParams, + options?: RequestOptions, ): APIPromise< CheckoutSuccess | CheckoutAccepted, ProcessCheckoutError | Problem | ErrorBody @@ -128,7 +127,7 @@ export class Checkouts extends APIResource { >({ path: `/v0.1/checkouts/${id}`, body, - ...params, + ...options, }); } @@ -137,11 +136,11 @@ export class Checkouts extends APIResource { */ deactivate( id: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.delete({ path: `/v0.1/checkouts/${id}`, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/customers/index.ts b/sdk/src/resources/customers/index.ts index 9c111d26..933e9b75 100644 --- a/sdk/src/resources/customers/index.ts +++ b/sdk/src/resources/customers/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { Customer, ErrorBody, @@ -25,7 +24,7 @@ export class Customers extends APIResource { */ create( body: Customer, - params?: FetchParams, + options?: RequestOptions, ): APIPromise< Customer, CreateCustomerError | Problem | ErrorForbidden | ErrorBody @@ -36,7 +35,7 @@ export class Customers extends APIResource { >({ path: `/v0.1/customers`, body, - ...params, + ...options, }); } @@ -45,11 +44,11 @@ export class Customers extends APIResource { */ get( customerId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/customers/${customerId}`, - ...params, + ...options, }); } @@ -62,12 +61,12 @@ export class Customers extends APIResource { update( customerId: string, body: UpdateCustomerParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.put({ path: `/v0.1/customers/${customerId}`, body, - ...params, + ...options, }); } @@ -76,7 +75,7 @@ export class Customers extends APIResource { */ listPaymentInstruments( customerId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise< PaymentInstrumentResponse[], Problem | ErrorForbidden | ErrorBody @@ -86,7 +85,7 @@ export class Customers extends APIResource { Problem | ErrorForbidden | ErrorBody >({ path: `/v0.1/customers/${customerId}/payment-instruments`, - ...params, + ...options, }); } @@ -96,11 +95,11 @@ export class Customers extends APIResource { deactivatePaymentInstrument( customerId: string, token: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.delete({ path: `/v0.1/customers/${customerId}/payment-instruments/${token}`, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/members/index.ts b/sdk/src/resources/members/index.ts index 918fa94d..10eca8ef 100644 --- a/sdk/src/resources/members/index.ts +++ b/sdk/src/resources/members/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { Attributes, Member, @@ -75,12 +74,12 @@ export class Members extends APIResource { list( merchantCode: string, query?: ListMerchantMembersQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/members`, query, - ...params, + ...options, }); } @@ -90,12 +89,12 @@ export class Members extends APIResource { create( merchantCode: string, body: CreateMerchantMemberParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/merchants/${merchantCode}/members`, body, - ...params, + ...options, }); } @@ -105,11 +104,11 @@ export class Members extends APIResource { get( merchantCode: string, memberId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/members/${memberId}`, - ...params, + ...options, }); } @@ -120,12 +119,12 @@ export class Members extends APIResource { merchantCode: string, memberId: string, body: UpdateMerchantMemberParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.put({ path: `/v0.1/merchants/${merchantCode}/members/${memberId}`, body, - ...params, + ...options, }); } @@ -135,11 +134,11 @@ export class Members extends APIResource { delete( merchantCode: string, memberId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.delete({ path: `/v0.1/merchants/${merchantCode}/members/${memberId}`, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/memberships/index.ts b/sdk/src/resources/memberships/index.ts index 38e40392..f2f8c8d9 100644 --- a/sdk/src/resources/memberships/index.ts +++ b/sdk/src/resources/memberships/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { Membership, MembershipStatus, ResourceType } from "../../types"; export type ListMembershipsQueryParams = { offset?: number; @@ -27,12 +26,12 @@ export class Memberships extends APIResource { */ list( query?: ListMembershipsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/memberships`, query, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/merchants/index.ts b/sdk/src/resources/merchants/index.ts index 091803fa..cdbf5077 100644 --- a/sdk/src/resources/merchants/index.ts +++ b/sdk/src/resources/merchants/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { ListPersonsResponseBody, Merchant, @@ -27,12 +26,12 @@ export class Merchants extends APIResource { get( merchantCode: string, query?: GetMerchantQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v1/merchants/${merchantCode}`, query, - ...params, + ...options, }); } @@ -42,12 +41,12 @@ export class Merchants extends APIResource { listPersons( merchantCode: string, query?: ListPersonsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v1/merchants/${merchantCode}/persons`, query, - ...params, + ...options, }); } @@ -58,12 +57,12 @@ export class Merchants extends APIResource { merchantCode: string, personId: string, query?: GetPersonQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v1/merchants/${merchantCode}/persons/${personId}`, query, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/payouts/index.ts b/sdk/src/resources/payouts/index.ts index 24b030fe..0b32ef89 100644 --- a/sdk/src/resources/payouts/index.ts +++ b/sdk/src/resources/payouts/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { ErrorExtended, FinancialPayouts, Problem } from "../../types"; export type ListPayoutsV1QueryParams = { start_date: string; @@ -30,12 +29,12 @@ export class Payouts extends APIResource { list( merchantCode: string, query: ListPayoutsV1QueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v1.0/merchants/${merchantCode}/payouts`, query, - ...params, + ...options, }); } @@ -44,12 +43,12 @@ export class Payouts extends APIResource { */ listDeprecated( query: ListPayoutsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/me/financials/payouts`, query, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/readers/index.ts b/sdk/src/resources/readers/index.ts index 0a225fb4..47f67f1d 100644 --- a/sdk/src/resources/readers/index.ts +++ b/sdk/src/resources/readers/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { CreateReaderCheckoutRequest, CreateReaderCheckoutResponse, @@ -31,11 +30,11 @@ export class Readers extends APIResource { */ list( merchantCode: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/readers`, - ...params, + ...options, }); } @@ -45,12 +44,12 @@ export class Readers extends APIResource { create( merchantCode: string, body: CreateReaderParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/merchants/${merchantCode}/readers`, body, - ...params, + ...options, }); } @@ -60,11 +59,11 @@ export class Readers extends APIResource { get( merchantCode: string, id: ReaderID, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/readers/${id}`, - ...params, + ...options, }); } @@ -74,11 +73,11 @@ export class Readers extends APIResource { delete( merchantCode: string, id: ReaderID, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.delete({ path: `/v0.1/merchants/${merchantCode}/readers/${id}`, - ...params, + ...options, }); } @@ -89,12 +88,12 @@ export class Readers extends APIResource { merchantCode: string, id: ReaderID, body: UpdateReaderParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.patch({ path: `/v0.1/merchants/${merchantCode}/readers/${id}`, body, - ...params, + ...options, }); } @@ -116,12 +115,12 @@ export class Readers extends APIResource { merchantCode: string, readerId: string, body: CreateReaderCheckoutRequest, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/merchants/${merchantCode}/readers/${readerId}/checkout`, body, - ...params, + ...options, }); } @@ -150,11 +149,11 @@ export class Readers extends APIResource { getStatus( merchantCode: string, readerId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/readers/${readerId}/status`, - ...params, + ...options, }); } @@ -179,12 +178,12 @@ export class Readers extends APIResource { merchantCode: string, readerId: string, body?: CreateReaderTerminateParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/merchants/${merchantCode}/readers/${readerId}/terminate`, body, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/receipts/index.ts b/sdk/src/resources/receipts/index.ts index fa2b14f9..c1a55bba 100644 --- a/sdk/src/resources/receipts/index.ts +++ b/sdk/src/resources/receipts/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { ErrorBody, Problem, Receipt } from "../../types"; export type GetReceiptQueryParams = { mid: string; @@ -15,12 +14,12 @@ export class Receipts extends APIResource { get( id: string, query: GetReceiptQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v1.1/receipts/${id}`, query, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/roles/index.ts b/sdk/src/resources/roles/index.ts index c721a524..42356c92 100644 --- a/sdk/src/resources/roles/index.ts +++ b/sdk/src/resources/roles/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { Metadata, Problem, Role } from "../../types"; export type ListMerchantRolesResponse = { items: Role[] }; @@ -42,11 +41,11 @@ export class Roles extends APIResource { */ list( merchantCode: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/roles`, - ...params, + ...options, }); } @@ -56,12 +55,12 @@ export class Roles extends APIResource { create( merchantCode: string, body: CreateMerchantRoleParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/merchants/${merchantCode}/roles`, body, - ...params, + ...options, }); } @@ -71,11 +70,11 @@ export class Roles extends APIResource { get( merchantCode: string, roleId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/merchants/${merchantCode}/roles/${roleId}`, - ...params, + ...options, }); } @@ -85,11 +84,11 @@ export class Roles extends APIResource { delete( merchantCode: string, roleId: string, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.delete({ path: `/v0.1/merchants/${merchantCode}/roles/${roleId}`, - ...params, + ...options, }); } @@ -100,12 +99,12 @@ export class Roles extends APIResource { merchantCode: string, roleId: string, body: UpdateMerchantRoleParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.patch({ path: `/v0.1/merchants/${merchantCode}/roles/${roleId}`, body, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/subaccounts/index.ts b/sdk/src/resources/subaccounts/index.ts index 817bcc3a..2f318197 100644 --- a/sdk/src/resources/subaccounts/index.ts +++ b/sdk/src/resources/subaccounts/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { Operator, Problem } from "../../types"; export type ListSubAccountsQueryParams = { query?: string; @@ -41,12 +40,12 @@ export class Subaccounts extends APIResource { */ listSubAccounts( query?: ListSubAccountsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/me/accounts`, query, - ...params, + ...options, }); } @@ -55,12 +54,12 @@ export class Subaccounts extends APIResource { */ createSubAccount( body: CreateSubAccountParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/me/accounts`, body, - ...params, + ...options, }); } @@ -69,11 +68,11 @@ export class Subaccounts extends APIResource { */ compatGetOperator( operatorId: number, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/me/accounts/${operatorId}`, - ...params, + ...options, }); } @@ -83,12 +82,12 @@ export class Subaccounts extends APIResource { updateSubAccount( operatorId: number, body: UpdateSubAccountParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.put({ path: `/v0.1/me/accounts/${operatorId}`, body, - ...params, + ...options, }); } } diff --git a/sdk/src/resources/transactions/index.ts b/sdk/src/resources/transactions/index.ts index be0899dc..48c5f0a8 100644 --- a/sdk/src/resources/transactions/index.ts +++ b/sdk/src/resources/transactions/index.ts @@ -1,7 +1,6 @@ // Code generated by @sumup/sumup-ts-codegen. DO NOT EDIT. -import { type APIPromise, APIResource, type FetchParams } from "../../core"; - +import { type APIPromise, APIResource, type RequestOptions } from "../../core"; import type { EntryModeFilter, ErrorBody, @@ -92,12 +91,12 @@ export class Transactions extends APIResource { refund( txnId: string, body?: RefundTransactionParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.post({ path: `/v0.1/me/refund/${txnId}`, body, - ...params, + ...options, }); } @@ -114,12 +113,12 @@ export class Transactions extends APIResource { get( merchantCode: string, query?: GetTransactionV2_1QueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v2.1/merchants/${merchantCode}/transactions`, query, - ...params, + ...options, }); } @@ -135,12 +134,12 @@ export class Transactions extends APIResource { */ getDeprecated( query?: GetTransactionQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/me/transactions`, query, - ...params, + ...options, }); } @@ -150,12 +149,12 @@ export class Transactions extends APIResource { list( merchantCode: string, query?: ListTransactionsV2_1QueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v2.1/merchants/${merchantCode}/transactions/history`, query, - ...params, + ...options, }); } @@ -164,12 +163,12 @@ export class Transactions extends APIResource { */ listDeprecated( query?: ListTransactionsQueryParams, - params?: FetchParams, + options?: RequestOptions, ): APIPromise { return this._client.get({ path: `/v0.1/me/transactions/history`, query, - ...params, + ...options, }); } } diff --git a/sdk/tests/client.test.ts b/sdk/tests/client.test.ts index d44221eb..4607684f 100644 --- a/sdk/tests/client.test.ts +++ b/sdk/tests/client.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { SumUp } from "../src"; import { API_VERSION } from "../src/api-version"; +import { mergeParams } from "../src/client"; import { buildRuntimeHeaders } from "../src/runtime"; import { VERSION } from "../src/version"; @@ -43,3 +44,95 @@ describe("query string", () => { ).toEqual("a=b&foo=false&x=&include=1&include=2"); }); }); + +describe("merge params", () => { + it("allows per-request authorization to override the default client header", () => { + const params = mergeParams( + { + headers: { + Authorization: "Bearer default-token", + }, + }, + { + authorization: "Bearer request-token", + }, + ); + + const headers = new Headers(params.headers); + expect(headers.get("authorization")).toBe("Bearer request-token"); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("request options", () => { + it("retries a request when the per-call override allows it", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("network")) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "checkout-id" }), { + headers: { + "content-type": "application/json", + }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new SumUp({ maxRetries: 0 }); + const data = await client.checkouts.get("checkout-id", { maxRetries: 1 }); + + expect(data).toEqual({ id: "checkout-id" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("aborts a request when the per-call timeout is exceeded", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn((_input: URL | RequestInfo, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener( + "abort", + () => { + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new SumUp(); + const request = client.checkouts.get("checkout-id", { timeout: 10 }); + const assertion = expect(request).rejects.toThrow( + "Request timed out after 10ms.", + ); + + await vi.advanceTimersByTimeAsync(10); + + await assertion; + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("generated signatures", () => { + it("keeps request options in the final argument position", () => { + const client = new SumUp(); + const getSpy = vi + .spyOn(client, "get") + .mockReturnValue({} as ReturnType); + + client.checkouts.list(undefined, { timeout: 25 }); + + expect(getSpy).toHaveBeenCalledWith({ + path: "/v0.1/checkouts", + query: undefined, + timeout: 25, + }); + }); +});