Creating an OCR API with FaaS on Azure - Part 2: Persisting Data in Azure Postgres SQL with Best Practices
In the first part of this series, we built a Function App that securely receives an image via HTTP and saves it to Azure Blob Storage, using managed identity. Now, let's move forward and record metadata of these images in a database. Spoiler alert: we'll use Azure Postgres SQL, a layered architecture, and best practices like SOLID principles and responsibility segregation. Why save data in a database? Blob Storage is excellent for storing files, but what if you want to know: When a file was uploaded? What is the public URL of the image? What is the OCR processing status (pending, processed, error)? Which images are medical prescriptions, for instance? That's where Azure Postgres SQL steps in, to maintain control of everything going on with each image. Updating Project Architecture An IImageRepository.ts file has been added to manage the database domain logic and its implementation within OcrImageRepository.ts: /ocr-function-app ├── application/ │ └── UploadImageService.ts ├── domain/ │ └── IImageStorage.ts │ └── IImageRepository.ts ├── infrastructure/ │ └── AzureBlobStorage.ts │ └── OcrImageRepository.ts ├── validations/ │ └── ContentTypeValidator.ts ├── HttpAddToBlob/ │ └── index.ts │ └── function.json ├── constants.ts ├── host.json ├── local.settings.json └── package.json The idea is to keep a lightweight DDD approach, delegating responsibilities to more specific layers. Validating Content Type Before processing anything arriving at the Function, let's validate if the content is a real image. First, we create an enum to store the allowed types: export enum AllowedContentTypes { JPEG = 'image/jpeg', PNG = 'image/png', JPG = 'image/jpg', } Then, we craft a simple validation class: import { AllowedContentTypes } from "../constants"; export class ContentTypeValidator { private static allowedTypes = Object.values(AllowedContentTypes); static validate(contentType?: AllowedContentTypes): void { if (!contentType || !this.allowedTypes.includes(contentType)) { throw new Error('Unsupported content type. Send a JPEG or PNG image.'); } } } Inside the Function: const contentType = req.headers['content-type']; ContentTypeValidator.validate(contentType); Creating the Database Table Once the database is set up, execute this script via Azure Data Studio or SSMS to create the table: CREATE TABLE OcrImages ( Id INT IDENTITY PRIMARY KEY, FileName NVARCHAR(200) NOT NULL, Url NVARCHAR(MAX) NOT NULL, UploadDate DATETIME NOT NULL DEFAULT GETDATE(), Status NVARCHAR(50) NOT NULL DEFAULT 'pending', IsPrescription BOOLEAN NOT NULL DEFAULT false ); Packages Used Install the following packages for your TypeScript project with Azure Functions: npm install pg Purpose: Connect to Azure Postgres SQL (pg). Connecting the Function to Azure Postgres SQL Avoid hardcoding connection strings with usernames and passwords by using Microsoft Entra ID (managed identity) via the Service Connector feature in the Azure portal. Steps to connect: Access your Function App in the Azure portal. Click Service Connector > + Add. Select Azure Postgres SQL as the destination. Choose User Assigned. Set up Firewall settings. Click Next: Review + Create and Create. Defining Repository Contracts Create the IImageRepository interface to define persistence capabilities, independent of the database type: export interface IImageRepository { save(fileName: string, url: string): Promise; } Implementing with Azure Postgres SQL Now, for the actual implementation under infrastructure: import { IImageRepository } from "../domain/IImageRepository"; import { Pool } from "pg"; export class OcrImageRepository implements IImageRepository { constructor(private readonly pool: Pool) {} async save(fileName: string, url: string): Promise { try { await this.pool.query( ` INSERT INTO OcrImages (FileName, Url) VALUES ($1, $2) `, [fileName, url] ); } catch (err) { throw new Error(`Error inserting image: ${(err as Error).message}`); } } } Notice how this class only manages connections and inserts, knowing nothing about HTTP, OCR, or validations—true to SOLID principles. Update the application service with dependencies: export class UploadImageService { constructor( private readonly imageStorage: IImageStorage, private readonly imageRepository: IImageRepository, ) {} async handleUpload(buffer: Buffer): Promise { const fileName = `${uuidv4()}.pngg`; const url = await this.imageStorage.uploadImage(buffer, fileName); await this.imageRepository.save(fileName, url); return { url, fileName }; } } Current Function Structure The HTTP Function now: Validates content type (Content-Type). Checks image size. Saves the image to Azure Blob Storage. Records image name and URL i

In the first part of this series, we built a Function App that securely receives an image via HTTP and saves it to Azure Blob Storage, using managed identity. Now, let's move forward and record metadata of these images in a database. Spoiler alert: we'll use Azure Postgres SQL, a layered architecture, and best practices like SOLID principles and responsibility segregation.
Why save data in a database?
Blob Storage is excellent for storing files, but what if you want to know:
- When a file was uploaded?
- What is the public URL of the image?
- What is the OCR processing status (pending, processed, error)?
- Which images are medical prescriptions, for instance?
That's where Azure Postgres SQL steps in, to maintain control of everything going on with each image.
Updating Project Architecture
An IImageRepository.ts
file has been added to manage the database domain logic and its implementation within OcrImageRepository.ts
:
/ocr-function-app
├── application/
│ └── UploadImageService.ts
├── domain/
│ └── IImageStorage.ts
│ └── IImageRepository.ts
├── infrastructure/
│ └── AzureBlobStorage.ts
│ └── OcrImageRepository.ts
├── validations/
│ └── ContentTypeValidator.ts
├── HttpAddToBlob/
│ └── index.ts
│ └── function.json
├── constants.ts
├── host.json
├── local.settings.json
└── package.json
The idea is to keep a lightweight DDD approach, delegating responsibilities to more specific layers.
Validating Content Type
Before processing anything arriving at the Function, let's validate if the content is a real image.
First, we create an enum to store the allowed types:
export enum AllowedContentTypes {
JPEG = 'image/jpeg',
PNG = 'image/png',
JPG = 'image/jpg',
}
Then, we craft a simple validation class:
import { AllowedContentTypes } from "../constants";
export class ContentTypeValidator {
private static allowedTypes = Object.values(AllowedContentTypes);
static validate(contentType?: AllowedContentTypes): void {
if (!contentType || !this.allowedTypes.includes(contentType)) {
throw new Error('Unsupported content type. Send a JPEG or PNG image.');
}
}
}
Inside the Function:
const contentType = req.headers['content-type'];
ContentTypeValidator.validate(contentType);
Creating the Database Table
Once the database is set up, execute this script via Azure Data Studio or SSMS to create the table:
CREATE TABLE OcrImages (
Id INT IDENTITY PRIMARY KEY,
FileName NVARCHAR(200) NOT NULL,
Url NVARCHAR(MAX) NOT NULL,
UploadDate DATETIME NOT NULL DEFAULT GETDATE(),
Status NVARCHAR(50) NOT NULL DEFAULT 'pending',
IsPrescription BOOLEAN NOT NULL DEFAULT false
);
Packages Used
Install the following packages for your TypeScript project with Azure Functions:
npm install pg
Purpose:
- Connect to Azure Postgres SQL (
pg
).
Connecting the Function to Azure Postgres SQL
Avoid hardcoding connection strings with usernames and passwords by using Microsoft Entra ID (managed identity) via the Service Connector feature in the Azure portal.
Steps to connect:
- Access your Function App in the Azure portal.
- Click Service Connector > + Add.
- Select Azure Postgres SQL as the destination.
- Choose User Assigned.
- Set up Firewall settings.
- Click Next: Review + Create and Create.
Defining Repository Contracts
Create the IImageRepository
interface to define persistence capabilities, independent of the database type:
export interface IImageRepository {
save(fileName: string, url: string): Promise<void>;
}
Implementing with Azure Postgres SQL
Now, for the actual implementation under infrastructure
:
import { IImageRepository } from "../domain/IImageRepository";
import { Pool } from "pg";
export class OcrImageRepository implements IImageRepository {
constructor(private readonly pool: Pool) {}
async save(fileName: string, url: string): Promise<void> {
try {
await this.pool.query(
`
INSERT INTO OcrImages (FileName, Url)
VALUES ($1, $2)
`,
[fileName, url]
);
} catch (err) {
throw new Error(`Error inserting image: ${(err as Error).message}`);
}
}
}
Notice how this class only manages connections and inserts, knowing nothing about HTTP, OCR, or validations—true to SOLID principles.
Update the application service with dependencies:
export class UploadImageService {
constructor(
private readonly imageStorage: IImageStorage,
private readonly imageRepository: IImageRepository,
) {}
async handleUpload(buffer: Buffer): Promise<{ url: string; fileName: string }> {
const fileName = `${uuidv4()}.pngg`;
const url = await this.imageStorage.uploadImage(buffer, fileName);
await this.imageRepository.save(fileName, url);
return { url, fileName };
}
}
Current Function Structure
The HTTP Function now:
- Validates content type (
Content-Type
). - Checks image size.
- Saves the image to Azure Blob Storage.
- Records image name and URL in Azure Postgres SQL.
Here's the complete Function code:
const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
try {
if (!req.body) {
context.res = {
status: 400,
body: "Invalid or missing image"
};
return;
}
const contentType = req.headers['content-type'];
ContentTypeValidator.validate(contentType as AllowedContentTypes);
const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body);
if (buffer.length > 15 * 1024 * 1024) {
throw new Error("Image exceeds maximum size of 15MB.");
}
const credential = new DefaultAzureCredential({ managedIdentityClientId });
const storage = new AzureBlobStorage(accountUrl, containerName, credential);
const { token: password } = await credential.getToken('https://ossrdbms-aad.database.windows.net/.default');
const pool = new Pool({
host, user, password, database, port, ssl,
});
const repository = new OcrImageRepository(pool);
const uploadService = new UploadImageService(storage, repository);
const { url, fileName } = await uploadService.handleUpload(buffer);
context.res = {
status: 200,
body: {
message: "Image stored successfully",
url,
fileName,
},
};
} catch (error) {
context.log.error("Error storing image", error);
context.res = {
status: 500,
body: "Error storing image",
error,
};
}
};
export default httpTrigger;