-
Notifications
You must be signed in to change notification settings - Fork 382
feat: PDFMonkey integration with 12 REST API operations #1024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
20ad5c7
feat: PDFMonkey integration
ASP-31 cf7259d
Update packages/pdfmonkey/client.ts
ASP-31 7623852
feat(pdfmonkey): PDFMonkey integration with 18 REST API operations an…
ASP-31 297f7cc
feat(pdfmonkey): fix webhook signature verification with Svix HMAC
ASP-31 ac6796a
chore: update lockfile after dependency sync
ASP-31 8edc3f3
fix(pdfmonkey): rethrow ApiError so 429 retries apply
ambikeesshh 38cf6a5
fix(pdfmonkey): send nested list queries and unwrap API envelopes
ambikeesshh f32fa74
fix(pdfmonkey): verify Svix HMAC and drop generator leftovers
ambikeesshh e9d29ff
test(pdfmonkey): cover handlers, 429 routing, and Svix signatures
ambikeesshh cebaeff
fix(pdfmonkey): reject empty Svix keys and use AuthMissingError
ambikeesshh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; | ||
| import { ApiError, request } from 'corsair/http'; | ||
|
|
||
| export class Api2PdfAPIError extends Error { | ||
| public readonly status?: number; | ||
| public readonly statusText?: string; | ||
| // API error bodies vary by endpoint; unknown forces callers to narrow before use. | ||
| public readonly body?: unknown; | ||
| public readonly retryAfter?: number; | ||
|
|
||
| constructor( | ||
| message: string, | ||
| public readonly code?: number, | ||
| options?: { cause?: Error }, | ||
| ) { | ||
| super(message, options); | ||
| this.name = 'Api2PdfAPIError'; | ||
|
|
||
| if (options?.cause instanceof ApiError) { | ||
| this.status = options.cause.status; | ||
| this.statusText = options.cause.statusText; | ||
| this.body = options.cause.body; | ||
| this.retryAfter = options.cause.retryAfter; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const API2PDF_API_BASE = 'https://api.pdfmonkey.io'; | ||
|
|
||
| export type PdfMonkeyRequestOptions = { | ||
| apiKey?: string; | ||
| method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; | ||
| // Endpoint payloads differ per operation; Record keeps the client generic. | ||
| body?: Record<string, unknown>; | ||
| query?: Record<string, string | number | boolean | undefined>; | ||
| }; | ||
|
|
||
| function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { | ||
| return { | ||
| BASE: API2PDF_API_BASE, | ||
| VERSION: '2.0.0', | ||
| WITH_CREDENTIALS: false, | ||
| CREDENTIALS: 'omit', | ||
| TOKEN: undefined, | ||
| HEADERS: { | ||
| ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), | ||
| ...(isWrite ? { 'Content-Type': 'application/json' } : {}), | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| // Catch values are untyped at runtime; unknown forces narrowing to ApiError/Error | ||
| // before rethrowing as Api2PdfAPIError. | ||
| async function handleRequestError(error: unknown): Promise<never> { | ||
| if (error instanceof Api2PdfAPIError) { | ||
| throw error; | ||
| } | ||
| if (error instanceof ApiError) { | ||
| throw new Api2PdfAPIError(error.message, error.status, { | ||
| cause: error, | ||
| }); | ||
| } | ||
| if (error instanceof Error) { | ||
| throw new Api2PdfAPIError(error.message, undefined, { cause: error }); | ||
| } | ||
| throw new Api2PdfAPIError('Unknown error'); | ||
| } | ||
|
|
||
| /** | ||
| * Performs a request to the PDFMonkey REST API. | ||
| * | ||
| * Auth: API key via the `Authorization` header using `Bearer <secret_key>`. | ||
| * The `/status` health check does not require authentication. | ||
| */ | ||
| export async function makePdfMonkeyRequest<T>( | ||
| endpoint: string, | ||
| options: PdfMonkeyRequestOptions = {}, | ||
| ): Promise<T> { | ||
| const { apiKey, method = 'GET', body, query = {} } = options; | ||
| const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH'; | ||
|
|
||
| const config = buildConfig(apiKey, isWrite); | ||
|
|
||
| const requestOptions: ApiRequestOptions = { | ||
| method, | ||
| url: endpoint, | ||
| body: isWrite ? body : undefined, | ||
| mediaType: isWrite ? 'application/json; charset=utf-8' : undefined, | ||
| query, | ||
| }; | ||
|
|
||
| try { | ||
| return await request<T>(config, requestOptions); | ||
| } catch (error) { | ||
| return handleRequestError(error); | ||
| } | ||
| } | ||
|
|
||
| /** Plain-text health check (returns e.g. "OK"). */ | ||
| export async function makePdfMonkeyTextRequest( | ||
| endpoint: string, | ||
| options: Pick<PdfMonkeyRequestOptions, 'apiKey' | 'method' | 'query'> = {}, | ||
| ): Promise<string> { | ||
| const { apiKey, method = 'GET', query = {} } = options; | ||
| const config = buildConfig(apiKey); | ||
|
|
||
| const requestOptions: ApiRequestOptions = { | ||
| method, | ||
| url: endpoint, | ||
| query, | ||
| }; | ||
|
|
||
| try { | ||
| const response = await request<string>(config, requestOptions); | ||
| return typeof response === 'string' ? response : String(response); | ||
| } catch (error) { | ||
| return handleRequestError(error); | ||
| } | ||
| } | ||
|
|
||
| export function assertApi2PdfSuccess< | ||
| // Error field shape varies by endpoint (string | object | null); unknown forces | ||
| // callers to narrow before reading it. | ||
| T extends { Success?: boolean; Error?: unknown }, | ||
| >(response: T): T { | ||
| if (response.Success === false) { | ||
| const message = | ||
| typeof response.Error === 'string' | ||
| ? response.Error | ||
| : 'API2PDF request failed'; | ||
| throw new Api2PdfAPIError(message); | ||
| } | ||
| return response; | ||
| } | ||
|
|
||
| // Endpoint payloads differ per operation; Record keeps the client generic across | ||
| // chrome/pdfsharp/libreoffice field sets without a union of every wire shape. | ||
| export function buildPostPayload( | ||
| fields: Record<string, unknown>, | ||
| options?: { | ||
| inline?: boolean; | ||
| fileName?: string; | ||
| // Headless Chrome options bag is open-ended upstream. | ||
| chromeOptions?: Record<string, unknown>; | ||
| }, | ||
| ): Record<string, unknown> { | ||
| const payload: Record<string, unknown> = { | ||
| inline: options?.inline ?? true, | ||
| ...fields, | ||
| }; | ||
|
|
||
| if (options?.fileName) { | ||
| payload.fileName = options.fileName; | ||
| } | ||
|
|
||
| if (options?.chromeOptions) { | ||
| payload.options = options.chromeOptions; | ||
| } | ||
|
|
||
| return payload; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import { logEventFromContext } from 'corsair/core'; | ||
| import { makePdfMonkeyRequest } from '../client'; | ||
| import type { PDFMonkeyEndpoints } from '../index'; | ||
| import type { | ||
| PDFMonkeyEndpointInputs, | ||
| PDFMonkeyEndpointOutputs, | ||
| } from './types'; | ||
|
|
||
| /** Create a document (async, queues for generation) */ | ||
| export const createDocument: PDFMonkeyEndpoints['createDocument'] = async ( | ||
| ctx, | ||
| input, | ||
| ) => { | ||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['createDocument'] | ||
| >('/api/v1/documents', { | ||
| apiKey: ctx.key, | ||
| method: 'POST', | ||
| body: { | ||
| document: { | ||
| document_template_id: input.document.document_template_id, | ||
| status: input.document.status, | ||
| payload: input.document.payload, | ||
| meta: input.document.meta, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.createDocument', | ||
| { | ||
| document_template_id: input.document.document_template_id, | ||
| status: input.document.status, | ||
| }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| /** Create a document synchronously (waits for generation to complete) */ | ||
| export const createDocumentSync: PDFMonkeyEndpoints['createDocumentSync'] = | ||
| async (ctx, input) => { | ||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['createDocumentSync'] | ||
| >('/api/v1/documents/sync', { | ||
| apiKey: ctx.key, | ||
| method: 'POST', | ||
| body: { | ||
| document: { | ||
| document_template_id: input.document.document_template_id, | ||
| status: input.document.status, | ||
| payload: input.document.payload, | ||
| meta: input.document.meta, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.createDocumentSync', | ||
| { | ||
| document_template_id: input.document.document_template_id, | ||
| status: input.document.status, | ||
| }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| /** Get a document card (status + download URL) */ | ||
| export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async ( | ||
| ctx, | ||
| input, | ||
| ) => { | ||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['getDocumentCard'] | ||
| >('/api/v1/document_cards/' + input.id, { | ||
| apiKey: ctx.key, | ||
| method: 'GET', | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.getDocumentCard', | ||
| { id: input.id }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| /** List document cards (paginated with filters) */ | ||
| export const listDocumentCards: PDFMonkeyEndpoints['listDocumentCards'] = | ||
| async (ctx, input) => { | ||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['listDocumentCards'] | ||
| >('/api/v1/document_cards', { | ||
| apiKey: ctx.key, | ||
| method: 'GET', | ||
| query: { | ||
| page: input.page, | ||
| q_document_template_id: input.q_document_template_id, | ||
| q_status: input.q_status, | ||
| q_workspace_id: input.q_workspace_id, | ||
| q_updated_since: input.q_updated_since, | ||
| q_search: input.q_search, | ||
| }, | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.listDocumentCards', | ||
| { | ||
| page: input.page, | ||
| q_status: input.q_status, | ||
| }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| /** Get a full document (with payload and generation logs) */ | ||
| export const getDocument: PDFMonkeyEndpoints['getDocument'] = async ( | ||
| ctx, | ||
| input, | ||
| ) => { | ||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['getDocument'] | ||
| >('/api/v1/documents/' + input.id, { | ||
| apiKey: ctx.key, | ||
| method: 'GET', | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.getDocument', | ||
| { id: input.id }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| /** Update a document */ | ||
| export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async ( | ||
| ctx, | ||
| input, | ||
| ) => { | ||
| const document = input.document; | ||
| if (!document) { | ||
| throw new Error('document is required for update'); | ||
| } | ||
| const body: Record<string, unknown> = {}; | ||
| if (document.document_template_id !== undefined) | ||
| body.document_template_id = document.document_template_id; | ||
| if (document.status !== undefined) body.status = document.status; | ||
| if (document.payload !== undefined) body.payload = document.payload; | ||
| if (document.meta !== undefined) body.meta = document.meta; | ||
|
|
||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['updateDocument'] | ||
| >('/api/v1/documents/' + input.document_id, { | ||
| apiKey: ctx.key, | ||
| method: 'PUT', | ||
| body: { | ||
| document: body, | ||
| }, | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.updateDocument', | ||
| { document_id: input.document_id }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| /** Delete a document */ | ||
| export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async ( | ||
| ctx, | ||
| input, | ||
| ) => { | ||
| const response = await makePdfMonkeyRequest< | ||
| PDFMonkeyEndpointOutputs['deleteDocument'] | ||
| >('/api/v1/documents/' + input.id, { | ||
| apiKey: ctx.key, | ||
| method: 'DELETE', | ||
| }); | ||
|
|
||
| await logEventFromContext( | ||
| ctx, | ||
| 'pdfmonkey.documents.deleteDocument', | ||
| { id: input.id }, | ||
| 'completed', | ||
| ); | ||
|
|
||
| return response; | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.