Extending the Platform
Learn how to extend Open Receipt OCR with custom providers and features.
Adding a New OCR Provider
To add a new OCR provider (e.g., “CustomOCR”), follow these steps:
1. Update Shared Types
Add the provider to the enum in packages/types/src/ocr-provider.enum.ts:
export enum OcrProvider {
// ... existing providers
CUSTOM_OCR = 'custom-ocr',
}
2. Configure Secrets
Add required API keys/credentials to server/src/core/types/app-secret.enum.ts:
export enum AppSecret {
// ... existing secrets
CUSTOM_OCR_API_KEY = 'CUSTOM_OCR_API_KEY',
CUSTOM_OCR_ENDPOINT = 'CUSTOM_OCR_ENDPOINT',
}
Document these in server/.env.example:
CUSTOM_OCR_API_KEY=your_api_key
CUSTOM_OCR_ENDPOINT=https://api.customocr.com/v1
3. Create the Processor
Create server/src/worker/ocr/custom-ocr.processor.ts:
import { Injectable, Inject } from '@nestjs/common';
import { SecretProvider } from '../../core/secrets/secrets.provider';
import { StorageProvider } from '../../core/storage/storage.provider';
import { AppSecret } from '../../core/types/app-secret.enum';
import { OcrFileEntity } from '../../ocr-jobs/entities/ocr-file.entity';
@Injectable()
export class CustomOcrProcessor {
constructor(
@Inject(SecretProvider) private readonly secretProvider: SecretProvider,
private readonly storage: StorageProvider,
) {}
async process(file: OcrFileEntity, executionId: number): Promise<string> {
// 1. Get secrets
const apiKey = await this.secretProvider.getSecret(
AppSecret.CUSTOM_OCR_API_KEY,
);
const endpoint = await this.secretProvider.getSecret(
AppSecret.CUSTOM_OCR_ENDPOINT,
);
if (!apiKey || !endpoint) {
throw new Error('CustomOCR credentials not configured');
}
// 2. Get file stream from storage
const fileStream = await this.storage.getStream(file.storageKey);
// 3. Call your OCR API
const result = await this.callCustomOcrApi(
fileStream,
apiKey,
endpoint,
);
// 4. Return result as JSON string
return JSON.stringify({
markdown: result.markdown,
rawText: result.text,
confidence: result.confidence,
});
}
private async callCustomOcrApi(
fileStream: NodeJS.ReadableStream,
apiKey: string,
endpoint: string,
): Promise<any> {
// Implementation depends on your OCR service API
// Example using fetch:
const formData = new FormData();
const blob = new Blob([fileStream]);
formData.append('image', blob);
const response = await fetch(`${endpoint}/recognize`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(
`CustomOCR API error: ${response.status} ${response.statusText}`,
);
}
return response.json();
}
}
4. Register the Processor
Add your processor to server/src/worker/worker.module.ts:
import { CustomOcrProcessor } from './ocr/custom-ocr.processor';
@Module({
// ... other config
providers: [
// ... existing processors
CustomOcrProcessor,
],
})
export class WorkerModule {}
5. Hook into OCR Processor
Update server/src/worker/ocr/ocr.processor.ts:
import { CustomOcrProcessor } from './custom-ocr.processor';
import { OcrProvider } from '@open-receipt-ocr/types';
@Injectable()
export class OcrProcessor {
constructor(
// ... existing injections
private readonly customOcr: CustomOcrProcessor,
) {}
async process(
file: OcrFileEntity,
provider: OcrProvider,
executionId: number,
): Promise<string> {
switch (provider) {
// ... existing cases
case OcrProvider.CUSTOM_OCR:
return this.customOcr.process(file, executionId);
default:
throw new Error(`Unknown provider: ${provider}`);
}
}
}
6. Client-Side Rendering (Optional)
Create a parser for your OCR output in client/src/app/pipes/parsers/custom-ocr.parser.ts:
import { Injectable } from '@angular/core';
import { OcrOutputParser } from '../ocr-output-parser.interface';
interface CustomOcrOutput {
markdown: string;
rawText: string;
confidence: number;
}
@Injectable()
export class CustomOcrParser implements OcrOutputParser {
supports(provider: string): boolean {
return provider === 'custom-ocr';
}
parse(data: string): string {
try {
const output: CustomOcrOutput = JSON.parse(data);
return `
<h3>Confidence: ${(output.confidence * 100).toFixed(0)}%</h3>
${output.markdown}
`;
} catch (e) {
return data;
}
}
}
Register in client/src/app/pipes/parsers/ocr-output-parser.service.ts:
import { CustomOcrParser } from './custom-ocr.parser';
@Injectable()
export class OcrOutputParserService {
private parsers: OcrOutputParser[] = [
// ... existing parsers
new CustomOcrParser(),
];
}
Adding a New Storage Provider
To add a new storage backend (e.g., “S3”):
1. Define the Provider Type
Update server/src/core/storage/storage-provider-type.enum.ts:
export enum StorageProviderType {
// ... existing types
S3 = 's3',
}
2. Implement the Provider
Create server/src/core/storage/s3-storage.provider.ts:
import { Injectable, Inject } from '@nestjs/common';
import { StorageProvider } from './storage.provider';
import { StorageProviderType } from './storage-provider-type.enum';
import { SecretProvider } from '../secrets/secrets.provider';
import { AppSecret } from '../types/app-secret.enum';
import * as AWS from 'aws-sdk';
@Injectable()
export class S3StorageProvider extends StorageProvider {
readonly name = StorageProviderType.S3;
private s3: AWS.S3;
constructor(
@Inject(SecretProvider) private secretProvider: SecretProvider,
) {
super();
}
async initialize(): Promise<void> {
const accessKey = await this.secretProvider.getSecret(
AppSecret.AWS_ACCESS_KEY_ID,
);
const secretKey = await this.secretProvider.getSecret(
AppSecret.AWS_SECRET_ACCESS_KEY,
);
const region = await this.secretProvider.getSecret(AppSecret.AWS_REGION);
this.s3 = new AWS.S3({
accessKeyId: accessKey,
secretAccessKey: secretKey,
region: region,
});
}
async uploadStream(
key: string,
stream: NodeJS.ReadableStream,
): Promise<void> {
const params = {
Bucket: 'your-bucket-name',
Key: key,
Body: stream,
};
await this.s3.upload(params).promise();
}
async getStream(key: string): Promise<NodeJS.ReadableStream> {
const params = {
Bucket: 'your-bucket-name',
Key: key,
};
return this.s3.getObject(params).createReadStream();
}
async exists(key: string): Promise<boolean> {
try {
const params = {
Bucket: 'your-bucket-name',
Key: key,
};
await this.s3.headObject(params).promise();
return true;
} catch {
return false;
}
}
async delete(key: string): Promise<void> {
const params = {
Bucket: 'your-bucket-name',
Key: key,
};
await this.s3.deleteObject(params).promise();
}
}
3. Register the Provider
Update server/src/core/storage/storage.module.ts:
import { S3StorageProvider } from './s3-storage.provider';
@Module({
providers: [
// ... existing providers
S3StorageProvider,
],
exports: [
// ... existing exports
S3StorageProvider,
],
})
export class StorageModule {}
Update factory in server/src/core/storage/storage.provider.ts:
static async create(
@Inject(SecretProvider) secretProvider: SecretProvider,
@Inject(S3StorageProvider) s3Provider: S3StorageProvider,
): Promise<StorageProvider> {
const type = process.env.STORAGE_PROVIDER as StorageProviderType;
let provider: StorageProvider;
switch (type) {
// ... existing cases
case StorageProviderType.S3:
provider = s3Provider;
break;
default:
provider = localProvider;
}
await provider.initialize?.();
return provider;
}
Adding a New Secret Provider
To add a new secret management backend (e.g., “HashiCorp Vault”):
1. Define the Provider Type
Update server/src/core/secrets/secret-provider-type.enum.ts:
export enum SecretProviderType {
// ... existing types
VAULT = 'vault',
}
2. Implement the Provider
Create server/src/core/secrets/providers/vault-secret.provider.ts:
import { Injectable } from '@nestjs/common';
import { SecretProvider } from '../secrets.provider';
import { SecretProviderType } from '../secret-provider-type.enum';
import { AppSecret } from '../../types/app-secret.enum';
import axios from 'axios';
@Injectable()
export class VaultSecretProvider extends SecretProvider {
readonly name = SecretProviderType.VAULT;
private vaultUrl: string;
private vaultToken: string;
constructor() {
super();
this.vaultUrl = process.env.VAULT_ADDR || 'http://localhost:8200';
this.vaultToken = process.env.VAULT_TOKEN || '';
}
async getSecret(name: AppSecret): Promise<string | undefined> {
try {
const response = await axios.get(
`${this.vaultUrl}/v1/secret/data/${name}`,
{
headers: {
'X-Vault-Token': this.vaultToken,
},
},
);
return response.data.data.data.value;
} catch (error) {
console.error(`Failed to fetch secret ${name} from Vault:`, error);
return undefined;
}
}
}
3. Register and Configure
Update server/src/core/secrets/secrets.module.ts:
import { VaultSecretProvider } from './providers/vault-secret.provider';
@Module({
providers: [
// ... existing providers
VaultSecretProvider,
],
exports: [
// ... existing exports
VaultSecretProvider,
],
})
export class SecretsModule {}
Update factory in server/src/core/secrets/secrets.provider.ts:
static create(vaultProvider: VaultSecretProvider): SecretProvider {
const type = process.env.SECRET_PROVIDER as SecretProviderType;
switch (type) {
// ... existing cases
case SecretProviderType.VAULT:
return vaultProvider;
default:
return new EnvSecretProvider();
}
}
Best Practices
- Error Handling: Provide clear error messages when credentials are missing
- Logging: Log important steps for debugging (use NestJS logger)
- Testing: Include unit tests for your provider
- Documentation: Update configuration guide with setup instructions
- Dependencies: Add to
package.jsonif your provider requires external packages - Validation: Validate configuration at startup time, not at runtime
Testing Your Provider
describe('CustomOcrProcessor', () => {
let processor: CustomOcrProcessor;
let mockSecretProvider: any;
let mockStorageProvider: any;
beforeEach(async () => {
mockSecretProvider = {
getSecret: jest.fn(),
};
mockStorageProvider = {
getStream: jest.fn(),
};
processor = new CustomOcrProcessor(
mockSecretProvider,
mockStorageProvider,
);
});
it('should process a file successfully', async () => {
mockSecretProvider.getSecret.mockResolvedValue('test-key');
mockStorageProvider.getStream.mockResolvedValue(null);
const result = await processor.process({} as OcrFileEntity, 1);
expect(result).toBeDefined();
});
});