// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types'; import type { HTTPMethod, PromiseOrValue, MergedRequestInit } from './internal/types'; import { uuid4 } from './internal/utils/uuid'; import { validatePositiveInteger, isAbsoluteURL } from './internal/utils/values'; import { sleep } from './internal/utils/sleep'; import { castToError, isAbortError } from './internal/errors'; import type { APIResponseProps } from './internal/parse'; import { getPlatformHeaders } from './internal/detect-platform'; import * as Shims from './internal/shims'; import * as Opts from './internal/request-options'; import { VERSION } from './version'; import * as Errors from './error'; import * as Uploads from './uploads'; import * as API from './resources/index'; import { APIPromise } from './api-promise'; import { type Fetch } from './internal/builtin-types'; import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers'; import { FinalRequestOptions, RequestOptions } from './internal/request-options'; import { Project, ProjectCreateParams, ProjectListResponse, Projects } from './resources/projects'; import { Task, TaskCreateParams, TaskListResponse, TaskUpdateParams, Tasks } from './resources/tasks'; import { User, UserCreateParams, UserListResponse, Users } from './resources/users'; import { readEnv } from './internal/utils/env'; import { logger } from './internal/utils/log'; import { isEmptyObj } from './internal/utils/values'; const safeJSON = (text: string) => { try { return JSON.parse(text); } catch (err) { return undefined; } }; type LogFn = (message: string, ...rest: unknown[]) => void; export type Logger = { error: LogFn; warn: LogFn; info: LogFn; debug: LogFn; }; export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; const isLogLevel = (key: string | undefined): key is LogLevel => { const levels: Record = { off: true, error: true, warn: true, info: true, debug: true, }; return key! in levels; }; export interface ClientOptions { /** * Override the default base URL for the API, e.g., "https://api.example.com/v2/" * * Defaults to process.env['LIGHTSWITCH_BASE_URL']. */ baseURL?: string | null | undefined; /** * The maximum amount of time (in milliseconds) that the client should wait for a response * from the server before timing out a single request. * * Note that request timeouts are retried by default, so in a worst-case scenario you may wait * much longer than this timeout before the promise succeeds or fails. */ timeout?: number | undefined; /** * Additional `RequestInit` options to be passed to `fetch` calls. * Properties will be overridden by per-request `fetchOptions`. */ fetchOptions?: MergedRequestInit | undefined; /** * Specify a custom `fetch` function implementation. * * If not provided, we expect that `fetch` is defined globally. */ fetch?: Fetch | undefined; /** * The maximum number of times that the client will retry a request in case of a * temporary failure, like a network error or a 5XX error from the server. * * @default 2 */ maxRetries?: number | undefined; /** * Default headers to include with every request to the API. * * These can be removed in individual requests by explicitly setting the * header to `null` in request options. */ defaultHeaders?: HeadersLike | undefined; /** * Default query parameters to include with every request to the API. * * These can be removed in individual requests by explicitly setting the * param to `undefined` in request options. */ defaultQuery?: Record | undefined; /** * Set the log level. * * Defaults to process.env['LIGHTSWITCH_LOG']. */ logLevel?: LogLevel | undefined | null; /** * Set the logger. * * Defaults to globalThis.console. */ logger?: Logger | undefined | null; } type FinalizedRequestInit = RequestInit & { headers: Headers }; /** * API Client for interfacing with the Lightswitch API. */ export class Lightswitch { baseURL: string; maxRetries: number; timeout: number; logger: Logger | undefined; logLevel: LogLevel | undefined; fetchOptions: MergedRequestInit | undefined; private fetch: Fetch; #encoder: Opts.RequestEncoder; protected idempotencyHeader?: string; private _options: ClientOptions; /** * API Client for interfacing with the Lightswitch API. * * @param {string} [opts.baseURL=process.env['LIGHTSWITCH_BASE_URL'] ?? https://lightswitch.gitbook.dev/api] - Override the default base URL for the API. * @param {number} [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. * @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request. * @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API. * @param {Record} opts.defaultQuery - Default query parameters to include with every request to the API. */ constructor({ baseURL = readEnv('LIGHTSWITCH_BASE_URL'), ...opts }: ClientOptions = {}) { const options: ClientOptions = { ...opts, baseURL: baseURL || `https://lightswitch.gitbook.dev/api`, }; this.baseURL = options.baseURL!; this.timeout = options.timeout ?? Lightswitch.DEFAULT_TIMEOUT /* 1 minute */; this.logger = options.logger ?? console; if (options.logLevel != null) { this.logLevel = options.logLevel; } else { const envLevel = readEnv('LIGHTSWITCH_LOG'); if (isLogLevel(envLevel)) { this.logLevel = envLevel; } } this.fetchOptions = options.fetchOptions; this.maxRetries = options.maxRetries ?? 2; this.fetch = options.fetch ?? Shims.getDefaultFetch(); this.#encoder = Opts.FallbackEncoder; this._options = options; } protected defaultQuery(): Record | undefined { return this._options.defaultQuery; } protected validateHeaders({ values, nulls }: NullableHeaders) { return; } /** * Basic re-implementation of `qs.stringify` for primitive types. */ protected stringifyQuery(query: Record): string { return Object.entries(query) .filter(([_, value]) => typeof value !== 'undefined') .map(([key, value]) => { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; } if (value === null) { return `${encodeURIComponent(key)}=`; } throw new Errors.LightswitchError( `Cannot stringify type ${typeof value}; Expected string, number, boolean, or null. If you need to pass nested query parameters, you can manually encode them, e.g. { query: { 'foo[key1]': value1, 'foo[key2]': value2 } }, and please open a GitHub issue requesting better support for your use case.`, ); }) .join('&'); } private getUserAgent(): string { return `${this.constructor.name}/JS ${VERSION}`; } protected defaultIdempotencyKey(): string { return `stainless-node-retry-${uuid4()}`; } protected makeStatusError( status: number, error: Object, message: string | undefined, headers: Headers, ): Errors.APIError { return Errors.APIError.generate(status, error, message, headers); } buildURL(path: string, query: Record | null | undefined): string { const url = isAbsoluteURL(path) ? new URL(path) : new URL(this.baseURL + (this.baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path)); const defaultQuery = this.defaultQuery(); if (!isEmptyObj(defaultQuery)) { query = { ...defaultQuery, ...query }; } if (typeof query === 'object' && query && !Array.isArray(query)) { url.search = this.stringifyQuery(query as Record); } return url.toString(); } private calculateContentLength(body: unknown): string | null { if (typeof body === 'string') { if (typeof (globalThis as any).Buffer !== 'undefined') { return (globalThis as any).Buffer.byteLength(body, 'utf8').toString(); } if (typeof (globalThis as any).TextEncoder !== 'undefined') { const encoder = new (globalThis as any).TextEncoder(); const encoded = encoder.encode(body); return encoded.length.toString(); } } else if (ArrayBuffer.isView(body)) { return body.byteLength.toString(); } return null; } /** * Used as a callback for mutating the given `FinalRequestOptions` object. */ protected async prepareOptions(options: FinalRequestOptions): Promise {} /** * Used as a callback for mutating the given `RequestInit` object. * * This is useful for cases where you want to add certain headers based off of * the request properties, e.g. `method` or `url`. */ protected async prepareRequest( request: RequestInit, { url, options }: { url: string; options: FinalRequestOptions }, ): Promise {} get(path: string, opts?: PromiseOrValue): APIPromise { return this.methodRequest('get', path, opts); } post(path: string, opts?: PromiseOrValue): APIPromise { return this.methodRequest('post', path, opts); } patch(path: string, opts?: PromiseOrValue): APIPromise { return this.methodRequest('patch', path, opts); } put(path: string, opts?: PromiseOrValue): APIPromise { return this.methodRequest('put', path, opts); } delete(path: string, opts?: PromiseOrValue): APIPromise { return this.methodRequest('delete', path, opts); } private methodRequest( method: HTTPMethod, path: string, opts?: PromiseOrValue, ): APIPromise { return this.request( Promise.resolve(opts).then((opts) => { return { method, path, ...opts }; }), ); } request( options: PromiseOrValue, remainingRetries: number | null = null, ): APIPromise { return new APIPromise(this, this.makeRequest(options, remainingRetries)); } private async makeRequest( optionsInput: PromiseOrValue, retriesRemaining: number | null, ): Promise { const options = await optionsInput; const maxRetries = options.maxRetries ?? this.maxRetries; if (retriesRemaining == null) { retriesRemaining = maxRetries; } await this.prepareOptions(options); const { req, url, timeout } = this.buildRequest(options, { retryCount: maxRetries - retriesRemaining }); await this.prepareRequest(req, { url, options }); logger(this).debug('request', url, options, req.headers); if (options.signal?.aborted) { throw new Errors.APIUserAbortError(); } const controller = new AbortController(); const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError); if (response instanceof Error) { if (options.signal?.aborted) { throw new Errors.APIUserAbortError(); } if (retriesRemaining) { return this.retryRequest(options, retriesRemaining); } if (isAbortError(response)) { throw new Errors.APIConnectionTimeoutError(); } // detect native connection timeout errors // deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)" // undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)" // others do not provide enough information to distinguish timeouts from other connection errors if (/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''))) { throw new Errors.APIConnectionTimeoutError(); } throw new Errors.APIConnectionError({ cause: response }); } if (!response.ok) { if (retriesRemaining && this.shouldRetry(response)) { const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; logger(this).debug(`response (error; ${retryMessage})`, response.status, url, response.headers); return this.retryRequest(options, retriesRemaining, response.headers); } const errText = await response.text().catch((err: any) => castToError(err).message); const errJSON = safeJSON(errText); const errMessage = errJSON ? undefined : errText; const retryMessage = retriesRemaining ? `(error; no more retries left)` : `(error; not retryable)`; logger(this).debug( `response (error; ${retryMessage})`, response.status, url, response.headers, errMessage, ); const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers); throw err; } return { response, options, controller }; } async fetchWithTimeout( url: RequestInfo, init: RequestInit | undefined, ms: number, controller: AbortController, ): Promise { const { signal, method, ...options } = init || {}; if (signal) signal.addEventListener('abort', () => controller.abort()); const timeout = setTimeout(() => controller.abort(), ms); const isReadableBody = Shims.isReadableLike(options.body); const fetchOptions: RequestInit = { signal: controller.signal as any, ...(isReadableBody ? { duplex: 'half' } : {}), method: 'GET', ...options, }; if (method) { // Custom methods like 'patch' need to be uppercased // See https://github.com/nodejs/undici/issues/2294 fetchOptions.method = method.toUpperCase(); } return ( // use undefined this binding; fetch errors if bound to something else in browser/cloudflare this.fetch.call(undefined, url, fetchOptions).finally(() => { clearTimeout(timeout); }) ); } private shouldRetry(response: Response): boolean { // Note this is not a standard header. const shouldRetryHeader = response.headers.get('x-should-retry'); // If the server explicitly says whether or not to retry, obey. if (shouldRetryHeader === 'true') return true; if (shouldRetryHeader === 'false') return false; // Retry on request timeouts. if (response.status === 408) return true; // Retry on lock timeouts. if (response.status === 409) return true; // Retry on rate limits. if (response.status === 429) return true; // Retry internal errors. if (response.status >= 500) return true; return false; } private async retryRequest( options: FinalRequestOptions, retriesRemaining: number, responseHeaders?: Headers | undefined, ): Promise { let timeoutMillis: number | undefined; // Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it. const retryAfterMillisHeader = responseHeaders?.get('retry-after-ms'); if (retryAfterMillisHeader) { const timeoutMs = parseFloat(retryAfterMillisHeader); if (!Number.isNaN(timeoutMs)) { timeoutMillis = timeoutMs; } } // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After const retryAfterHeader = responseHeaders?.get('retry-after'); if (retryAfterHeader && !timeoutMillis) { const timeoutSeconds = parseFloat(retryAfterHeader); if (!Number.isNaN(timeoutSeconds)) { timeoutMillis = timeoutSeconds * 1000; } else { timeoutMillis = Date.parse(retryAfterHeader) - Date.now(); } } // If the API asks us to wait a certain amount of time (and it's a reasonable amount), // just do what it says, but otherwise calculate a default if (!(timeoutMillis && 0 <= timeoutMillis && timeoutMillis < 60 * 1000)) { const maxRetries = options.maxRetries ?? this.maxRetries; timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); } await sleep(timeoutMillis); return this.makeRequest(options, retriesRemaining - 1); } private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number { const initialRetryDelay = 0.5; const maxRetryDelay = 8.0; const numRetries = maxRetries - retriesRemaining; // Apply exponential backoff, but not more than the max. const sleepSeconds = Math.min(initialRetryDelay * Math.pow(2, numRetries), maxRetryDelay); // Apply some jitter, take up to at most 25 percent of the retry time. const jitter = 1 - Math.random() * 0.25; return sleepSeconds * jitter * 1000; } buildRequest( options: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, ): { req: FinalizedRequestInit; url: string; timeout: number } { options = { ...options }; const { method, path, query } = options; const url = this.buildURL(path!, query as Record); if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; const { bodyHeaders, body } = this.buildBody({ options }); const reqHeaders = this.buildHeaders({ options, method, bodyHeaders, retryCount }); const req: FinalizedRequestInit = { method, headers: reqHeaders, ...(options.signal && { signal: options.signal }), ...((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream && { duplex: 'half' }), ...(body && { body }), ...((this.fetchOptions as any) ?? {}), ...((options.fetchOptions as any) ?? {}), }; return { req, url, timeout: options.timeout }; } private buildHeaders({ options, method, bodyHeaders, retryCount, }: { options: FinalRequestOptions; method: HTTPMethod; bodyHeaders: HeadersLike; retryCount: number; }): Headers { let idempotencyHeaders: HeadersLike = {}; if (this.idempotencyHeader && method !== 'get') { if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey(); idempotencyHeaders[this.idempotencyHeader] = options.idempotencyKey; } const headers = buildHeaders([ idempotencyHeaders, { Accept: 'application/json', 'User-Agent': this.getUserAgent(), 'X-Stainless-Retry-Count': String(retryCount), ...(options.timeout ? { 'X-Stainless-Timeout': String(options.timeout) } : {}), ...getPlatformHeaders(), }, this._options.defaultHeaders, bodyHeaders, options.headers, ]); this.validateHeaders(headers); return headers.values; } private buildBody({ options: { body, headers: rawHeaders } }: { options: FinalRequestOptions }): { bodyHeaders: HeadersLike; body: BodyInit | undefined; } { if (!body) { return { bodyHeaders: undefined, body: undefined }; } const headers = buildHeaders([rawHeaders]); if ( // Pass raw type verbatim ArrayBuffer.isView(body) || body instanceof ArrayBuffer || body instanceof DataView || (typeof body === 'string' && // Preserve legacy string encoding behavior for now headers.values.has('content-type')) || // `Blob` is superset of `File` body instanceof Blob || // `FormData` -> `multipart/form-data` body instanceof FormData || // `URLSearchParams` -> `application/x-www-form-urlencoded` body instanceof URLSearchParams || // Send chunked stream (each chunk has own `length`) ((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) ) { return { bodyHeaders: undefined, body: body as BodyInit }; } else if ( typeof body === 'object' && (Symbol.asyncIterator in body || (Symbol.iterator in body && 'next' in body && typeof body.next === 'function')) ) { return { bodyHeaders: undefined, body: Shims.ReadableStreamFrom(body as AsyncIterable) }; } else { return this.#encoder({ body, headers }); } } static Lightswitch = this; static DEFAULT_TIMEOUT = 60000; // 1 minute static LightswitchError = Errors.LightswitchError; static APIError = Errors.APIError; static APIConnectionError = Errors.APIConnectionError; static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError; static APIUserAbortError = Errors.APIUserAbortError; static NotFoundError = Errors.NotFoundError; static ConflictError = Errors.ConflictError; static RateLimitError = Errors.RateLimitError; static BadRequestError = Errors.BadRequestError; static AuthenticationError = Errors.AuthenticationError; static InternalServerError = Errors.InternalServerError; static PermissionDeniedError = Errors.PermissionDeniedError; static UnprocessableEntityError = Errors.UnprocessableEntityError; static toFile = Uploads.toFile; projects: API.Projects = new API.Projects(this); tasks: API.Tasks = new API.Tasks(this); users: API.Users = new API.Users(this); } Lightswitch.Projects = Projects; Lightswitch.Tasks = Tasks; Lightswitch.Users = Users; export declare namespace Lightswitch { export type RequestOptions = Opts.RequestOptions; export { Projects as Projects, type Project as Project, type ProjectListResponse as ProjectListResponse, type ProjectCreateParams as ProjectCreateParams, }; export { Tasks as Tasks, type Task as Task, type TaskListResponse as TaskListResponse, type TaskCreateParams as TaskCreateParams, type TaskUpdateParams as TaskUpdateParams, }; export { Users as Users, type User as User, type UserListResponse as UserListResponse, type UserCreateParams as UserCreateParams, }; }