diff --git a/README.md b/README.md index 8c1aa99..10511b8 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,13 @@ const github = new GithubClient({ token: process.env.GITHUB_TOKEN }); -for await (const pr of github.repos.nodejs.node.pulls().iterate()) { +for await (const pr of github.repos.nodejs.node.pulls()) { console.log(pr.title); } -const tags = await github.repos.OpenAlly["github.sdk"].tags().all(); +const tags = await github.repos.OpenAlly["github.sdk"].tags(); -const userRepos = await github.users.torvalds.repos().all(); +const userRepos = await github.users.torvalds.repos(); ``` ## 📚 API diff --git a/docs/api/ApiEndpoint.md b/docs/api/ApiEndpoint.md index ae77fca..b594e25 100644 --- a/docs/api/ApiEndpoint.md +++ b/docs/api/ApiEndpoint.md @@ -10,12 +10,12 @@ const endpoint = repos.nodejs.node.pulls() .setAgent("my-app/1.0.0"); // Stream items one by one across all pages -for await (const pr of endpoint.iterate()) { +for await (const pr of endpoint) { console.log(pr.title); } // Or collect everything at once -const allPRs = await endpoint.all(); +const allPRs = await endpoint; ``` ## Methods @@ -35,3 +35,32 @@ Asynchronously iterates over all items across all pages. Pagination is handled t ### `.all(): Promise` Collects all pages and resolves with a flat array of every item. + +### `[Symbol.asyncIterator](): AsyncIterableIterator` + +Makes `ApiEndpoint` compatible with `for await...of` loops. Internally delegates to `.iterate()`, so pagination is handled transparently. + +```ts +for await (const pr of endpoint) { + console.log(pr.title); +} +``` + +### `.then(onfulfilled?, onrejected?): Promise` + +Makes `ApiEndpoint` a **Thenable**, so it can be `await`-ed directly without calling `.all()` explicitly. Internally delegates to `.all()`. + +```ts +// These two are equivalent: +const prs = await endpoint; +const prs = await endpoint.all(); +``` + +This also means `ApiEndpoint` instances can be passed to `Promise.resolve()`, used in `Promise.all()`, and chained with `.then()` / `.catch()` / `.finally()` like a regular promise. + +```ts +endpoint + .then((prs) => prs.filter((pr) => pr.draft === false)) + .then(console.log) + .catch(console.error); +``` diff --git a/docs/api/GithubClient.md b/docs/api/GithubClient.md index 767536a..038420c 100644 --- a/docs/api/GithubClient.md +++ b/docs/api/GithubClient.md @@ -11,15 +11,15 @@ const github = new GithubClient({ }); // Iterate over all open pull requests -for await (const pr of github.repos.OpenAlly["github.sdk"].pulls().iterate()) { +for await (const pr of github.repos.OpenAlly["github.sdk"].pulls()) { console.log(pr.title); } // Collect all tags at once -const tags = await github.repos.OpenAlly["github.sdk"].tags().all(); +const tags = await github.repos.OpenAlly["github.sdk"].tags(); // List all repositories for a user -const userRepos = await github.users.torvalds.repos().all(); +const userRepos = await github.users.torvalds.repos(); ``` ## Constructor diff --git a/docs/api/repos.md b/docs/api/repos.md index 889be6d..7d8089a 100644 --- a/docs/api/repos.md +++ b/docs/api/repos.md @@ -6,20 +6,20 @@ The `repos` proxy provides access to GitHub repository endpoints. import { repos } from "@openally/github.sdk"; // Collect all tags -const tags = await repos.OpenAlly["github.sdk"].tags().all(); +const tags = await repos.OpenAlly["github.sdk"].tags(); // Stream pull requests page by page -for await (const pr of repos.nodejs.node.pulls().iterate()) { +for await (const pr of repos.nodejs.node.pulls()) { console.log(pr.number, pr.title); } // Stream workflow runs for a specific workflow file -for await (const run of repos.nodejs.node.workflowRuns("ci.yml").iterate()) { +for await (const run of repos.nodejs.node.workflowRuns("ci.yml")) { console.log(run.id, run.status); } // Collect all jobs for a specific run -const jobs = await repos.nodejs.node.runJobs(12345678).all(); +const jobs = await repos.nodejs.node.runJobs(12345678); ``` ## Access pattern diff --git a/docs/api/users.md b/docs/api/users.md index 13b9253..b732d82 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -6,15 +6,15 @@ The `users` proxy provides access to GitHub user endpoints. import { users } from "@openally/github.sdk"; // Collect all repositories for a user -const repos = await users.torvalds.repos().all(); +const repos = await users.torvalds.repos(); // Stream followers one by one -for await (const follower of users.torvalds.followers().iterate()) { +for await (const follower of users.torvalds.followers()) { console.log(follower.login); } // Collect all starred repositories -const starred = await users.torvalds.starred().all(); +const starred = await users.torvalds.starred(); ``` ## Access pattern diff --git a/src/api/repos.ts b/src/api/repos.ts index 7ff66a0..f760b70 100644 --- a/src/api/repos.ts +++ b/src/api/repos.ts @@ -46,24 +46,38 @@ function createRepoProxy( commits: () => new ApiEndpoint(`/repos/${owner}/${repo}/commits`, config), workflows: () => new ApiEndpoint( `/repos/${owner}/${repo}/actions/workflows`, - { ...config, extractor: (raw: WorkflowsResponse) => raw.workflows } + { + ...config, + extractor: (raw: WorkflowsResponse) => raw.workflows + } ), workflowRuns: (workflowId: string | number) => new ApiEndpoint( `/repos/${owner}/${repo}/actions/workflows/${workflowId}/runs`, - { ...config, extractor: (raw: WorkflowRunsResponse) => raw.workflow_runs } + { + ...config, + extractor: (raw: WorkflowRunsResponse) => raw.workflow_runs + } ), runJobs: (runId: number) => new ApiEndpoint( `/repos/${owner}/${repo}/actions/runs/${runId}/jobs`, - { ...config, extractor: (raw: JobsResponse) => raw.jobs } + { + ...config, + extractor: (raw: JobsResponse) => raw.jobs + } ), runArtifacts: (runId: number) => new ApiEndpoint( `/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`, - { ...config, extractor: (raw: ArtifactsResponse) => raw.artifacts } + { + ...config, + extractor: (raw: ArtifactsResponse) => raw.artifacts + } ) }; } -export function createReposProxy(config: RequestConfig = {}): ReposProxy { +export function createReposProxy( + config: RequestConfig = {} +): ReposProxy { return createApiProxy( (owner) => createApiProxy( (repo) => createRepoProxy(owner, repo, config) diff --git a/src/api/users.ts b/src/api/users.ts index 3253d88..1bf08b3 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -32,16 +32,20 @@ export type UsersProxy = { function createUserProxy( username: string, config: RequestConfig = {} -): UserEndpointMethods { +) { return Object.fromEntries( - (Object.keys(kUserEndpointResponseMap) as UserEndpoint[]).map( + Object.keys(kUserEndpointResponseMap).map( (endpoint) => [endpoint, () => new ApiEndpoint(`/users/${username}/${endpoint}`, config)] ) - ) as UserEndpointMethods; + ); } -export function createUsersProxy(config: RequestConfig = {}): UsersProxy { - return createApiProxy((username) => createUserProxy(username, config)) as UsersProxy; +export function createUsersProxy( + config: RequestConfig = {} +): UsersProxy { + return createApiProxy( + (username) => createUserProxy(username, config) + ) as UsersProxy; } export const users = createUsersProxy(); diff --git a/src/class/ApiEndpoint.ts b/src/class/ApiEndpoint.ts index 48f8d89..012d9a4 100644 --- a/src/class/ApiEndpoint.ts +++ b/src/class/ApiEndpoint.ts @@ -99,7 +99,18 @@ export class ApiEndpoint { } while (this.#nextURL !== null); } + [Symbol.asyncIterator](): AsyncIterableIterator { + return this.iterate(); + } + all(): Promise { return Array.fromAsync(this.iterate()); } + + then( + onfulfilled?: ((value: T[]) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.all().then(onfulfilled, onrejected); + } } diff --git a/test/ApiEndpoint.spec.ts b/test/ApiEndpoint.spec.ts index af6efd4..7dc1405 100644 --- a/test/ApiEndpoint.spec.ts +++ b/test/ApiEndpoint.spec.ts @@ -140,6 +140,21 @@ describe("ApiEndpoint", () => { }); }); + describe("all() with thenable", () => { + it("should fetch a single page and return all items", async() => { + mockAgent + .get(kGithubOrigin) + .intercept({ path: "/users/foo/repos", method: "GET" }) + .reply(200, JSON.stringify([{ id: 1 }, { id: 2 }]), { + headers: { "content-type": "application/json" } + }); + + const result = await new ApiEndpoint("/users/foo/repos"); + + assert.deepEqual(result, [{ id: 1 }, { id: 2 }]); + }); + }); + describe("iterate()", () => { it("should yield items one at a time", async() => { mockAgent @@ -184,6 +199,50 @@ describe("ApiEndpoint", () => { }); }); + describe("Symbol.asyncIterator", () => { + it("should yield items one at a time", async() => { + mockAgent + .get(kGithubOrigin) + .intercept({ path: "/users/foo/repos", method: "GET" }) + .reply(200, JSON.stringify([{ id: 1 }, { id: 2 }]), { + headers: { "content-type": "application/json" } + }); + + const items: unknown[] = []; + for await (const item of new ApiEndpoint("/users/foo/repos")) { + items.push(item); + } + + assert.deepEqual(items, [{ id: 1 }, { id: 2 }]); + }); + + it("should yield items across paginated pages", async() => { + const pool = mockAgent.get(kGithubOrigin); + + pool + .intercept({ path: "/users/foo/repos", method: "GET" }) + .reply(200, JSON.stringify([{ id: 1 }]), { + headers: { + "content-type": "application/json", + link: '; rel="next"' + } + }); + + pool + .intercept({ path: "/users/foo/repos?page=2", method: "GET" }) + .reply(200, JSON.stringify([{ id: 2 }, { id: 3 }]), { + headers: { "content-type": "application/json" } + }); + + const items: unknown[] = []; + for await (const item of new ApiEndpoint("/users/foo/repos")) { + items.push(item); + } + + assert.deepEqual(items, [{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + }); + describe("headers", () => { it("should send the default User-Agent header", async() => { mockAgent @@ -257,10 +316,12 @@ describe("ApiEndpoint", () => { describe("extractor", () => { it("should apply the extractor to transform the raw response", async() => { + const workflows = [{ id: 10 }, { id: 20 }]; + mockAgent .get(kGithubOrigin) .intercept({ path: "/repos/foo/bar/actions/workflows", method: "GET" }) - .reply(200, JSON.stringify({ total_count: 2, workflows: [{ id: 10 }, { id: 20 }] }), { + .reply(200, JSON.stringify({ total_count: 2, workflows }), { headers: { "content-type": "application/json" } }); @@ -269,7 +330,7 @@ describe("ApiEndpoint", () => { { extractor: (raw) => raw.workflows } ).all(); - assert.deepEqual(result, [{ id: 10 }, { id: 20 }]); + assert.deepEqual(result, workflows); }); it("should apply the extractor on every page when paginating", async() => { diff --git a/test/GithubClient.spec.ts b/test/GithubClient.spec.ts index 06e1d5e..df5c537 100644 --- a/test/GithubClient.spec.ts +++ b/test/GithubClient.spec.ts @@ -107,9 +107,15 @@ describe("GithubClient", () => { it("should return ApiEndpoints for repo sub-resources", () => { const client = new GithubClient(); - assert.ok(client.repos.owner.myrepo.tags() instanceof ApiEndpoint); - assert.ok(client.repos.owner.myrepo.pulls() instanceof ApiEndpoint); - assert.ok(client.repos.owner.myrepo.workflows() instanceof ApiEndpoint); + assert.ok( + client.repos.owner.myrepo.tags() instanceof ApiEndpoint + ); + assert.ok( + client.repos.owner.myrepo.pulls() instanceof ApiEndpoint + ); + assert.ok( + client.repos.owner.myrepo.workflows() instanceof ApiEndpoint + ); }); it("should use the configured token when fetching repo data", async() => { @@ -126,7 +132,9 @@ describe("GithubClient", () => { headers: { "content-type": "application/json" } }); - const result = await client.repos.octocat["hello-world"].tags().all(); + const result = await client.repos.octocat["hello-world"] + .tags() + .all(); assert.deepEqual(result, [{ name: "v1.0.0" }]); }); @@ -145,22 +153,33 @@ describe("GithubClient", () => { headers: { "content-type": "application/json" } }); - await assert.doesNotReject(client.repos.octocat["hello-world"].tags().all()); + await assert.doesNotReject( + client.repos.octocat["hello-world"].tags().all() + ); }); it("should fetch workflows with the envelope extractor", async() => { const client = new GithubClient(); + const workflows = [{ id: 7, name: "CI" }]; mockAgent .get(kGithubOrigin) - .intercept({ path: "/repos/octocat/hello-world/actions/workflows", method: "GET" }) - .reply(200, JSON.stringify({ total_count: 1, workflows: [{ id: 7, name: "CI" }] }), { + .intercept({ + path: "/repos/octocat/hello-world/actions/workflows", + method: "GET" + }) + .reply(200, JSON.stringify({ total_count: 1, workflows }), { headers: { "content-type": "application/json" } }); - const result = await client.repos.octocat["hello-world"].workflows().all(); + const result = await client.repos.octocat["hello-world"] + .workflows() + .all(); - assert.deepEqual(result, [{ id: 7, name: "CI" }]); + assert.deepEqual( + result, + workflows + ); }); }); }); diff --git a/test/createApiProxy.spec.ts b/test/createApiProxy.spec.ts index a8d0489..3a623a8 100644 --- a/test/createApiProxy.spec.ts +++ b/test/createApiProxy.spec.ts @@ -8,7 +8,7 @@ import { createApiProxy } from "../src/class/createApiProxy.ts"; describe("createApiProxy", () => { it("should call the factory with the accessed property key", () => { - const proxy = createApiProxy((key: string) => key.toUpperCase()); + const proxy = createApiProxy((key) => key.toUpperCase()); assert.equal(proxy.hello, "HELLO"); assert.equal(proxy.world, "WORLD"); @@ -16,7 +16,7 @@ describe("createApiProxy", () => { it("should call the factory on each property access", () => { let callCount = 0; - const proxy = createApiProxy((key: string) => { + const proxy = createApiProxy((key) => { callCount++; return key; @@ -30,7 +30,7 @@ describe("createApiProxy", () => { }); it("should return different values for different keys", () => { - const proxy = createApiProxy((key: string) => { + const proxy = createApiProxy((key) => { return { name: key }; }); @@ -51,7 +51,7 @@ describe("createApiProxy", () => { }); it("should return a plain object (no prototype)", () => { - const proxy = createApiProxy((key: string) => key); + const proxy = createApiProxy((key) => key); assert.equal(Object.getPrototypeOf(proxy), null); }); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..93b98a1 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "types": ["node"], + "lib": ["DOM", "ES2022", "ES2023", "ES2024", "ESNext"] + }, + "include": [ + "." + ] +}