From 228996d5d3548428507941d92875e7b090eccfb8 Mon Sep 17 00:00:00 2001 From: Matthew Heroux Date: Sun, 22 Oct 2023 23:55:34 -0500 Subject: [PATCH] feat: html-to-pdf pragmatic api (#446) Signed-off-by: hxtree --- services/html-to-pdf/README.md | 32 +++- services/html-to-pdf/example.png | Bin 0 -> 33607 bytes .../src/module/pdf/create-html-to-pdf.dto.ts | 20 --- .../src/module/pdf/create-url-to-pdf.dto.ts | 20 --- .../src/module/pdf/operation.dto.ts | 65 +++++++ .../src/module/pdf/pdf.controller.ts | 129 ++++++++------ .../src/module/pdf/pdf.e2e-spec.ts | 160 ++++++++++++++++-- .../html-to-pdf/src/module/pdf/pdf.service.ts | 120 ++++++------- .../src/module/pdf/url-to-data.dto.ts | 12 -- services/html-to-pdf/stacks/main-stack.ts | 2 +- 10 files changed, 366 insertions(+), 194 deletions(-) create mode 100644 services/html-to-pdf/example.png delete mode 100644 services/html-to-pdf/src/module/pdf/create-html-to-pdf.dto.ts delete mode 100644 services/html-to-pdf/src/module/pdf/create-url-to-pdf.dto.ts create mode 100644 services/html-to-pdf/src/module/pdf/operation.dto.ts delete mode 100644 services/html-to-pdf/src/module/pdf/url-to-data.dto.ts diff --git a/services/html-to-pdf/README.md b/services/html-to-pdf/README.md index 4631e915..9ec2b112 100644 --- a/services/html-to-pdf/README.md +++ b/services/html-to-pdf/README.md @@ -1,19 +1,33 @@ # @org-apis/html-to-pdf -HTMLtoPDF is a service for generating PDFs from HTML. It support generating a -PDF from either a request containing a HTML body or a URL. +HTMLtoPDF is a turn-key microservice that generates PDFs from HTML. It support +both URL and HTML based requests. + +## Usage Example + +```bash +curl -X POST https://nx7uv2rfy4.execute-api.us-east-2.amazonaws.com/default/v1/html-to-pdf/pdf -H "Content-Type: application/json" -d '{"input": "URL", "output": "PDF", "url": "https://google.com"}' -o example.pdf +``` + +![Example Image](./example.png) ## How it Works -PDF are generated using a headless version of Chromium. This form of PDF -rendering supports text recognition, images, hyperlinks, print media queries, -table breaks, and other features all this with relatively little code +PDF are generated using a headless version of Chromium running in a lambda. This +form of PDF rendering supports text recognition, images, hyperlinks, print media +queries, table breaks, and other features all this with relatively little code maintenance. -It can be finicky to get working within a Lambda and API Gateway. Lambda doesn't -include fonts fonts. Chromium should be installed in a Lambda layer. API gateway -can cause blank PDF if it doesn't properly handle binary responses. Serverless -Express also has to be configured to support the binary mime type. +This service address many finicky obstacles with making a request through API +gateway to a Lambda running Chromium to generate a PDF. + +- NodeJS Lambda Layers do include default fonts files (\*.tff) like a standard + OS does. +- Performance is essential and Lambda deploys are small. A compressed version of + Chromium must be deployed independently as Lambda layer and for performance. +- API gateway if not properly configured to handle binary responses can cause a + blank PDF. +- Serverless Express has to be configured to support the binary mime type. ## References diff --git a/services/html-to-pdf/example.png b/services/html-to-pdf/example.png new file mode 100644 index 0000000000000000000000000000000000000000..00bbe71dc3ab5fcc91d571d3f66d598bf088ae97 GIT binary patch literal 33607 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 literal 0 HcmV?d00001 diff --git a/services/html-to-pdf/src/module/pdf/create-html-to-pdf.dto.ts b/services/html-to-pdf/src/module/pdf/create-html-to-pdf.dto.ts deleted file mode 100644 index 46ce90ab..00000000 --- a/services/html-to-pdf/src/module/pdf/create-html-to-pdf.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsFilename, IsString } from '@cats-cradle/validation-schemas'; - -export class CreateHtmlToPdfDto { - @IsString() - @ApiProperty({ - description: 'HTML', - default: 'Hello, World', - type: String, - }) - public html: string; - - @IsFilename() - @ApiProperty({ - description: 'Filename', - default: 'report.pdf', - type: String, - }) - public filename: string; -} diff --git a/services/html-to-pdf/src/module/pdf/create-url-to-pdf.dto.ts b/services/html-to-pdf/src/module/pdf/create-url-to-pdf.dto.ts deleted file mode 100644 index cc7a68c1..00000000 --- a/services/html-to-pdf/src/module/pdf/create-url-to-pdf.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsFilename, IsUrl } from '@cats-cradle/validation-schemas'; - -export class CreateUrlToPdfDto { - @IsUrl() - @ApiProperty({ - description: 'url', - default: 'https://google.com', - type: String, - }) - public url: string; - - @IsFilename() - @ApiProperty({ - description: 'Filename', - default: 'report.pdf', - type: String, - }) - public filename: string; -} diff --git a/services/html-to-pdf/src/module/pdf/operation.dto.ts b/services/html-to-pdf/src/module/pdf/operation.dto.ts new file mode 100644 index 00000000..d7b67804 --- /dev/null +++ b/services/html-to-pdf/src/module/pdf/operation.dto.ts @@ -0,0 +1,65 @@ +import { + IsUrl, + IsEnum, + IsString, + IsOptional, + IsFilename, +} from '@cats-cradle/validation-schemas'; +import { ApiProperty } from '@nestjs/swagger'; +import { v4 } from 'uuid'; + +export enum OperationInput { + HTML = 'HTML', + URL = 'URL', +} + +export enum OperationOutput { + DATA = 'DATA', + PDF = 'PDF', + JSON = 'JSON', +} + +export class OperationDto { + @IsEnum(OperationInput) + @ApiProperty({ + description: 'input format', + default: OperationInput.HTML, + enum: OperationOutput, + }) + input: OperationInput; + + @IsEnum(OperationOutput) + @ApiProperty({ + description: 'output format', + default: OperationOutput.PDF, + enum: OperationOutput, + }) + output: OperationOutput; + + @IsOptional() + @IsUrl() + @ApiProperty({ + description: 'url', + default: 'https://google.com', + type: String, + }) + url?: string; + + @IsOptional() + @IsString() + @ApiProperty({ + description: 'HTML', + default: + 'ExampleHello, World', + type: String, + }) + content?: string; + + @IsFilename() + @ApiProperty({ + description: 'Filename', + default: `${v4()}.pdf`, + type: String, + }) + filename?: string; +} diff --git a/services/html-to-pdf/src/module/pdf/pdf.controller.ts b/services/html-to-pdf/src/module/pdf/pdf.controller.ts index e5394ab0..70f4e356 100644 --- a/services/html-to-pdf/src/module/pdf/pdf.controller.ts +++ b/services/html-to-pdf/src/module/pdf/pdf.controller.ts @@ -6,73 +6,94 @@ import { Get, VERSION_NEUTRAL, Query, + BadRequestException, + StreamableFile, } from '@nestjs/common'; import { Response } from 'express'; import { v4 } from 'uuid'; import { PdfService } from './pdf.service'; -import { CreateHtmlToPdfDto } from './create-html-to-pdf.dto'; -import { CreateUrlToPdfDto } from './create-url-to-pdf.dto'; +import { OperationDto, OperationInput, OperationOutput } from './operation.dto'; @Controller({ path: 'pdf', version: ['1', VERSION_NEUTRAL] }) export class PdfController { constructor(private readonly pdfService: PdfService) {} - @Get('example-data') - async exampleData(@Res() res: Response) { - const data = await this.pdfService.renderPageData( - 'Demo Page

Demo

', - ); - - res.status(200).send(data); - } - - @Get('example-pdf') - async test(@Res() res: Response, @Query('url') url?: string) { - const buffer = await this.pdfService.renderUrl(url ?? 'http://example.com'); - this.responseAsPdf(false, buffer, res, `${v4()}.pdf`); - } - - @Post('render-url') - async renderUrl(@Res() res: Response, @Body() body: CreateUrlToPdfDto) { - const buffer = await this.pdfService.renderUrl(body.url); - this.responseAsPdf(false, buffer, res, body.filename); + @Get() + async url( + @Res({ passthrough: true }) res: Response, + @Query('url') url?: string, + ) { + const buffer = await this.pdfService.urlToPdf(url ?? 'http://example.com'); + const filename = `${v4()}.pdf`; + res.setHeader('Content-Length', Buffer.byteLength(buffer, 'utf-8')); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename=${filename}`); + return new StreamableFile(this.pdfService.createReadableStream(buffer)); } - @Post('render-html-data') - async renderHtmlData(@Res() res: Response, @Body() body: CreateHtmlToPdfDto) { - return this.pdfService.renderPageData(body.html); - } + @Post() + async operation( + @Res({ passthrough: true }) res: Response, + @Body() body: OperationDto, + ): Promise { + let buffer; + const filename = body.filename ?? `${v4()}.pdf`; - @Post('render-html') - async renderHtml(@Res() res: Response, @Body() body: CreateHtmlToPdfDto) { - const buffer = await this.pdfService.renderHtml(body.html); - this.responseAsPdf(false, buffer, res, body.filename); - } - - private responseAsPdf( - json: boolean, - buffer: Buffer, - res: Response, - filename: string, - ) { - if (!json) { - const stream = this.pdfService.createReadableStream(buffer); + try { + switch (true) { + case body.input === OperationInput.HTML + && body.output === OperationOutput.DATA: + return await this.pdfService.htmlToData(body.content ?? ''); + case body.input === OperationInput.HTML + && body.output === OperationOutput.JSON: + buffer = await this.pdfService.htmlToPdf(body.content ?? ''); + return { + content: buffer.toString('base64'), + filename: body.filename ?? `${v4()}.pdf`, + mimeType: 'application/pdf', + }; + case body.input === OperationInput.HTML + && body.output === OperationOutput.PDF: + buffer = await this.pdfService.htmlToPdf(body.content ?? ''); + res.setHeader('Content-Length', Buffer.byteLength(buffer, 'utf-8')); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename=${filename}`, + ); + return new StreamableFile( + this.pdfService.createReadableStream(buffer), + ); - res.setHeader('Content-Length', Buffer.byteLength(buffer, 'utf-8')); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename=${filename}`); - stream.pipe(res); - } else { - /** - * base64 can be responses can be checked using the following - * https://base64.guru/converter/decode/pdf - */ - res.setHeader('Content-Type', 'application/json;charset=UTF-8'); - res.status(200).send({ - content: buffer.toString('base64'), - filename, - mimeType: 'application/pdf', - }); + case body.input === OperationInput.URL + && body.output === OperationOutput.DATA: + return await this.pdfService.urlToData(body.url ?? ''); + case body.input === OperationInput.URL + && body.output === OperationOutput.JSON: + buffer = await this.pdfService.urlToPdf(body.url ?? ''); + return { + content: buffer.toString('base64'), + filename: body.filename ?? `${v4()}.pdf`, + mimeType: 'application/pdf', + }; + case body.input === OperationInput.URL + && body.output === OperationOutput.PDF: + buffer = await this.pdfService.urlToPdf(body.url ?? ''); + res.setHeader('Content-Length', Buffer.byteLength(buffer, 'utf-8')); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename=${filename}`, + ); + return new StreamableFile( + this.pdfService.createReadableStream(buffer), + ); + default: + return new BadRequestException('Invalid request'); + } + } catch (err) { + const error = err as Error; + return new BadRequestException(`Failed to render pdf: ${error.message}`); } } } diff --git a/services/html-to-pdf/src/module/pdf/pdf.e2e-spec.ts b/services/html-to-pdf/src/module/pdf/pdf.e2e-spec.ts index 1a3f3bf4..1f8c3369 100644 --- a/services/html-to-pdf/src/module/pdf/pdf.e2e-spec.ts +++ b/services/html-to-pdf/src/module/pdf/pdf.e2e-spec.ts @@ -1,22 +1,26 @@ import supertest from 'supertest'; import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, Injectable } from '@nestjs/common'; import { FakerFactory } from '@cats-cradle/faker-factory'; -import { PdfModule } from './pdf.module'; -import { UrlToDataDto } from './url-to-data.dto'; +import { OperationInput, OperationOutput } from './operation.dto'; +import { PdfService } from './pdf.service'; +import { PdfController } from './pdf.controller'; describe('/pdf', () => { let app: INestApplication; + let pdfService: PdfService; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [PdfModule], - providers: [], - controllers: [], + imports: [], + controllers: [PdfController], + providers: [PdfService], }).compile(); app = moduleRef.createNestApplication(); + pdfService = moduleRef.get(PdfService); + await app.init(); }); @@ -24,20 +28,150 @@ describe('/pdf', () => { app.close(); }); - describe('POST /pdf/render-html-data', () => { - it.skip('should render html page', async () => { - const result = await supertest(app.getHttpServer()) - .post('/pdf/render-html-data') + describe('GET /pdf', () => { + it('should render url page to pdf', async () => { + jest.spyOn(pdfService, 'urlToPdf').mockImplementation((url: string) => Promise.resolve(Buffer.from('Test', 'utf-8'))); + + const response = await supertest(app.getHttpServer()).get('/pdf'); + // .expect(200); + + expect(response.header['content-type']).toEqual('application/pdf'); + expect(response.body).toEqual(Buffer.from('Test', 'utf-8')); + }); + }); + + describe('POST /pdf', () => { + it('should render url page to pdf', async () => { + jest.spyOn(pdfService, 'urlToPdf').mockImplementation((url: string) => Promise.resolve(Buffer.from('Test', 'utf-8'))); + + const response = await supertest(app.getHttpServer()) + .post('/pdf') + .send({ + input: OperationInput.URL, + output: OperationOutput.PDF, + url: 'http://example.com', + }) + .expect(201); + + expect(response.header['content-type']).toEqual('application/pdf'); + expect(response.body).toEqual(Buffer.from('Test', 'utf-8')); + }); + + it('should render url page to json', async () => { + jest.spyOn(pdfService, 'urlToPdf').mockImplementation((url: string) => Promise.resolve(Buffer.from('Test', 'utf-8'))); + + const response = await supertest(app.getHttpServer()) + .post('/pdf') + .send({ + input: OperationInput.URL, + output: OperationOutput.JSON, + url: 'http://example.com', + }) + .expect(201); + + expect(response.header['content-type']).toEqual( + 'application/json; charset=utf-8', + ); + expect(response.body).toEqual( + expect.objectContaining({ + content: 'VGVzdA==', + filename: expect.stringContaining('.pdf'), + mimeType: 'application/pdf', + }), + ); + }); + + it('should render url page to data', async () => { + jest.spyOn(pdfService, 'urlToData').mockImplementation((url: string) => Promise.resolve({ + title: 'Example Domain', + })); + + const response = await supertest(app.getHttpServer()) + .post('/pdf') + .send({ + input: OperationInput.URL, + output: OperationOutput.DATA, + url: 'https://example.com', + }) + .expect(201); + + expect(response.header['content-type']).toEqual( + 'application/json; charset=utf-8', + ); + expect(response.body).toEqual( + expect.objectContaining({ + title: 'Example Domain', + }), + ); + }); + + it('should render url page to pdf', async () => { + jest.spyOn(pdfService, 'htmlToPdf').mockImplementation((url: string) => Promise.resolve(Buffer.from('Test', 'utf-8'))); + + const response = await supertest(app.getHttpServer()) + .post('/pdf') + .send({ + input: OperationInput.HTML, + output: OperationOutput.PDF, + content: + 'Example PageExample', + }) + .expect(201); + + expect(response.header['content-type']).toEqual('application/pdf'); + expect(response.body).toEqual(Buffer.from('Test', 'utf-8')); + }); + + it('should render url page to json', async () => { + jest.spyOn(pdfService, 'htmlToPdf').mockImplementation((url: string) => Promise.resolve(Buffer.from('Test', 'utf-8'))); + + const response = await supertest(app.getHttpServer()) + .post('/pdf') .send({ - html: 'Example PageExample', + input: OperationInput.HTML, + output: OperationOutput.JSON, + content: + 'Example PageExample', }) .expect(201); - expect(result.body).toEqual( + expect(response.header['content-type']).toEqual( + 'application/json; charset=utf-8', + ); + expect(response.body).toEqual( + expect.objectContaining({ + content: 'VGVzdA==', + filename: expect.stringContaining('.pdf'), + mimeType: 'application/pdf', + }), + ); + }); + + it('should render html page to data', async () => { + jest + .spyOn(pdfService, 'htmlToData') + .mockImplementation((html: string) => Promise.resolve({ + title: 'Example Page', + })); + + const response = await supertest(app.getHttpServer()) + .post('/pdf') + .send({ + input: OperationInput.HTML, + output: OperationOutput.DATA, + content: + 'Example PageExample', + }) + .expect(201); + + expect(response.header['content-type']).toEqual( + 'application/json; charset=utf-8', + ); + expect(response.body).toEqual( expect.objectContaining({ title: 'Example Page', }), ); - }, 15000); + }); }); }); diff --git a/services/html-to-pdf/src/module/pdf/pdf.service.ts b/services/html-to-pdf/src/module/pdf/pdf.service.ts index 6a4cfc6c..9edb7e84 100644 --- a/services/html-to-pdf/src/module/pdf/pdf.service.ts +++ b/services/html-to-pdf/src/module/pdf/pdf.service.ts @@ -1,74 +1,67 @@ /* eslint @typescript-eslint/no-var-requires: "off" */ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Readable } from 'stream'; - -const puppeteer = require('puppeteer-core'); -const chromium = require('@sparticuz/chromium-min'); +import puppeteer from 'puppeteer-core'; +import chromium from '@sparticuz/chromium-min'; @Injectable() export class PdfService { - async renderHtml(html: string): Promise { - try { - const browser = await this.getBrowser(); - const page = await browser.newPage(); - - await page.setContent(html, { - waitUntil: ['networkidle0', 'domcontentloaded'], - }); - - const buffer = await page.pdf({ format: 'a4', printBackground: true }); - - await browser.close(); - return buffer; - } catch (err) { - const error = err as Error; - return new BadRequestException(`Failed to render pdf: ${error.message}`); - } + async htmlToPdf(html: string): Promise { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + + await page.setContent(html, { + waitUntil: ['networkidle0', 'domcontentloaded'], + }); + + const buffer = await page.pdf({ format: 'a4', printBackground: true }); + + await browser.close(); + return buffer; + } + + async urlToPdf(url: string) { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + await page.goto(url, { waitUntil: ['networkidle2', 'domcontentloaded'] }); + + const buffer = await page.pdf({ + format: 'A4', + landscape: false, + printBackground: true, + margin: { top: '30px' }, + scale: 0.98, + }); + + await browser.close(); + + return buffer; } - async renderUrl(url: string) { - try { - const browser = await this.getBrowser(); - const page = await browser.newPage(); - await page.goto(url, { waitUntil: ['networkidle2', 'domcontentloaded'] }); - - const buffer = await page.pdf({ - format: 'A4', - landscape: false, - printBackground: true, - margin: { top: '30px' }, - scale: 0.98, - }); - - await browser.close(); - - return buffer; - } catch (err) { - const error = err as Error; - return new BadRequestException(`Failed to render pdf: ${error.message}`); - } + async htmlToData(html: string) { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + await page.setContent(html, { + waitUntil: ['networkidle0', 'domcontentloaded'], + }); + const data = { + title: (await page.title()) ?? 'undefined', + }; + + await browser.close(); + return data; } - async renderPageData(html: string) { - try { - const browser = await this.getBrowser(); - const page = await browser.newPage(); - await page.setContent(html, { - waitUntil: ['networkidle0', 'domcontentloaded'], - }); - const data = { - title: (await page.title()) ?? 'undefined', - mimeType: page.mimeType, - filename: page.filename, - charset: page.charset, - }; - - await browser.close(); - return data; - } catch (err) { - const error = err as Error; - return new BadRequestException(`Failed to render pdf: ${error.message}`); - } + async urlToData(url: string) { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + await page.goto(url, { waitUntil: ['networkidle2', 'domcontentloaded'] }); + const data = { + title: (await page.title()) ?? 'undefined', + }; + + await browser.close(); + return data; } private async getBrowser() { @@ -76,9 +69,6 @@ export class PdfService { ? '/opt/nodejs/node_modules/@sparticuz/chromium/bin' : undefined; - chromium.setHeadlessMode = true; - chromium.setGraphicsMode = true; - await chromium.font( 'http://themes.googleusercontent.com/static/fonts/opensans/v6/cJZKeOuBrn4kERxqtaUH3aCWcynf_cDxXwCLxiixG1c.ttf', ); diff --git a/services/html-to-pdf/src/module/pdf/url-to-data.dto.ts b/services/html-to-pdf/src/module/pdf/url-to-data.dto.ts deleted file mode 100644 index c9bebac4..00000000 --- a/services/html-to-pdf/src/module/pdf/url-to-data.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsUrl } from '@cats-cradle/validation-schemas'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UrlToDataDto { - @IsUrl() - @ApiProperty({ - description: 'url', - default: 'https://example.com', - type: String, - }) - public url: string; -} diff --git a/services/html-to-pdf/stacks/main-stack.ts b/services/html-to-pdf/stacks/main-stack.ts index e10bb782..c872a06a 100644 --- a/services/html-to-pdf/stacks/main-stack.ts +++ b/services/html-to-pdf/stacks/main-stack.ts @@ -29,7 +29,7 @@ export class HtmlToPdfStack extends cdk.Stack { }); new cdk.CfnOutput(this, 'test endpoint', { - value: `${microservice.getBaseUrl()}/pdf/example-pdf`, + value: `${microservice.getBaseUrl()}/pdf?url=https://google.com`, }); } }