diff --git a/.fernignore b/.fernignore index 43ca0d2..71f871b 100644 --- a/.fernignore +++ b/.fernignore @@ -1,3 +1,18 @@ # Specify files that shouldn't be modified by Fern README.md + +## Support content type on form data +src/core/form-data-utils +src/Client.ts +src/core/fetcher + +## Change response signature +src/wrapper +src/index.ts + +# Helper +src/generateFromHtml.ts + +## Tests +tests/custom.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c348128..4fd98c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,53 +3,53 @@ name: ci on: [push] jobs: - compile: - runs-on: ubuntu-latest + compile: + runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v3 + steps: + - name: Checkout repo + uses: actions/checkout@v3 - - name: Set up node - uses: actions/setup-node@v3 + - name: Set up node + uses: actions/setup-node@v3 - - name: Compile - run: yarn && yarn build + - name: Compile + run: yarn && yarn build - test: - runs-on: ubuntu-latest + test: + runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v3 + steps: + - name: Checkout repo + uses: actions/checkout@v3 - - name: Set up node - uses: actions/setup-node@v3 + - name: Set up node + uses: actions/setup-node@v3 - - name: Compile - run: yarn && yarn test + - name: Compile + run: yarn && yarn test - publish: - needs: [ compile, test ] - if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') - runs-on: ubuntu-latest + publish: + needs: [compile, test] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v3 + steps: + - name: Checkout repo + uses: actions/checkout@v3 - - name: Set up node - uses: actions/setup-node@v3 + - name: Set up node + uses: actions/setup-node@v3 - - name: Install dependencies - run: yarn install + - name: Install dependencies + run: yarn install - - name: Build - run: yarn build + - name: Build + run: yarn build - - name: Publish to npm - run: | - npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} - npm publish --access public - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + - name: Publish to npm + run: | + npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} + npm publish --access public + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/src/Client.ts b/src/Client.ts index 1bf57cf..aa42773 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -40,7 +40,13 @@ export class FileforgeClient { requestOptions?: FileforgeClient.RequestOptions ): Promise { const _request = new core.FormDataWrapper(); - await _request.append("options", JSON.stringify(request.options)); + const options = await serializers.GenerateRequestOptions.jsonOrThrow(request.options, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: false, + allowUnrecognizedEnumValues: false, + breadcrumbsPrefix: [""], + }); + await _request.append("options", new Blob([JSON.stringify(options)], { type: "application/json" })); for (const _file of files) { await _request.append("files", _file); } @@ -73,24 +79,6 @@ export class FileforgeClient { if (_response.error.reason === "status-code") { switch (_response.error.statusCode) { - case 400: - throw new Fileforge.BadRequestError( - await serializers.ErrorSchema.parseOrThrow(_response.error.body, { - unrecognizedObjectKeys: "passthrough", - allowUnrecognizedUnionMembers: true, - allowUnrecognizedEnumValues: true, - breadcrumbsPrefix: ["response"], - }) - ); - case 401: - throw new Fileforge.UnauthorizedError( - await serializers.ErrorSchema.parseOrThrow(_response.error.body, { - unrecognizedObjectKeys: "passthrough", - allowUnrecognizedUnionMembers: true, - allowUnrecognizedEnumValues: true, - breadcrumbsPrefix: ["response"], - }) - ); case 500: throw new Fileforge.InternalServerError(_response.error.body); case 502: @@ -136,7 +124,13 @@ export class FileforgeClient { requestOptions?: FileforgeClient.RequestOptions ): Promise { const _request = new core.FormDataWrapper(); - await _request.append("options", JSON.stringify(request.options)); + const options = await serializers.MergeRequestOptions.jsonOrThrow(request.options, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: false, + allowUnrecognizedEnumValues: false, + breadcrumbsPrefix: [""], + }); + await _request.append("options", new Blob([JSON.stringify(options)], { type: "application/json" })); for (const _file of files) { await _request.append("files", _file); } diff --git a/src/core/fetcher/Fetcher.ts b/src/core/fetcher/Fetcher.ts index 265126e..fa6a4ed 100644 --- a/src/core/fetcher/Fetcher.ts +++ b/src/core/fetcher/Fetcher.ts @@ -94,14 +94,7 @@ async function fetcherImpl(args: Fetcher.Args): Promise => { const signals: AbortSignal[] = []; diff --git a/src/core/form-data-utils/FormDataWrapper.ts b/src/core/form-data-utils/FormDataWrapper.ts index 4974501..ba3c7e5 100644 --- a/src/core/form-data-utils/FormDataWrapper.ts +++ b/src/core/form-data-utils/FormDataWrapper.ts @@ -6,46 +6,25 @@ interface CrossPlatformFormData { class FormDataRequestBody { private fd: any; - private encoder: any; constructor(fd: any) { this.fd = fd; } - async setup(): Promise { - if (this.encoder == null && RUNTIME.type === "node") { - this.encoder = new (await import("form-data-encoder")).FormDataEncoder(this.fd); - } - } + async setup(): Promise {} /** * @returns the multipart form data request */ public async getBody(): Promise { - if (RUNTIME.type !== "node") { - return this.fd; - } else { - if (this.encoder == null) { - await this.setup(); - } - return (await import("node:stream")).Readable.from(this.encoder); - } + return this.fd; } /** * @returns headers that need to be added to the multipart form data request */ public async getHeaders(): Promise> { - if (RUNTIME.type !== "node") { - return {}; - } else { - if (this.encoder == null) { - await this.setup(); - } - return { - "Content-Length": this.encoder.length, - }; - } + return {}; } } @@ -59,7 +38,7 @@ export class FormDataWrapper { public async append(name: string, value: any): Promise { if (this.fd == null) { if (RUNTIME.type === "node") { - this.fd = new (await import("formdata-node")).FormData(); + this.fd = new FormData(); } else { this.fd = new (await import("form-data")).default(); } diff --git a/src/generateFromHtml.ts b/src/generateFromHtml.ts new file mode 100644 index 0000000..838e398 --- /dev/null +++ b/src/generateFromHtml.ts @@ -0,0 +1,57 @@ +import { Fileforge } from "index"; +import { FileforgeClient, ResponseObject } from "./wrapper/FileforgeClient"; +import mime from "mime-types"; + +export interface Asset { + path: string; + content: string; +} +export interface PathBuffer { + path: string; + content: Buffer; +} + +export type AssetOrPathBuffer = Asset | PathBuffer; + +export interface DocumentInput { + html: string; + fileName?: string; + test?: boolean; + host?: boolean; + expiresAt?: Date; + files?: AssetOrPathBuffer[]; +} + +export async function generateFromHtml(client: FileforgeClient, document: DocumentInput): Promise { + const files: AssetOrPathBuffer[] = document.files ?? []; + files.push({ path: "/index.html", content: document.html }); + + const test: boolean = document.test ?? true; + const save: boolean = document.host ?? false; + + const optionsToUpload: Fileforge.GenerateRequestOptions = { + test: test, + host: save, + expiresAt: document.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000), + fileName: document.fileName ?? "document", + }; + + const htmlBlob = new Blob([document.html], { type: "text/html" }); + const htmlFile = new File([htmlBlob], "index.html", { type: "text/html" }); + + let filesToUpload = [htmlFile]; + + files.forEach((asset) => { + if (asset.content) { + const assetType = mime.lookup(asset.path) || "application/octet-stream"; + + const fileBlob = new Blob([asset.content], { type: assetType }); + const file = new File([fileBlob], asset.path, { type: assetType }); + filesToUpload.push(file); + } + }); + + return await client.generate(filesToUpload, { + options: optionsToUpload, + }); +} diff --git a/src/index.ts b/src/index.ts index c93e5d4..79f939e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * as Fileforge from "./api"; -export { FileforgeClient } from "./Client"; +export { FileforgeClient, ResponseStream, ResponseURL } from "./wrapper/FileforgeClient"; export { FileforgeEnvironment } from "./environments"; export { FileforgeError, FileforgeTimeoutError } from "./errors"; +export { generateFromHtml } from "./generateFromHtml"; \ No newline at end of file diff --git a/src/wrapper/FileforgeClient.ts b/src/wrapper/FileforgeClient.ts new file mode 100644 index 0000000..e8f2a09 --- /dev/null +++ b/src/wrapper/FileforgeClient.ts @@ -0,0 +1,77 @@ +import { FileforgeClient as FernClient } from "../Client"; +import * as fs from "fs"; +import * as Fileforge from "../api/index"; +import * as core from "../core"; +import stream, { Stream } from "stream"; + +export interface ResponseURL { + url: string; +} + +export interface ResponseStream { + file: stream.Readable; +} + +export type ResponseObject = ResponseStream | ResponseURL; + +export class FileforgeClient { + private client: FernClient; + + constructor(options: FernClient.Options) { + this.client = new FernClient(options); + } + + /** + * Generates a PDF document from web assets. + * @throws {@link Fileforge.BadRequestError} + * @throws {@link Fileforge.UnauthorizedError} + * @throws {@link Fileforge.InternalServerError} + * @throws {@link Fileforge.BadGatewayError} + */ + public async generate( + files: File[] | fs.ReadStream[], + request: Fileforge.GenerateRequest, + requestOptions?: FernClient.RequestOptions + ): Promise { + const generated = await this.client.generate(files, request, requestOptions); + // read all contents + const chunks: any[] = []; + for await (let chunk of generated) { + chunks.push(chunk); + } + const value = Buffer.concat(chunks); + // try json parse + try { + return JSON.parse(value.toString()) as ResponseObject; + } catch {} + // return file + const { Readable } = await import("node:stream"); + return { file: Readable.from(chunks) }; + } + + /** + * @throws {@link Fileforge.BadRequestError} + * @throws {@link Fileforge.UnauthorizedError} + * @throws {@link Fileforge.InternalServerError} + */ + public async merge( + files: File[] | fs.ReadStream[], + request: Fileforge.MergeRequest, + requestOptions?: FernClient.RequestOptions + ): Promise { + const merged = await this.client.merge(files, request, requestOptions); + // read all contents + const chunks: any[] = []; + for await (let chunk of merged) { + chunks.push(chunk); + } + const value = Buffer.concat(chunks); + // try json parse + try { + return JSON.parse(value.toString()) as ResponseObject; + } catch {} + // return file + const { Readable } = await import("node:stream"); + return { file: Readable.from(chunks) }; + } +} diff --git a/tests/custom.test.ts b/tests/custom.test.ts index 7f5e031..de3edc1 100644 --- a/tests/custom.test.ts +++ b/tests/custom.test.ts @@ -1,3 +1,25 @@ +import { FileforgeClient, generateFromHtml, ResponseStream, ResponseURL } from "../src"; +import * as fs from "fs"; +import * as errors from "../src/errors"; + +const FILEFORGE_API_KEY = process.env.FILEFORGE_API_KEY!; + +const HTML = ` + + + My First Web Page + + +

Hello World!

+ + +`; + +const CSS = `body{ + background-color: lightblue; +} +`; + /** * This is a custom test file, if you wish to add more tests * to your SDK. @@ -7,7 +29,175 @@ * you will have tests automatically generated for you. */ describe("test", () => { - it("default", () => { - expect(true).toBe(true); - }); + it.skip("should generate a PDF", async () => { + const htmlBlob = new Blob([HTML], { + type: "text/html", + }); + const cssBlob = new Blob([CSS], { + type: "text/css", + }); + const htmlFile = new File([htmlBlob], "index.html", { type: "text/html" }); + const cssFile = new File([cssBlob], "style.css", { type: "text/css" }); + + const ff = new FileforgeClient({ + apiKey: FILEFORGE_API_KEY, + }); + + const pdf = (await ff.generate([htmlFile, cssFile], { + options: {}, + })) as ResponseStream; + + // Write the PDF stream to a file + const writeStream = fs.createWriteStream("output.pdf"); + pdf.file.pipe(writeStream); + }, 10_000_000); + + it.skip("should generate a PDF link", async () => { + const htmlBlob = new Blob([HTML], { + type: "text/html", + }); + const cssBlob = new Blob([CSS], { + type: "text/css", + }); + const htmlFile = new File([htmlBlob], "index.html", { type: "text/html" }); + const cssFile = new File([cssBlob], "style.css", { type: "text/css" }); + + const ff = new FileforgeClient({ + apiKey: process.env.FILEFORGE_API_KEY ?? "", + }); + + const pdf = (await ff.generate([htmlFile, cssFile], { + options: { + host: true, + }, + })) as ResponseURL; + + expect(pdf.url).not.toBeNull(); + }, 10_000_000); + + it.skip("should fail because of invalid api key", async () => { + const htmlBlob = new Blob([HTML], { + type: "text/html", + }); + const cssBlob = new Blob([CSS], { + type: "text/css", + }); + const htmlFile = new File([htmlBlob], "index.html", { type: "text/html" }); + const cssFile = new File([cssBlob], "style.css", { type: "text/css" }); + + const ff = new FileforgeClient({ + apiKey: "blabla_invalid_key", + }); + try { + const pdf = await ff.generate([htmlFile, cssFile], { + options: { + host: true, + }, + }); + } catch (e) { + expect(e).not.toBeNull(); + if (e instanceof errors.FileforgeError) { + expect(e.statusCode).toBe(401); + } + } + }, 10_000_000); + + it.skip("should generate a PDF buffer from helper", async () => { + const htmlBlob = new Blob([HTML], { + type: "text/html", + }); + const cssBlob = new Blob([CSS], { + type: "text/css", + }); + const htmlFile = new File([htmlBlob], "index.html", { type: "text/html" }); + const cssFile = new File([cssBlob], "style.css", { type: "text/css" }); + + const ff = new FileforgeClient({ + apiKey: FILEFORGE_API_KEY, + }); + + const pdf = (await generateFromHtml(ff, { + html: HTML, + fileName: "test", + host: false, + test: false, + })) as ResponseStream; + // Write the PDF stream to a file + const writeStream = fs.createWriteStream("output_helper.pdf"); + pdf.file.pipe(writeStream); + }, 10_000_000); + + it("should generate a PDF url from helper", async () => { + const htmlBlob = new Blob([HTML], { + type: "text/html", + }); + const cssBlob = new Blob([CSS], { + type: "text/css", + }); + const htmlFile = new File([htmlBlob], "index.html", { type: "text/html" }); + const cssFile = new File([cssBlob], "style.css", { type: "text/css" }); + + const ff = new FileforgeClient({ + apiKey: FILEFORGE_API_KEY, + }); + + const pdf = (await generateFromHtml(ff, { + html: HTML, + fileName: "test", + host: true, + })) as ResponseURL; + + expect(pdf.url).not.toBeNull(); + }, 10_000_000); + + it("should merge two PDFs", async () => { + const PDF1 = await fs.promises.readFile("./output.pdf"); + const PDF2 = await fs.promises.readFile("./output_helper.pdf"); + + const pdfBlob1 = new Blob([PDF1], { + type: "application/pdf", + }); + const pdfBlob2 = new Blob([PDF2], { + type: "application/pdf", + }); + const file1 = new File([pdfBlob1], "pdf1.pdf", { type: "application/pdf" }); + const file2 = new File([pdfBlob2], "pdf2.pdf", { type: "application/pdf" }); + + const ff = new FileforgeClient({ + apiKey: FILEFORGE_API_KEY, + }); + + const pdf = (await ff.merge([file1, file2], { + options: {}, + })) as ResponseStream; + + // Write the PDF stream to a file + const writeStream = fs.createWriteStream("output_merged.pdf"); + pdf.file.pipe(writeStream); + }, 10_000_000); + + it("should generate from html snippet", async () => { + try { + const client = new FileforgeClient({ + apiKey: FILEFORGE_API_KEY, + }); + const documentInput = { + html: HTML, + fileName: "example", + test: false, + host: false, + expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000), + files: [{ path: "/style.css", content: CSS }], + }; + + const response = (await generateFromHtml(client, documentInput)) as ResponseStream; + + // Write the PDF stream to a file + const writeStream = fs.createWriteStream("outputSnippet.pdf"); + response.file.pipe(writeStream); + console.log("PDF generated successfully."); + } catch (error) { + console.error("Error generating PDF:", error); + } + }, 10_000_000); });