Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/list-workspace-filter-and-list-all.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pdfmonkey": minor
---

Adding a required `workspace_id` to `snippets.list()` and `templateFolders.list()` (the API returns nothing without it), and a `listAll()` method on workspaces, document templates, snippets and template folders that fetches every item in a single `page=all` request.
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# pdfmonkey

## Unreleased

- `snippets.list()` and `templateFolders.list()` now require `workspace_id`; the API returns nothing without it.
- Adding `listAll()` to workspaces, document templates, snippets and template folders to fetch every item in a single `page=all` request.

## 1.0.0

### Major Changes

- a0a2381: Initial public release of the PDFMonkey Node.js SDK — a zero-dependency, dual ESM + CommonJS client for the PDFMonkey API. Covers documents (create/get/update/delete, synchronous generation, polling, and PDF download/stream), document cards, templates, template folders, snippets, PDF engines, rest hooks, workspaces, and the current user. Includes paginated list helpers, a typed error hierarchy, request/response/error hooks, configurable retries with backoff, and Web Crypto webhook signature verification for Node 20+ and edge runtimes.

## Unreleased

- Initial release.
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ if (page.hasNextPage()) {
}
```

Workspaces, document templates, snippets and template folders also expose `listAll()`, which fetches every item in a single request (`page=all`) and returns a plain array. Document cards do not support it, so iterate with `for await (const card of page)` instead.

```ts
const workspaces = await client.workspaces.listAll();
const templates = await client.documentTemplates.listAll({ workspace_id: 'ws_xxx' });
const snippets = await client.snippets.listAll({ workspace_id: 'ws_xxx' });
const folders = await client.templateFolders.listAll({ workspace_id: 'ws_xxx' });
```

### Webhooks

Register webhook endpoints:
Expand Down Expand Up @@ -273,11 +282,11 @@ export async function POST(request: Request): Promise<Response> {

```ts
// Snippets
const snippets = await client.snippets.list();
const snippets = await client.snippets.list({ workspace_id: 'ws_xxx' });
await client.snippets.create({ identifier: 'header', code: '<div>Header</div>', workspace_id: 'ws_xxx' });

// Template Folders
const folders = await client.templateFolders.list();
const folders = await client.templateFolders.list({ workspace_id: 'ws_xxx' });

// Workspaces
const workspaces = await client.workspaces.list();
Expand Down
13 changes: 11 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ const engines = await client.pdfEngines.list();
### Template Folders

```ts
const page = await client.templateFolders.list();
const page = await client.templateFolders.list({ workspace_id: 'ws_xxx' });
const folder = await client.templateFolders.get('fold_xxx');
const created = await client.templateFolders.create({ identifier: 'invoices' });
const updated = await client.templateFolders.update('fold_xxx', { identifier: 'reports' });
Expand All @@ -171,7 +171,7 @@ await client.templateFolders.delete('fold_xxx');
Reusable HTML partials shared across templates.

```ts
const page = await client.snippets.list();
const page = await client.snippets.list({ workspace_id: 'ws_xxx' });
const snippet = await client.snippets.get('snp_xxx');
const created = await client.snippets.create({
identifier: 'header',
Expand Down Expand Up @@ -247,6 +247,15 @@ const next = await page.getNextPage(); // throws if no next page
const prev = await page.getPreviousPage(); // throws if no previous page
```

`listAll()` on workspaces, documentTemplates, snippets and templateFolders fetches everything in one request (`page=all`) and returns `T[]`. Not available on documentCards.

```ts
const workspaces = await client.workspaces.listAll();
const templates = await client.documentTemplates.listAll({ workspace_id: 'ws_xxx' });
const snippets = await client.snippets.listAll({ workspace_id: 'ws_xxx' });
const folders = await client.templateFolders.listAll({ workspace_id: 'ws_xxx' });
```

## Error Handling

All errors extend `PDFMonkeyError`.
Expand Down
57 changes: 51 additions & 6 deletions src/__tests__/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,15 +485,15 @@ describe('TemplateFolders', () => {
},
]);

await client.templateFolders.list({ page: 2 });
await client.templateFolders.list({ workspace_id: 'ws_1', page: 2 });

const [url] = fetch.mock.calls[0] as [string];
const parsed = new URL(url);
expect(parsed.searchParams.get('page[number]')).toBe('2');
});

it('lists folders', async () => {
const { client } = createClient([
const { client, fetch } = createClient([
{
status: 200,
body: {
Expand All @@ -503,8 +503,22 @@ describe('TemplateFolders', () => {
},
]);

const page = await client.templateFolders.list();
const page = await client.templateFolders.list({ workspace_id: 'ws_1' });
expect(page.data).toHaveLength(1);
const parsed = new URL(fetch.mock.calls[0]?.[0] as string);
expect(parsed.searchParams.get('q[workspace_id]')).toBe('ws_1');
});

it('lists all folders in a single request', async () => {
const { client, fetch } = createClient([
{ status: 200, body: { template_folders: [{ id: 'fold_1' }, { id: 'fold_2' }] } },
]);

const folders = await client.templateFolders.listAll({ workspace_id: 'ws_1' });
expect(folders).toHaveLength(2);
const parsed = new URL(fetch.mock.calls[0]?.[0] as string);
expect(parsed.searchParams.get('page')).toBe('all');
expect(parsed.searchParams.get('q[workspace_id]')).toBe('ws_1');
});

it('gets a folder by ID', async () => {
Expand Down Expand Up @@ -588,6 +602,16 @@ describe('Workspaces', () => {
const ws = await client.workspaces.get('ws_1');
expect(ws.identifier).toBe('main');
});

it('lists all workspaces in a single request', async () => {
const { client, fetch } = createClient([
{ status: 200, body: { workspaces: [{ id: 'ws_1' }, { id: 'ws_2' }] } },
]);

const workspaces = await client.workspaces.listAll();
expect(workspaces).toHaveLength(2);
expect(new URL(fetch.mock.calls[0]?.[0] as string).searchParams.get('page')).toBe('all');
});
});

// ── Snippets ───────────────────────────────────────────────────────────────
Expand All @@ -609,7 +633,7 @@ describe('Snippets', () => {
});

it('lists snippets', async () => {
const { client } = createClient([
const { client, fetch } = createClient([
{
status: 200,
body: {
Expand All @@ -619,8 +643,29 @@ describe('Snippets', () => {
},
]);

const page = await client.snippets.list();
const page = await client.snippets.list({ workspace_id: 'ws_1' });
expect(page.data).toHaveLength(1);
const parsed = new URL(fetch.mock.calls[0]?.[0] as string);
expect(parsed.searchParams.get('q[workspace_id]')).toBe('ws_1');
});

it('lists all snippets in a single request', async () => {
const { client, fetch } = createClient([
{ status: 200, body: { snippets: [{ id: 'snp_1' }, { id: 'snp_2' }] } },
]);

const snippets = await client.snippets.listAll({ workspace_id: 'ws_1' });
expect(snippets).toHaveLength(2);
const parsed = new URL(fetch.mock.calls[0]?.[0] as string);
expect(parsed.searchParams.get('page')).toBe('all');
expect(parsed.searchParams.get('q[workspace_id]')).toBe('ws_1');
});

it('rejects a malformed listAll response', async () => {
const { client } = createClient([{ status: 200, body: { snippet: {} } }]);
await expect(client.snippets.listAll({ workspace_id: 'ws_1' })).rejects.toThrow(
'expected "snippets" to be an array',
);
});

it('lists snippets with page param', async () => {
Expand All @@ -634,7 +679,7 @@ describe('Snippets', () => {
},
]);

await client.snippets.list({ page: 2 });
await client.snippets.list({ workspace_id: 'ws_1', page: 2 });

const [url] = fetch.mock.calls[0] as [string];
const parsed = new URL(url);
Expand Down
21 changes: 21 additions & 0 deletions src/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,24 @@ export async function fetchPage<T>(
}
return new Page<T>(client, path, opts?.query ?? {}, data as T[], response.meta, extractKey);
}

/**
* Fetch every item of a list endpoint in a single request (`page=all`).
* Only endpoints backed by the generic index support it; document cards do not.
*/
export async function fetchAll<T>(
client: PDFMonkey,
path: string,
extractKey: string,
opts?: RequestOptions,
): Promise<T[]> {
const response = await client.get<Record<string, unknown>>(path, {
...opts,
query: { ...opts?.query, page: 'all' },
});
const data = response[extractKey];
if (!Array.isArray(data)) {
throw new PDFMonkeyError(`Invalid list response: expected "${extractKey}" to be an array`);
}
return data as T[];
}
19 changes: 18 additions & 1 deletion src/resources/document-templates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ResourceRequestOptions } from '../client.js';
import { buildListQuery, fetchPage, type Page } from '../pagination.js';
import { buildListQuery, fetchAll, fetchPage, type Page } from '../pagination.js';
import { APIResource } from '../resource.js';

// ── Types ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -121,6 +121,23 @@ export class DocumentTemplates extends APIResource {
);
}

/** List all document template cards in a single request. */
async listAll(
params?: Omit<DocumentTemplateListParams, 'page'>,
options?: ResourceRequestOptions,
): Promise<DocumentTemplateCard[]> {
const query = buildListQuery(
{ workspace_id: params?.workspace_id, folders: params?.folders },
{ sort: params?.sort },
);
return fetchAll<DocumentTemplateCard>(
this._client,
'/document_template_cards',
'document_template_cards',
{ ...options, query },
);
}

/** Retrieve a document template by ID. */
async get(id: string, options?: ResourceRequestOptions): Promise<DocumentTemplate> {
const response = await this._client.get<DocumentTemplateResponse>(
Expand Down
19 changes: 15 additions & 4 deletions src/resources/snippets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ResourceRequestOptions } from '../client.js';
import { buildListQuery, fetchPage, type Page } from '../pagination.js';
import { buildListQuery, fetchAll, fetchPage, type Page } from '../pagination.js';
import { APIResource } from '../resource.js';

// ── Types ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -27,6 +27,8 @@ export interface SnippetUpdateParams {
}

export interface SnippetListParams {
/** Required: the API returns no snippets without a workspace filter. */
workspace_id: string;
page?: number;
}

Expand All @@ -38,12 +40,21 @@ interface SnippetResponse {

/** Manage reusable HTML snippets shared across templates. */
export class Snippets extends APIResource {
/** List snippets. Returns a paginated result. */
async list(params?: SnippetListParams, options?: ResourceRequestOptions): Promise<Page<Snippet>> {
const query = buildListQuery({}, { page: params?.page });
/** List a workspace's snippets. Returns a paginated result. */
async list(params: SnippetListParams, options?: ResourceRequestOptions): Promise<Page<Snippet>> {
const query = buildListQuery({ workspace_id: params.workspace_id }, { page: params.page });
return fetchPage<Snippet>(this._client, '/snippets', 'snippets', { ...options, query });
}

/** List all of a workspace's snippets in a single request. */
async listAll(
params: Omit<SnippetListParams, 'page'>,
options?: ResourceRequestOptions,
): Promise<Snippet[]> {
const query = buildListQuery({ workspace_id: params.workspace_id });
return fetchAll<Snippet>(this._client, '/snippets', 'snippets', { ...options, query });
}

/** Retrieve a snippet by ID. */
async get(id: string, options?: ResourceRequestOptions): Promise<Snippet> {
const response = await this._client.get<SnippetResponse>(
Expand Down
22 changes: 18 additions & 4 deletions src/resources/template-folders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ResourceRequestOptions } from '../client.js';
import { buildListQuery, fetchPage, type Page } from '../pagination.js';
import { buildListQuery, fetchAll, fetchPage, type Page } from '../pagination.js';
import { APIResource } from '../resource.js';

// ── Types ──────────────────────────────────────────────────────────────────
Expand All @@ -21,6 +21,8 @@ export interface TemplateFolderUpdateParams {
}

export interface TemplateFolderListParams {
/** Required: the API returns no folders without a workspace filter. */
workspace_id: string;
page?: number;
}

Expand All @@ -32,18 +34,30 @@ interface TemplateFolderResponse {

/** Manage folders for organizing document templates. */
export class TemplateFolders extends APIResource {
/** List template folders. Returns a paginated result. */
/** List a workspace's template folders. Returns a paginated result. */
async list(
params?: TemplateFolderListParams,
params: TemplateFolderListParams,
options?: ResourceRequestOptions,
): Promise<Page<TemplateFolder>> {
const query = buildListQuery({}, { page: params?.page });
const query = buildListQuery({ workspace_id: params.workspace_id }, { page: params.page });
return fetchPage<TemplateFolder>(this._client, '/template_folders', 'template_folders', {
...options,
query,
});
}

/** List all of a workspace's template folders in a single request. */
async listAll(
params: Omit<TemplateFolderListParams, 'page'>,
options?: ResourceRequestOptions,
): Promise<TemplateFolder[]> {
const query = buildListQuery({ workspace_id: params.workspace_id });
return fetchAll<TemplateFolder>(this._client, '/template_folders', 'template_folders', {
...options,
query,
});
}

/** Retrieve a template folder by ID. */
async get(id: string, options?: ResourceRequestOptions): Promise<TemplateFolder> {
const response = await this._client.get<TemplateFolderResponse>(
Expand Down
7 changes: 6 additions & 1 deletion src/resources/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ResourceRequestOptions } from '../client.js';
import { buildListQuery, fetchPage, type Page } from '../pagination.js';
import { buildListQuery, fetchAll, fetchPage, type Page } from '../pagination.js';
import { APIResource } from '../resource.js';

// ── Types ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -29,6 +29,11 @@ export class Workspaces extends APIResource {
return fetchPage<Workspace>(this._client, '/workspaces', 'workspaces', { ...options, query });
}

/** List all workspaces in a single request. */
async listAll(options?: ResourceRequestOptions): Promise<Workspace[]> {
return fetchAll<Workspace>(this._client, '/workspaces', 'workspaces', options);
}

/** Retrieve a workspace by ID. */
async get(id: string, options?: ResourceRequestOptions): Promise<Workspace> {
const response = await this._client.get<{ workspace: Workspace }>(
Expand Down
Loading