@epistola.app/epistola-client
v1.4.0
Published
Node.js client for the Epistola document generation API, on the platform's own fetch. Generated from the Epistola OpenAPI contract, with identity headers, RFC 9457 problem-detail error handling, API-key and self-signed JWT auth, NDJSON result collection,
Maintainers
Readme
Epistola Node.js Client
Node.js client library for the Epistola
document generation API, generated from the Epistola OpenAPI contract with
OpenAPI Generator (typescript-fetch, on the platform's own
fetch) and hand-written glue for identity, authentication, error handling, result collection, and
client-side validation. TypeScript declarations are included.
It adds, on top of the stock generated client:
- Client identity headers (
User-Agent+X-EP-Node-Id) required on every request. - RFC 9457 problem-detail error handling — a typed
ProblemDetailExceptionwith atypeSlugdiscriminator and generatedKnownProblemSlugsconstants. - Static API-key authentication via
Authorization: ApiKey <key>, the supported method. - Self-signed JWT authentication (
JwtSigner, experimental), minting a fresh short-lived token per request onnode:cryptoalone. - The
Acceptheader each operation is declared with. The generated classes never set one, and Node'sfetchsends*/*by default — so without this a request never says it acceptsapplication/problem+json, the document every error handler here is built to parse. - NDJSON result collection (
ResultCollector) with adaptive polling, compression, and partition-aware routing helpers. - Client-side JSON Schema validation of template data (
TemplateSchemaValidator) and generatedvalidate<Model>helpers for the contract's own constraints.
The package version tracks the Epistola contract version (info.version) and releases in lockstep
with the Kotlin, Jakarta EE, .NET and Python clients. It needs Node.js 22.12 or later and ships as
an ES module (which Node's require() can load as well).
It has no runtime dependencies: HTTP is the platform's fetch, JWT signing is node:crypto,
decompression is node:zlib. The one optional feature that needs a library is client-side template
validation, which loads ajv and ajv-formats on first use; they are optional peer dependencies,
so install them only if you use it (see Client-side validation).
Release history: CHANGELOG.md.
Install
npm install @epistola.app/epistola-clientQuick start
import { ClientIdentity, EpistolaClient, TemplatesApi } from '@epistola.app/epistola-client'
const identity = ClientIdentity.builder()
.nodeId('my-pod-123') // defaults to the hostname
.product('my-app', '1.0.0') // appended to User-Agent
.build()
const client = EpistolaClient.builder('https://epistola.example.com/api')
.identity(identity) // User-Agent + X-EP-Node-Id
.apiKey('epk_...') // Authorization: ApiKey <key>
.build()
const templates = new TemplatesApi(client)
const template = await templates.getTemplate({ tenantId: 'acme', catalogId: 'invoices', templateId: 'invoice' })EpistolaClient.builder(baseUrl, apiKey) is a shorthand for the same thing. The legacy X-API-Key
header is not sent: it remains supported server-side but is deprecated. Some Epistola Suite
deployments disable API-key authentication entirely; switch on
e.typeSlug === KnownProblemSlugs.API_KEY_AUTH_DISABLED and guide the caller to JWT auth.
Self-signed JWT authentication is experimental: Epistola Suite may not implement it yet, and the
flow may still change. To try it, pass .jwtSigner(signer) instead of .apiKey(...):
import { JwtSigner } from '@epistola.app/epistola-client'
const signer = JwtSigner.builder()
.consumerId('invoice-service')
.privateKey(JwtSigner.loadPrivateKey('private.pem'))
.build()EpistolaClient is a generated Configuration, so every generated API class takes it directly.
One builder can produce more than one client: call build() again after changing
requestTimeoutMs(...) for the two profiles a long-running consumer typically needs against the
same backend — no timeout (the default) for polling, rendering and large transfers, a bounded one
for everything else.
Partial updates
The generated models are plain TypeScript interfaces, and the generated serializers drop a property
you never set while sending one you set to null as null. On the API's PATCH operations, where
the contract documents null as "clear this", that is exactly the distinction that matters:
updateConsumer({ ..., updateConsumerRequest: { name: 'Billing' } }) renames the consumer and
touches nothing else; { name: 'Billing', description: null } also clears the description.
Binary downloads
Every operation the contract declares as format: binary — downloadDocument, previewDocument,
downloadImageContent — resolves to a Blob; Buffer.from(await blob.arrayBuffer()) gives you the bytes.
Uploading files
Multipart operations such as uploadImage and importCatalog take a Blob. Pass a File, which
carries a name and a type:
import { readFile } from 'node:fs/promises'
const file = new File([await readFile('logo.png')], 'logo.png', { type: 'image/png' })
const image = await new ImagesApi(client).uploadImage({ tenantId: 'acme-corp', catalogId: 'main', file, name: 'Logo' })A plain Blob is always sent with the filename blob, and as application/octet-stream unless it
was given a type. The server then names the image blob.
Error handling
Catch ProblemDetailException and switch on typeSlug against KnownProblemSlugs. The slug list is
open — the API can introduce new problem types without a client release — so always keep a
default branch and fall back to the HTTP status for unrecognized types.
import { KnownProblemSlugs, ProblemDetailException, ResponseError, TenantsApi } from '@epistola.app/epistola-client'
try {
await new TenantsApi(client).getTenant({ tenantId: 'acme' })
} catch (e) {
if (e instanceof ProblemDetailException) {
switch (e.typeSlug) {
case KnownProblemSlugs.NOT_FOUND:
log.warn(`tenant not found: ${e.detail}`)
break
case KnownProblemSlugs.FORBIDDEN:
throw new PermissionError(e.detail)
case KnownProblemSlugs.VALIDATION_ERROR:
for (const err of e.errors) log.warn(`${err.field}: ${err.message}`) // field-level ValidationProblemDetail
break
case KnownProblemSlugs.DATA_MODEL_VALIDATION_ERROR:
for (const [example, failures] of Object.entries(e.validationErrors)) { // 422 data-model failures
for (const f of failures) log.warn(`${example} ${f.path}: ${f.message}`)
}
break
default:
throw e // unknown / framework error — fall back to e.statusCode
}
} else if (e instanceof ResponseError) {
// an error response without a problem document
}
}ProblemDetailException extends the generated ResponseError, so existing instanceof
ResponseError sites keep working. It exposes type, typeSlug, title, problemStatus,
detail, instance, errors, validationErrors, extensions (every member outside the RFC 9457
base five), statusCode, rawBody, isValidationProblem, and isDataModelValidationProblem.
Result collection
import { ResultCollector } from '@epistola.app/epistola-client'
const collector = ResultCollector.builder()
.client(client)
.tenantId('acme')
.handler(async (result) => console.log(result.requestId, result.status))
.build()
await collector.start() // adaptive polling; resolves after collector.stop()Results stream in one at a time and the batch is acknowledged only after every result in it was
handled: a handler that throws leaves the batch unacknowledged, so it is redelivered. gzip is always
decoded; zstd is offered and decoded where Node's zlib has it (22.15+). partitionFor,
isMyPartition and routingKeyToMe compute, from the assignment the server reports, a routing key
whose result comes back to this node.
Client-side validation
Validating template data against the template's JSON Schema needs Ajv, which is an optional peer dependency so that consumers who never validate do not carry a schema compiler:
npm install ajv ajv-formatsWithout them, the first validate call rejects with an error saying exactly that. The generated
validate<Model> helpers for the contract's own constraints need nothing extra.
import { TemplateSchemaValidator, ValidatingGenerationApi, validateCreateTenantRequest } from '@epistola.app/epistola-client'
// Data against the template's JSON Schema, fetched once and cached (TtlSchemaCache, 5 minutes).
const validator = new TemplateSchemaValidator(templatesApi)
await validator.validate('acme', 'invoices', 'invoice', data) // throws TemplateDataValidationException
// Or transparently, before every generation call:
const generation = new ValidatingGenerationApi(new GenerationApi(client), templatesApi)
// A request model against the constraints the contract declares on it (slug patterns, lengths, ranges):
validateCreateTenantRequest({ id: 'Acme Corp', name: 'Acme' }) // throws ModelValidationExceptionDevelopment
The client is generated from the bundled spec. From the repository root:
make bundle # produce openapi.yaml
cd contracts/api/clients/nodejs-fetch
pnpm install --frozen-lockfile
./generate.sh # stock client + derived sources into src/generated/
pnpm build # dist/
pnpm test # node:test, against the compiled treesrc/generated/ (the stock client under api/, and the derived contractVersion,
contractIdentity, contractMediaTypes, contractOperations, knownProblemSlugs and
modelValidation) is gitignored and rebuilt from the spec each time. The cross-client conformance
suite drives this client with make conformance-node.
