diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 78e7f27..ddfa3e3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.0" + ".": "0.11.1" } diff --git a/.stats.yml b/.stats.yml index 6ac19ba..7fb3d31 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 46 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-e98d46c55826cdf541a9ee0df04ce92806ac6d4d92957ae79f897270b7d85b23.yml -openapi_spec_hash: 8a1af54fc0a4417165b8a52e6354b685 -config_hash: 043ddc54629c6d8b889123770cb4769f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-cb38560915edce03abce2ae3ef5bc745489dbe9b6f80c2b4ff42edf8c2ff276d.yml +openapi_spec_hash: a869194d6c864ba28d79ec0105439c3e +config_hash: ed56f95781ec9b2e73c97e1a66606071 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9bb96..dca3d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.11.1 (2025-09-08) + +Full Changelog: [v0.11.0...v0.11.1](https://github.com/onkernel/kernel-node-sdk/compare/v0.11.0...v0.11.1) + +### Features + +* **api:** add pagination to the deployments endpoint ([b8fa501](https://github.com/onkernel/kernel-node-sdk/commit/b8fa5012dd3b5944e128a5ff629aeea19159362a)) +* **api:** pagination properties added to response (has_more, next_offset) ([49c574e](https://github.com/onkernel/kernel-node-sdk/commit/49c574eeba8413d01cc4528282a59068dfc2150d)) +* **api:** update API spec with pagination headers ([d1169c0](https://github.com/onkernel/kernel-node-sdk/commit/d1169c09fb1222114db878a133ca593479d257fc)) + + +### Bug Fixes + +* coerce nullable values to undefined ([41cb0ae](https://github.com/onkernel/kernel-node-sdk/commit/41cb0aeb163e00f1e3e2fe41acd3f0c379b64976)) + + +### Chores + +* ci build action ([790397f](https://github.com/onkernel/kernel-node-sdk/commit/790397fdd2f074f49e70a1eebfa129750174bcb3)) + ## 0.11.0 (2025-09-04) Full Changelog: [v0.10.0...v0.11.0](https://github.com/onkernel/kernel-node-sdk/compare/v0.10.0...v0.11.0) diff --git a/README.md b/README.md index 98f3d75..fa69c0c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,37 @@ On timeout, an `APIConnectionTimeoutError` is thrown. Note that requests which time out will be [retried twice by default](#retries). +## Auto-pagination + +List methods in the Kernel API are paginated. +You can use the `for await … of` syntax to iterate through items across all pages: + +```ts +async function fetchAllDeploymentListResponses(params) { + const allDeploymentListResponses = []; + // Automatically fetches more pages as needed. + for await (const deploymentListResponse of client.deployments.list({ app_name: 'YOUR_APP', limit: 2 })) { + allDeploymentListResponses.push(deploymentListResponse); + } + return allDeploymentListResponses; +} +``` + +Alternatively, you can request a single page at a time: + +```ts +let page = await client.deployments.list({ app_name: 'YOUR_APP', limit: 2 }); +for (const deploymentListResponse of page.items) { + console.log(deploymentListResponse); +} + +// Convenience methods are provided for manually paginating: +while (page.hasNextPage()) { + page = await page.getNextPage(); + // ... +} +``` + ## Advanced Usage ### Accessing raw Response data (e.g., headers) diff --git a/api.md b/api.md index 4c61dfb..86d0347 100644 --- a/api.md +++ b/api.md @@ -23,7 +23,7 @@ Methods: - client.deployments.create({ ...params }) -> DeploymentCreateResponse - client.deployments.retrieve(id) -> DeploymentRetrieveResponse -- client.deployments.list({ ...params }) -> DeploymentListResponse +- client.deployments.list({ ...params }) -> DeploymentListResponsesOffsetPagination - client.deployments.follow(id, { ...params }) -> DeploymentFollowResponse # Apps diff --git a/package.json b/package.json index 75126b4..bae1017 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onkernel/sdk", - "version": "0.11.0", + "version": "0.11.1", "description": "The official TypeScript library for the Kernel API", "author": "Kernel <>", "types": "dist/index.d.ts", diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index ab5947d..38abeb4 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -12,7 +12,7 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz "${BUILD_PATH:-dist}" | curl -v -X PUT \ +UPLOAD_RESPONSE=$(tar "${BASE_PATH:+-C$BASE_PATH}" -cz "${ARTIFACT_PATH:-dist}" | curl -v -X PUT \ -H "Content-Type: application/gzip" \ --data-binary @- "$SIGNED_URL" 2>&1) diff --git a/src/client.ts b/src/client.ts index 130658c..77a53c3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,8 @@ import * as Shims from './internal/shims'; import * as Opts from './internal/request-options'; import { VERSION } from './version'; import * as Errors from './core/error'; +import * as Pagination from './core/pagination'; +import { AbstractPage, type OffsetPaginationParams, OffsetPaginationResponse } from './core/pagination'; import * as Uploads from './core/uploads'; import * as API from './resources/index'; import { APIPromise } from './core/api-promise'; @@ -24,6 +26,7 @@ import { DeploymentFollowResponse, DeploymentListParams, DeploymentListResponse, + DeploymentListResponsesOffsetPagination, DeploymentRetrieveResponse, DeploymentStateEvent, Deployments, @@ -551,6 +554,25 @@ export class Kernel { return { response, options, controller, requestLogID, retryOfRequestLogID, startTime }; } + getAPIList = Pagination.AbstractPage>( + path: string, + Page: new (...args: any[]) => PageClass, + opts?: RequestOptions, + ): Pagination.PagePromise { + return this.requestAPIList(Page, { method: 'get', path, ...opts }); + } + + requestAPIList< + Item = unknown, + PageClass extends Pagination.AbstractPage = Pagination.AbstractPage, + >( + Page: new (...args: ConstructorParameters) => PageClass, + options: FinalRequestOptions, + ): Pagination.PagePromise { + const request = this.makeRequest(options, null, undefined); + return new Pagination.PagePromise(this as any as Kernel, request, Page); + } + async fetchWithTimeout( url: RequestInfo, init: RequestInit | undefined, @@ -809,6 +831,12 @@ Kernel.Profiles = Profiles; export declare namespace Kernel { export type RequestOptions = Opts.RequestOptions; + export import OffsetPagination = Pagination.OffsetPagination; + export { + type OffsetPaginationParams as OffsetPaginationParams, + type OffsetPaginationResponse as OffsetPaginationResponse, + }; + export { Deployments as Deployments, type DeploymentStateEvent as DeploymentStateEvent, @@ -816,6 +844,7 @@ export declare namespace Kernel { type DeploymentRetrieveResponse as DeploymentRetrieveResponse, type DeploymentListResponse as DeploymentListResponse, type DeploymentFollowResponse as DeploymentFollowResponse, + type DeploymentListResponsesOffsetPagination as DeploymentListResponsesOffsetPagination, type DeploymentCreateParams as DeploymentCreateParams, type DeploymentListParams as DeploymentListParams, type DeploymentFollowParams as DeploymentFollowParams, diff --git a/src/core/pagination.ts b/src/core/pagination.ts new file mode 100644 index 0000000..c3266cd --- /dev/null +++ b/src/core/pagination.ts @@ -0,0 +1,167 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { KernelError } from './error'; +import { FinalRequestOptions } from '../internal/request-options'; +import { defaultParseResponse } from '../internal/parse'; +import { type Kernel } from '../client'; +import { APIPromise } from './api-promise'; +import { type APIResponseProps } from '../internal/parse'; +import { maybeCoerceBoolean, maybeCoerceInteger, maybeObj } from '../internal/utils/values'; + +export type PageRequestOptions = Pick; + +export abstract class AbstractPage implements AsyncIterable { + #client: Kernel; + protected options: FinalRequestOptions; + + protected response: Response; + protected body: unknown; + + constructor(client: Kernel, response: Response, body: unknown, options: FinalRequestOptions) { + this.#client = client; + this.options = options; + this.response = response; + this.body = body; + } + + abstract nextPageRequestOptions(): PageRequestOptions | null; + + abstract getPaginatedItems(): Item[]; + + hasNextPage(): boolean { + const items = this.getPaginatedItems(); + if (!items.length) return false; + return this.nextPageRequestOptions() != null; + } + + async getNextPage(): Promise { + const nextOptions = this.nextPageRequestOptions(); + if (!nextOptions) { + throw new KernelError( + 'No next page expected; please check `.hasNextPage()` before calling `.getNextPage()`.', + ); + } + + return await this.#client.requestAPIList(this.constructor as any, nextOptions); + } + + async *iterPages(): AsyncGenerator { + let page: this = this; + yield page; + while (page.hasNextPage()) { + page = await page.getNextPage(); + yield page; + } + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + for await (const page of this.iterPages()) { + for (const item of page.getPaginatedItems()) { + yield item; + } + } + } +} + +/** + * This subclass of Promise will resolve to an instantiated Page once the request completes. + * + * It also implements AsyncIterable to allow auto-paginating iteration on an unawaited list call, eg: + * + * for await (const item of client.items.list()) { + * console.log(item) + * } + */ +export class PagePromise< + PageClass extends AbstractPage, + Item = ReturnType[number], + > + extends APIPromise + implements AsyncIterable +{ + constructor( + client: Kernel, + request: Promise, + Page: new (...args: ConstructorParameters) => PageClass, + ) { + super( + client, + request, + async (client, props) => + new Page(client, props.response, await defaultParseResponse(client, props), props.options), + ); + } + + /** + * Allow auto-paginating iteration on an unawaited list call, eg: + * + * for await (const item of client.items.list()) { + * console.log(item) + * } + */ + async *[Symbol.asyncIterator](): AsyncGenerator { + const page = await this; + for await (const item of page) { + yield item; + } + } +} + +export type OffsetPaginationResponse = Item[]; + +export interface OffsetPaginationParams { + offset?: number; + + limit?: number; +} + +export class OffsetPagination extends AbstractPage { + items: Array; + + has_more: boolean | null; + + next_offset: number | null; + + constructor( + client: Kernel, + response: Response, + body: OffsetPaginationResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.items = body || []; + this.has_more = maybeCoerceBoolean(this.response.headers.get('x-has-more')) ?? null; + this.next_offset = maybeCoerceInteger(this.response.headers.get('x-next-offset')) ?? null; + } + + getPaginatedItems(): Item[] { + return this.items ?? []; + } + + override hasNextPage(): boolean { + if (this.has_more === false) { + return false; + } + + return super.hasNextPage(); + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = this.next_offset; + if (!offset) { + return null; + } + + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} diff --git a/src/index.ts b/src/index.ts index ac8023c..72d9bc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { Kernel as default } from './client'; export { type Uploadable, toFile } from './core/uploads'; export { APIPromise } from './core/api-promise'; export { Kernel, type ClientOptions } from './client'; +export { PagePromise } from './core/pagination'; export { KernelError, APIError, diff --git a/src/internal/utils/values.ts b/src/internal/utils/values.ts index 667646d..1fc2af6 100644 --- a/src/internal/utils/values.ts +++ b/src/internal/utils/values.ts @@ -76,21 +76,21 @@ export const coerceBoolean = (value: unknown): boolean => { }; export const maybeCoerceInteger = (value: unknown): number | undefined => { - if (value === undefined) { + if (value == null) { return undefined; } return coerceInteger(value); }; export const maybeCoerceFloat = (value: unknown): number | undefined => { - if (value === undefined) { + if (value == null) { return undefined; } return coerceFloat(value); }; export const maybeCoerceBoolean = (value: unknown): boolean | undefined => { - if (value === undefined) { + if (value == null) { return undefined; } return coerceBoolean(value); diff --git a/src/pagination.ts b/src/pagination.ts new file mode 100644 index 0000000..90bf015 --- /dev/null +++ b/src/pagination.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/pagination instead */ +export * from './core/pagination'; diff --git a/src/resources/deployments.ts b/src/resources/deployments.ts index 6cb5672..b9dfe5a 100644 --- a/src/resources/deployments.ts +++ b/src/resources/deployments.ts @@ -3,6 +3,7 @@ import { APIResource } from '../core/resource'; import * as Shared from './shared'; import { APIPromise } from '../core/api-promise'; +import { OffsetPagination, type OffsetPaginationParams, PagePromise } from '../core/pagination'; import { Stream } from '../core/streaming'; import { type Uploadable } from '../core/uploads'; import { buildHeaders } from '../internal/headers'; @@ -43,14 +44,20 @@ export class Deployments extends APIResource { * * @example * ```ts - * const deployments = await client.deployments.list(); + * // Automatically fetches more pages as needed. + * for await (const deploymentListResponse of client.deployments.list()) { + * // ... + * } * ``` */ list( query: DeploymentListParams | null | undefined = {}, options?: RequestOptions, - ): APIPromise { - return this._client.get('/deployments', { query, ...options }); + ): PagePromise { + return this._client.getAPIList('/deployments', OffsetPagination, { + query, + ...options, + }); } /** @@ -77,6 +84,8 @@ export class Deployments extends APIResource { } } +export type DeploymentListResponsesOffsetPagination = OffsetPagination; + /** * An event representing the current state of a deployment. */ @@ -234,53 +243,49 @@ export interface DeploymentRetrieveResponse { updated_at?: string | null; } -export type DeploymentListResponse = Array; - -export namespace DeploymentListResponse { +/** + * Deployment record information. + */ +export interface DeploymentListResponse { /** - * Deployment record information. + * Unique identifier for the deployment */ - export interface DeploymentListResponseItem { - /** - * Unique identifier for the deployment - */ - id: string; + id: string; - /** - * Timestamp when the deployment was created - */ - created_at: string; + /** + * Timestamp when the deployment was created + */ + created_at: string; - /** - * Deployment region code - */ - region: 'aws.us-east-1a'; + /** + * Deployment region code + */ + region: 'aws.us-east-1a'; - /** - * Current status of the deployment - */ - status: 'queued' | 'in_progress' | 'running' | 'failed' | 'stopped'; + /** + * Current status of the deployment + */ + status: 'queued' | 'in_progress' | 'running' | 'failed' | 'stopped'; - /** - * Relative path to the application entrypoint - */ - entrypoint_rel_path?: string; + /** + * Relative path to the application entrypoint + */ + entrypoint_rel_path?: string; - /** - * Environment variables configured for this deployment - */ - env_vars?: { [key: string]: string }; + /** + * Environment variables configured for this deployment + */ + env_vars?: { [key: string]: string }; - /** - * Status reason - */ - status_reason?: string; + /** + * Status reason + */ + status_reason?: string; - /** - * Timestamp when the deployment was last updated - */ - updated_at?: string | null; - } + /** + * Timestamp when the deployment was last updated + */ + updated_at?: string | null; } /** @@ -373,7 +378,7 @@ export interface DeploymentCreateParams { version?: string; } -export interface DeploymentListParams { +export interface DeploymentListParams extends OffsetPaginationParams { /** * Filter results by application name. */ @@ -394,6 +399,7 @@ export declare namespace Deployments { type DeploymentRetrieveResponse as DeploymentRetrieveResponse, type DeploymentListResponse as DeploymentListResponse, type DeploymentFollowResponse as DeploymentFollowResponse, + type DeploymentListResponsesOffsetPagination as DeploymentListResponsesOffsetPagination, type DeploymentCreateParams as DeploymentCreateParams, type DeploymentListParams as DeploymentListParams, type DeploymentFollowParams as DeploymentFollowParams, diff --git a/src/resources/index.ts b/src/resources/index.ts index 6b0e380..6482d4f 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -22,6 +22,7 @@ export { type DeploymentCreateParams, type DeploymentListParams, type DeploymentFollowParams, + type DeploymentListResponsesOffsetPagination, } from './deployments'; export { Invocations, diff --git a/src/version.ts b/src/version.ts index 9085e9d..945825f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.11.0'; // x-release-please-version +export const VERSION = '0.11.1'; // x-release-please-version diff --git a/tests/api-resources/deployments.test.ts b/tests/api-resources/deployments.test.ts index 289fe0c..80f430f 100644 --- a/tests/api-resources/deployments.test.ts +++ b/tests/api-resources/deployments.test.ts @@ -63,7 +63,10 @@ describe('resource deployments', () => { test.skip('list: request options and params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( - client.deployments.list({ app_name: 'app_name' }, { path: '/_stainless_unknown_path' }), + client.deployments.list( + { app_name: 'app_name', limit: 1, offset: 0 }, + { path: '/_stainless_unknown_path' }, + ), ).rejects.toThrow(Kernel.NotFoundError); });