diff --git a/README.md b/README.md index 6d9d941..894910e 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,41 @@ console.log(response.data); // ] ``` +#### Pagination + +The generated client provides a built-in helper for reading from paginated LifeOmic APIs: + +```yaml +# Example endpoint +Name: listPaginatedPosts +Request: + type: object + properties: + filter: { type: string } + nextPageToken: { type: string } + pageSize: { type: string } +Response: + type: object + properties: + items: + $ref: '#/definitions/Post' + links: + type: object + properties: + self: { type: string } + next: { type: string } +``` + +```typescript +// Usage +const result = await client.paginate(client.listPaginatedPosts, { + filter: '...', + pageSize: '10', +}); + +result.length; // result is the fully-paginated list of posts +``` + ### OpenAPI Spec generation Use the `generate-open-api-spec` command to generate an OpenAPI spec from a simple schema, which may be useful for interfacing with common OpenAPI tooling. diff --git a/src/generate-axios-client.test.ts b/src/generate-axios-client.test.ts index ff86a03..67f9df7 100644 --- a/src/generate-axios-client.test.ts +++ b/src/generate-axios-client.test.ts @@ -75,6 +75,15 @@ const removePathParams = (url, params) => {} ); +const parseQueryParamsFromPagingLink = (link) => { + const params = new URLSearchParams(link.split("?")[1]); + + return { + nextPageToken: params.get("nextPageToken"), + pageSize: params.get("pageSize"), + }; +}; + class Client { constructor(client) { this.client = client; @@ -97,6 +106,26 @@ class Client { url: substituteParams("/posts/:id", data), }); } + + async paginate(request, data, config) { + const result = []; + + let nextPageParams = {}; + do { + const response = await this[request.name]( + { ...nextPageParams, ...data }, + config + ); + + result.push(...response.data.items); + + nextPageParams = response.data.links.next + ? parseQueryParamsFromPagingLink(response.data.links.next) + : {}; + } while (!!nextPageParams.nextPageToken); + + return result; + } } module.exports.Client = Client; @@ -142,6 +171,17 @@ export declare class Client { Endpoints["PUT /posts/:id"]["PathParams"], config?: AxiosRequestConfig ): Promise>; + + paginate( + request: ( + data: T, + config?: AxiosRequestConfig + ) => Promise< + AxiosResponse<{ items: Item[]; links: { self: string; next?: string } }> + >, + data: T, + config?: AxiosRequestConfig + ): Promise; } `, }, diff --git a/src/generate-axios-client.ts b/src/generate-axios-client.ts index 591e874..af49182 100644 --- a/src/generate-axios-client.ts +++ b/src/generate-axios-client.ts @@ -41,6 +41,17 @@ export declare class ${outputClass} { ): Promise>`; }) .join('\n\n')} + + paginate( + request: ( + data: T, + config?: AxiosRequestConfig + ) => Promise< + AxiosResponse<{ items: Item[]; links: { self: string; next?: string } }> + >, + data: T, + config?: AxiosRequestConfig + ): Promise; }`.trim(), ].join('\n'); @@ -60,6 +71,15 @@ const removePathParams = (url, params) => {} ); +const parseQueryParamsFromPagingLink = (link) => { + const params = new URLSearchParams(link.split('?')[1]); + + return { + nextPageToken: params.get('nextPageToken'), + pageSize: params.get('pageSize') + }; +}; + class ${outputClass} { constructor(client) { @@ -83,6 +103,26 @@ class ${outputClass} { `; }) .join('\n\n')} + + async paginate(request, data, config) { + const result = []; + + let nextPageParams = {}; + do { + const response = await this[request.name]( + { ...nextPageParams, ...data }, + config + ); + + result.push(...response.data.items); + + nextPageParams = response.data.links.next + ? parseQueryParamsFromPagingLink(response.data.links.next) + : {}; + } while (!!nextPageParams.nextPageToken); + + return result; + } } module.exports.${outputClass} = ${outputClass}; diff --git a/src/integration.axios.test.ts b/src/integration.axios.test.ts index 2d822d9..186d650 100644 --- a/src/integration.axios.test.ts +++ b/src/integration.axios.test.ts @@ -53,6 +53,36 @@ const prepare = async () => { }, }, }, + 'GET /posts/list': { + Name: 'listPosts', + Request: { + type: 'object', + additionalProperties: false, + properties: { + filter: { type: 'string' }, + nextPageToken: { type: 'string' }, + pageSize: { type: 'string' }, + }, + }, + Response: { + type: 'object', + additionalProperties: false, + required: ['items', 'links'], + properties: { + items: { + type: 'array', + items: { type: 'string' }, + }, + links: { + type: 'object', + properties: { + self: { type: 'string' }, + next: { type: 'string' }, + }, + }, + }, + }, + }, }, }; @@ -112,4 +142,76 @@ describe('integration tests', () => { }, }); }); + + test('pagination', async () => { + const { client, request } = await prepare(); + + request + .mockResolvedValueOnce({ + data: { + items: ['first', 'second'], + links: { + self: 'blah-blah', + next: '/posts/list?nextPageToken=firstpagetoken&pageSize=10&randomProperty=blah', + }, + }, + }) + .mockResolvedValueOnce({ + data: { + items: ['third', 'fourth'], + links: { + self: 'blah-blah', + next: '/posts/list?nextPageToken=secondpagetoken&pageSize=10&randomProperty=blah', + }, + }, + }) + .mockResolvedValueOnce({ + data: { + items: ['fifth'], + links: { + self: 'blah-blah', + }, + }, + }); + + const result = await client.paginate(client.listPosts, { + filter: 'something', + }); + + expect(request).toHaveBeenCalledTimes(3); + expect(request).toHaveBeenNthCalledWith(1, { + method: 'GET', + params: { + filter: 'something', + }, + url: '/posts/list', + }); + // After first requests, inherits default page size + expect(request).toHaveBeenNthCalledWith(2, { + method: 'GET', + params: { + filter: 'something', + nextPageToken: 'firstpagetoken', + pageSize: '10', + }, + url: '/posts/list', + }); + expect(request).toHaveBeenNthCalledWith(3, { + method: 'GET', + params: { + filter: 'something', + nextPageToken: 'secondpagetoken', + pageSize: '10', + }, + url: '/posts/list', + }); + + expect(result).toStrictEqual([ + 'first', + 'second', + 'third', + 'fourth', + 'fifth', + ]); + }); });