Hi! Are you curious about how to create an API endpoint that generates PDF templates using React components and uploads them to Google Cloud Storage with Terraform as Infrastructure as Code? Or have you been assigned a task to do just that? If so, you've come to the right place!
Architecture
Techstack to use
- Express.js
- Typescript
- Google Cloud Storage
- react-pdf/renderer
Requirements
- NPM CLI - https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
- Terraform CLI - https://developer.hashicorp.com/terraform/cli/commands
- If you are using M1 mac and having a trouble setting up, you can follow this GIST https://gist.github.com/mharrvic/12b46934c608b0e21d6dd3e9fdeb1669
- GCLOUD CLI - https://cloud.google.com/sdk/gcloud
Express.js API Setup
You can skip this step and just use some templates available https://github.com/topics/express-typescript
If you'd like to proceed with the manual setup, let's do it!
Initialize the package.json file, open terminal
# Create a new folder
mkdir pdf-service
# Change the directory to our newly created folder
cd pdf-service
# Create package.json file
npm init --y
Install packages
# Install Dependencies
npm install express dotenv esbuild ulid rimraf
# Install DevDependencies
npm install -D @types/express @types/node typescript ts-node nodemon
Create a tsconfig.json
in the root folder, and add this
{
"compilerOptions": {
"jsx": "react",
"rootDir": "./src",
"outDir": "./build",
"target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
Create a new src
folder, then add an app.ts
file inside it.
import * as dotenv from "dotenv";
import express from "express";
dotenv.config();
const app = express();
app.use(express.json());
app.get("/health-check", async (req, res) => {
res.status(200).send({ message: "PDF Generator Service" });
});
app.post("/", async (req, res) => {
try {
res.status(200).send({
message: "PDF Generator Service",
url: "test",
});
} catch (error) {
res.status(500).send(error);
}
});
export { app };
Create an index.ts
file inside the src
folder
import { app } from "./app";
const PORT = process.env.PORT || 8080;
/**
* Initialize app and start Express server
*/
const main = async () => {
// Start server listening on PORT env var
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
};
main();
The current folder structure should look like this
├── node_modules/
├── package-lock.json
├── package.json
├── src
│ ├── app.ts
│ └── index.ts
└── tsconfig.json
Let’s add dev
, serve:build
, and build
command
Open package.json
file and add these inside the scripts
"dev": "NODE_ENV=development ./node_modules/.bin/nodemon --exec './node_modules/.bin/ts-node' src/index.ts",
"serve:build": "cd build && node index.js",
"build": "rimraf build && esbuild src/index.ts --platform=node --loader:.png=file --loader:.svg=file --loader:.woff=file --bundle --minify --outfile=build/index.js"
Let’s try the dev command if it works
npm run dev
# The output should be like this
[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `./node_modules/.bin/ts-node src/index.ts`
Listening on port 8080
Open the browser with http://localhost:8080/health-check, and the output should be like this
{"message":"PDF Generator Service"}
React-PDF Template
Let’s now setup our API with react-pdf template
Install react-pdf/renderer package
npm install @react-pdf/renderer react@latest react-dom@latest --legacy-peer-deps
npm install -D @types/react @types/react-dom --legacy-peer-deps
Create a new template
folder under src
, then add a pdf-template.tsx
file inside it.
// Contents from https://react-pdf.org/repl
import ReactPDF, {
Document,
Font,
Image,
Page,
StyleSheet,
Text,
} from "@react-pdf/renderer";
import React from "react";
Font.register({
family: "Oswald",
src: "https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf",
});
const styles = StyleSheet.create({
body: {
paddingTop: 35,
paddingBottom: 65,
paddingHorizontal: 35,
},
title: {
fontSize: 24,
textAlign: "center",
fontFamily: "Oswald",
},
author: {
fontSize: 12,
textAlign: "center",
marginBottom: 40,
},
subtitle: {
fontSize: 18,
margin: 12,
fontFamily: "Oswald",
},
text: {
margin: 12,
fontSize: 14,
textAlign: "justify",
fontFamily: "Times-Roman",
},
image: {
marginVertical: 15,
marginHorizontal: 100,
},
header: {
fontSize: 12,
marginBottom: 20,
textAlign: "center",
color: "grey",
},
pageNumber: {
position: "absolute",
fontSize: 12,
bottom: 30,
left: 0,
right: 0,
textAlign: "center",
color: "grey",
},
});
export const Quixote = () => (
<Document>
<Page style={styles.body}>
<Text style={styles.header} fixed>
~ Created with react-pdf ~
</Text>
<Text style={styles.title}>Don Quijote de la Mancha</Text>
<Text style={styles.author}>Miguel de Cervantes</Text>
<Image
style={styles.image}
src="https://cdn.britannica.com/12/154812-050-D4E47005/Don-Quixote-Sancho-Panza-illustration-Miguel-de.jpg?w=300&h=169&c=crop"
/>
<Text style={styles.subtitle}>
Capítulo I: Que trata de la condición y ejercicio del famoso hidalgo D.
Quijote de la Mancha
</Text>
<Text style={styles.text}>
En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha...
</Text>
<Text style={styles.text}>
Es, pues, de saber, que este sobredicho hidalgo...
</Text>
<Text style={styles.text}>
Con estas y semejantes razones perdía el pobre caballero el...
</Text>
<Text style={styles.text}>
En resolución, él se enfrascó tanto en su lectura...
</Text>
<Text style={styles.subtitle} break>
Capítulo II: Que trata de la primera salida que de su tierra hizo el
ingenioso Don Quijote
</Text>
<Image
style={styles.image}
src="https://cdn.britannica.com/12/154812-050-D4E47005/Don-Quixote-Sancho-Panza-illustration-Miguel-de.jpg?w=300&h=169&c=crop"
/>
<Text style={styles.text}>Hechas, pues, estas prevenciones...</Text>
<Text style={styles.text}>
Yendo, pues, caminando nuestro flamante aventurero...
</Text>
<Text style={styles.text}>Y era la verdad que por él caminaba...</Text>
<Text style={styles.text}>Luego volvía diciendo...</Text>
<Text style={styles.text}>
Casi todo aquel día caminó sin acontecerle cosa que de contar fuese...
</Text>
<Text
style={styles.pageNumber}
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
fixed
/>
</Page>
</Document>
);
export const pdfTemplate = async () => {
return await ReactPDF.renderToStream(<Quixote />);
};
Back to our app.ts
, let’s import the pdfTemplate
and temporarily comment out the response with NodeJS.ReadableStream Pipe
import { pdfTemplate } from "./template/pdf-template";
....
app.post("/", async (req, res) => {
try {
const pdfStream = await pdfTemplate();
res.setHeader("Content-Type", "application/pdf");
pdfStream.pipe(res);
pdfStream.on("end", () => console.log("Done streaming, response sent."));
// res.status(200).send({
// message: "PDF Generator Service",
// url: "test",
// });
} catch (error) {
res.status(500).send(error);
}
});
Open your Postman/Insomnia/hoppscotch or any API Client and make a POST
request to http://localhost:8080
, the response should be in PDF like this
Or you can just create another GET
test endpoint and directly access it to the browser
app.get("/test", async (req, res) => {
try {
const pdfStream = await pdfTemplate();
res.setHeader("Content-Type", "application/pdf");
pdfStream.pipe(res);
pdfStream.on("end", () => console.log("Done streaming, response sent."));
} catch (error) {
res.status(500).send(error);
}
});
// Open it in the browser http://localhost:8080/test
UI Template Preview
We can now access the PDF with our test endpoint. However, debugging with react-pdf might be difficult. A UI previewer could be useful.
Let’s use Vite for this use case
npm install -D vite @vitejs/plugin-react @esbuild-plugins/node-globals-polyfill @esbuild-plugins/node-modules-polyfill --legacy-peer-deps
Create a new client-preview
folder under src
, then add an App.tsx
and index.tsx
file inside it.
// App.tsx
import React from "react";
import { PDFViewer } from "@react-pdf/renderer";
import { Quixote } from "../template/pdf-template";
function App() {
return (
<PDFViewer height={800} width={1000}>
<Quixote />
</PDFViewer>
);
}
export default App;
// index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<App />
);
Create vite.config.ts
in our root folder
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// You don't need to add this to deps, it's included by @esbuild-plugins/node-modules-polyfill
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
},
resolve: {
alias: {
// This Rollup aliases are extracted from @esbuild-plugins/node-modules-polyfill,
// see https://github.com/remorses/esbuild-plugins/blob/master/node-modules-polyfill/src/polyfills.ts
// process and buffer are excluded because already managed
// by node-globals-polyfill
util: "rollup-plugin-node-polyfills/polyfills/util",
sys: "util",
events: "rollup-plugin-node-polyfills/polyfills/events",
stream: "rollup-plugin-node-polyfills/polyfills/stream",
path: "rollup-plugin-node-polyfills/polyfills/path",
querystring: "rollup-plugin-node-polyfills/polyfills/qs",
punycode: "rollup-plugin-node-polyfills/polyfills/punycode",
url: "rollup-plugin-node-polyfills/polyfills/url",
string_decoder: "rollup-plugin-node-polyfills/polyfills/string-decoder",
buffer: "rollup-plugin-node-polyfills/polyfills/buffer-es6",
process: "rollup-plugin-node-polyfills/polyfills/process-es6",
http: "rollup-plugin-node-polyfills/polyfills/http",
https: "rollup-plugin-node-polyfills/polyfills/http",
os: "rollup-plugin-node-polyfills/polyfills/os",
assert: "rollup-plugin-node-polyfills/polyfills/assert",
constants: "rollup-plugin-node-polyfills/polyfills/constants",
_stream_duplex:
"rollup-plugin-node-polyfills/polyfills/readable-stream/duplex",
_stream_passthrough:
"rollup-plugin-node-polyfills/polyfills/readable-stream/passthrough",
_stream_readable:
"rollup-plugin-node-polyfills/polyfills/readable-stream/readable",
_stream_writable:
"rollup-plugin-node-polyfills/polyfills/readable-stream/writable",
_stream_transform:
"rollup-plugin-node-polyfills/polyfills/readable-stream/transform",
timers: "rollup-plugin-node-polyfills/polyfills/timers",
console: "rollup-plugin-node-polyfills/polyfills/console",
vm: "rollup-plugin-node-polyfills/polyfills/vm",
zlib: "rollup-plugin-node-polyfills/polyfills/zlib",
tty: "rollup-plugin-node-polyfills/polyfills/tty",
domain: "rollup-plugin-node-polyfills/polyfills/domain",
},
},
});
Create index.html
in our root folder
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React PDF Service</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/client-preview/index.tsx"></script>
</body>
</html>
Open the package.json
and add "preview": "vite",
inside scripts
...
"scripts": {
"preview": "vite",
"dev": "NODE_ENV=development ./node_modules/.bin/nodemon --exec './node_modules/.bin/ts-node' src/index.ts",
"serve:build": "cd build && node index.js",
"build": "rimraf build && esbuild src/index.ts --platform=node --loader:.png=file --loader:.svg=file --loader:.woff=file --bundle --minify --outfile=build/index.js"
},
...
Run npm run preview
, then open the URL in your browser. You should now be able to preview your PDF!
Upload Stream to GCS
Let’s create a utility that uploads the pdf stream to GCS
Install the cloud storage package (we are using --legacy-peer-deps
here because we used the latest version of React and React-DOM. However, the current peer-deps of React-PDF is using the old version of React. Don't worry, we know what we're doing, so it's okay to put it there!)
npm install @google-cloud/storage --legacy-peer-deps
Create a new utils
folder under src
, then add uploadStreamToGcs.ts
import { Storage } from "@google-cloud/storage";
import { ulid } from "ulid";
import { format } from "util";
const storage = new Storage();
const bucketName =
process.env.GCS_BUCKET || "any-bucket-name-may-multo-sa-likod-mo-huhu";
const storageBucket = storage.bucket(bucketName);
export const uploadStreamToGcs = async (data: NodeJS.ReadableStream) => {
try {
return new Promise((resolve, reject) => {
const blob = storageBucket.file(`${ulid()}/test.pdf`);
const blobStream = data.pipe(
blob.createWriteStream({
resumable: false,
})
);
blobStream
.on("finish", () => {
const url = format(`gs://${storageBucket.name}/${blob.name}`);
resolve(url);
})
.on("error", (error) => {
reject(error);
});
});
} catch (error) {
throw new Error("Error uploading to GCS");
}
};
Let’s modify our app.ts
and use the upload to stream util to our POST endpoint
...
import { uploadStreamToGcs } from "./utils/uploadStreamToGcs";
...
app.post("/", async (req, res) => {
try {
// Feel free to use this body variable to pass data to your template
const body = req.body;
const pdfStream = await pdfTemplate();
const url = await uploadStreamToGcs(pdfStream);
res.status(200).send({
message: "PDF Generator Service",
url,
});
} catch (error) {
res.status(500).send(error);
}
});
...
To test it locally, make sure to configure your gcloud cli and authenticate. Then, try to hit the POST endpoint. It should now be able to upload the PDF file to Google Cloud Storage – Yey!
Docker Setup
We need to set up our API with Docker, as we plan to deploy it to CloudRun.
Create Dockerfile
to the root directory
FROM node:16
WORKDIR /app
COPY package*.json /app/
RUN npm ci --force --only=production
COPY . /app/
RUN npm run build
CMD ["npm", "run", "serve"]
Done! So simple right? haha
Alright, I'll explain each line with the help of ChatGPT.
# Specifies the base image to use for the Docker container.
# In this case, the base image is the latest version of Node.js with a version number of 16.
FROM node:16
# Sets the working directory for the rest of the instructions in the Dockerfile.
# This means that any commands that follow will be executed within the /app directory inside the container.
WORKDIR /app
# Copies the package.json file and any other files that match the pattern package*.json from the host system
# (i.e., the system where the Dockerfile is located) to the /app directory inside the container.
COPY package*.json /app/
# Runs the npm ci command inside the container to install the dependencies specified in the package.json file.
# The --force flag is used to force npm to install the dependencies even if there are warnings,
# and the --only=production flag tells npm to only install the dependencies needed for production (i.e., not development dependencies).
RUN npm ci --force --only=production
# Copies the entire project directory (i.e., all files and subdirectories)
# from the host system to the /app directory inside the container.
COPY . /app/
# Runs the npm run build command inside the container to build the project.
RUN npm run build
# Specifies the default command to run when the container is started.
# In this case, the command is npm run serve, which starts the server for the project.
CMD ["npm", "run", "serve:build"]
Whew! Thank you, ChatGPT!
Deployment
Registry Artifact Setup with Docker
- Create an artifact registry for the pdf service
Copy, modify, and paste this to your terminal
export PROJECT_ID=your-project-id
export REGION=us-central1
export REPO_NAME=pdf-service
Create artifact repo
gcloud artifacts repositories create ${REPO_NAME} --repository-format=docker \
--location=${REGION} --description="Docker image for pdf service"
- Authenticate your Docker
gcloud auth configure-docker ${REGION}-docker.pkg.dev
- Docker Build
docker build . --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/${REPO_NAME}:dev
- Docker Push to Artifact Registry
docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/${REPO_NAME}:dev
Terraform (Infrastructure as Code)
Terraform is currently the best way to convert infrastructure to code for me, as it supports a wide range of providers (multi-cloud). It is similar to the mantra of React Native, "Learn once, write anywhere".
Let's create an infrastructure/terraform
folder in the root directory. Then, create two more folders: environment
and modules
. We will use the reusable modules and set up different environments.
The folder tree should look something like this:
├── infrastructure
│ └── terraform
│ ├── environment
│ └── modules
CloudRun Module
Inside the modules
directory, create a cloudrun
folder and create two files: main.tf
and variables.tf
.
# variables.tf
variable "region" {
type = string
}
variable "project" {
type = string
}
variable "pdf_service_account_email" {
type = string
}
variable "artifact_repo" {
type = string
}
variable "artifact_registry_url" {
type = string
}
variable "gcs_bucket" {
type = string
}
# main.tf
resource "google_cloud_run_service" "pdf_service" {
provider = google-beta
name = "pdf-service"
location = var.region
project = var.project
template {
spec {
containers {
image = "${artifact_registry_url}/${var.project}/${artifact_repo}/${artifact_repo}:dev"
env {
name = "GCS_BUCKET"
value = var.gcs_bucket
}
}
service_account_name = var.pdf_service_account_email
}
metadata {
annotations = {
"run.googleapis.com/client-name" = "terraform"
"autoscaling.knative.dev/maxScale" = "1000"
}
}
}
traffic {
percent = 100
latest_revision = true
}
autogenerate_revision_name = true
}
Service Account Module
Inside the modules
directory, create a service-account
folder and create three files: main.tf
, outputs.tf
, and variables.tf
.
# variables.tf
variable "project" {
type = string
}
variable "region" {
type = string
}
# outputs.tf
output "pdf_service_account_email" {
value = google_service_account.pdf_service_account.email
}
# main.tf
resource "google_service_account" "pdf_service_account" {
provider = google-beta
account_id = "pdf-service-identity"
project = var.project
display_name = "PDF Service Identity"
}
resource "google_project_iam_member" "pdf_service_account" {
project = var.project
for_each = toset([
"roles/storage.objectCreator",
"roles/artifactregistry.writer",
"roles/run.developer",
"roles/iam.serviceAccountUser"
])
role = each.key
member = "serviceAccount:${google_service_account.pdf_service_account.email}"
}
Dev Environment
Inside the environment
directory, create a dev
folder and create main.tf
file
Make sure to update the local values with your configuration
provider "google" {
project = local.project
}
locals {
project = "your-gcloud-project-id"
region = "us-central1"
zone = "us-central1-a"
artifact_registry_url = "us-central1-docker.pkg.dev"
artifact_repo = "pdf-service"
gcs_bucket = "any-gcs-bucket-name"
}
module "service_account" {
source = "../../modules/service-account"
project = local.project
region = local.region
}
module "cloudrun" {
source = "../../modules/cloudrun"
project = local.project
region = local.region
pdf_service_account_email = module.service_account.pdf_service_account_email
artifact_registry_url = var.artifact_registry_url
artifact_repo = var.artifact_repo
gcs_bucket = var.gcs_bucket
}
The updated folder tree should look something like this:
├── infrastructure
│ └── terraform
│ ├── environment
│ │ └── dev
│ │ └── main.tf
│ └── modules
│ ├── cloudrun
│ │ ├── main.tf
│ │ └── variables.tf
│ └── service-accounts
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
Run terraform init
to initialize the terraform configuration
Run terraform plan
to generate the execution plan
Run terraform apply
to create or update infrastructure according to the execution plan
Top comments (0)