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. - Template data validation (
TemplateSchemaValidator), asking the server, with a pluggableTemplateDataValidatorfor consumers who want the check in-process; 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 and no optional ones: HTTP is the platform's fetch, JWT signing
is node:crypto, decompression is node:zlib. Template data validation asks the server rather than
compiling schemas here, so there is no longer a JSON Schema compiler to install (see
Template data validation).
Release history: CHANGELOG.md.
Install
npm install @epistola.app/epistola-client
Quick 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({ tenantKey: 'acme', registry: '~', namespace: 'acme', catalogKey: 'invoices', release: '1.4.0', templateKey: 'invoice' })
A catalog is addressed by its registry, namespace and catalogKey. registry: '~' addresses the
server's default registry; any other registry is named in full (registry: 'acme.com').
EpistolaClient.builder(baseUrl, apiKey) is a shorthand for the same thing. 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 an
omitted or null member keeps its value, because most generated servers cannot tell the two apart:
updateConsumer({ ..., updateConsumerRequest: { name: 'Billing' } }) renames the consumer and
touches nothing else, and so does { name: 'Billing', description: null }. To remove a value, send
an empty string for an optional text member ({ description: '' }), an empty array or object to
replace a list or a map with nothing, or the member's explicit flag ({ clearExpiresAt: true },
clearDefaultTheme, clearCodeListBinding).
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({ tenantKey: 'acme-corp', registry: '~', namespace: 'acme-corp', catalogKey: 'main', release: 'k7m2x9qa', 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.
Newer servers
A minor version of the contract may add response members and enum values ("Compatibility within
2.x" in the API description), so this client keeps working against a later 2.x server: a response
member it does not know is ignored, and an enum value it does not know is kept as the string
received, though the generated type does not list it. Keep a default branch when switching on an
enum.
What a client ignores it cannot send back. A request built from a response carries only the members
this client knows. That matters where a write replaces what was read: a template document read with
getVariantContent and written back with updateVariantContent loses every member a newer catalog wire version added.
Edit template documents with a client at least as new as the server, and change a consumer's
permissions by sending only the resources you change, never the object you read.
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({ tenantKey: 'acme' })
} catch (e) {
if (e instanceof ProblemDetailException) {
switch (e.typeSlug) {
case KnownProblemSlugs.NOT_FOUND:
log.warn(`tenant not found: ${e.detail}`)
break
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
case KnownProblemSlugs.RELEASE_DEPENDENCIES_MISSING:
for (const d of e.missingDependencies) { // 409 from deployCatalogRelease
log.warn(`${d.registry}/${d.namespace}/${d.catalogKey}@${d.version}: ${d.reason}`)
}
break
case KnownProblemSlugs.RELEASE_DEPENDENCIES_UNRELEASED:
for (const d of e.unreleasedDependencies) { // 409 from releaseCatalogDraft
log.warn(`${d.registry}/${d.namespace}/${d.catalogKey}: draft ${d.releaseName} (${d.releaseId})`)
}
break
case KnownProblemSlugs.CATALOG_READ_ONLY:
// any write to a catalog that cannot be changed here
log.warn(`catalog cannot be changed here: ${e.readOnlyReason}`)
break
case KnownProblemSlugs.MERGE_CONFLICTS_UNRESOLVED:
for (const conflict of e.unresolvedConflicts) log.warn(`needs ours or theirs: ${conflict}`) // mergeIntoCatalogDraft (alpha)
break
case KnownProblemSlugs.MERGE_PREVIEW_OUTDATED:
log.warn('the draft or the release changed since the preview: preview again') // expectedToken (alpha)
break
case KnownProblemSlugs.MERGE_BASE_MISSING:
// the merge base is not installed here: install it first, then merge again
log.warn(`install ${e.missingBaseVersion} (${e.missingBaseFingerprint}) first`)
break
case KnownProblemSlugs.CATALOG_VERSION_BEHIND:
log.warn(`the draft is behind ${e.behind.join(', ')}`) // releaseCatalogDraft: name them in acknowledgedBehind
break
case KnownProblemSlugs.CATALOG_DRAFT_IN_USE:
// deleteCatalogRelease: drafts of other catalogs name the draft, or it alone holds resources
// something outside it uses (resourceType/resourceKey); change those first
log.warn(`named by ${e.dependents.map((d) => `${d.catalogKey}: ${d.releaseName}`).join(', ')}; holds ${e.heldResources.join(', ')}`)
break
case KnownProblemSlugs.AMBIGUOUS_VARIANT:
log.warn(`several variants match: ${e.tiedVariants.join(', ')}`) // generateDocument, previewDocument: name one by variantKey
break
case KnownProblemSlugs.FORBIDDEN:
// any refused authorization: a permission in a tenant, access to a tenant, a platform role,
// or a policy (refusalReason), each read from this problem only
log.warn(`refused: missing ${e.requiredPermission} in ${e.refusedTenantKey}`)
break
case KnownProblemSlugs.CATALOG_RENDERING_DEFAULTS_TOO_NEW:
// importCatalog / installCatalogRelease: the release renders with rendering defaults this
// installation does not have
log.warn(`upgrade Epistola: the release needs ${e.extensions.renderingDefaultsVersion}, this has up to ${e.extensions.supportedVersion}`)
break
case KnownProblemSlugs.RELEASE_NUMBER_TAKEN:
// publishReleaseToRegistry (alpha): the registry has this version with other content;
// importCatalog, installCatalogRelease, installRegistryRelease: the tenant has this version
// with another fingerprint, or this release id as another release
log.warn('release the changes under a new version')
break
case KnownProblemSlugs.CATALOG_SOURCE_UNAVAILABLE:
// installCatalogRelease / listAvailableCatalogReleases: 503 when the catalog's source could
// not be reached (Retry-After may say when), 502 when it answered badly
log.warn(`catalog source unavailable (${e.problemStatus}): ${e.detail}`)
break
default:
throw e // a type this client does not know: handle it by e.problemStatus
}
} else if (e instanceof ResponseError) {
// an error response without a problem document
}
}
Each field error's code says why the field failed (min-length, stencil-recursion,
catalog-resources-not-ready, ...), or is undefined when the server sent none. Compare it against
KnownValidationCodes, generated from the contract's registry, and keep a default branch: new
codes may come in a minor version.
ProblemDetailException extends the generated ResponseError, so existing instanceof ResponseError sites keep working. It exposes type, typeSlug, title, problemStatus,
detail, instance, help (the type's page with this request's context, such as
?operation=getTemplate, or undefined: switch on the type, not on this, and open it for hints),
errors, validationErrors, missingFields, invalidFields,
missingDependencies, unreleasedDependencies, readOnlyReason, unresolvedConflicts,
isMergeConflictsUnresolvedProblem, missingBaseVersion, missingBaseFingerprint,
isMergeBaseMissingProblem, behind, isCatalogVersionBehindProblem, environments,
pinnedBy, isReleaseInUseProblem, dependents, heldResources, isCatalogDraftInUseProblem,
tiedVariants, isAmbiguousVariantProblem,
requiredPermission, refusedTenantKey, requiredPlatformRole, refusalReason,
isForbiddenProblem, extensions (every member
outside the base members, RFC 9457's five and help), statusCode, rawBody, isValidationProblem,
isDataModelValidationProblem, isTemplateDataProblem, isReleaseDependenciesMissingProblem,
isReleaseDependenciesUnreleasedProblem, and isCatalogReadOnlyProblem.
A release-dependencies-unreleased problem (409) means releaseCatalogDraft was refused because
the draft's dependency declarations name drafts of other catalogs, and a release pins only
releases. unreleasedDependencies lists each such draft (registry, namespace, catalogKey,
releaseId, releaseName); release them first, or send releaseDependencies: true to release them
along with the draft.
A catalog-draft-in-use problem (409) means deleteCatalogRelease was refused because something
outside the draft still relies on it. dependents lists the drafts of other catalogs whose
dependency declarations name it (registry, namespace, catalogKey, releaseId, releaseName),
and heldResources the resources only it holds that something outside it still uses, as
resourceType/resourceKey (theme/house-style); change those first.
A release-dependencies-missing problem (409) means deployCatalogRelease was refused because releases
of other catalogs the release pins cannot be had: the tenant does not have them and they could not
be fetched or verified. missingDependencies lists each, with a reason kept as the string the
server sent (compare it against MissingReleaseDependencyReason). The dependency releases are used
implicitly; pass includeDependencies: true to deploy them explicitly as well:
await new EnvironmentsApi(client).deployCatalogRelease({
tenantKey: 'acme', environmentKey: 'production',
registry: '~', namespace: 'acme', catalogKey: 'invoices', release: '1.4.0',
includeDependencies: true,
})
Result collection
import { ResultCollector } from '@epistola.app/epistola-client'
const collector = ResultCollector.builder()
.client(client)
.tenantKey('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.
Template data validation
Template data is checked by Epistola, through validateTemplateData. Nothing is compiled here,
so there is no schema compiler to install, and the verdict cannot disagree with what generation will
do — the server validates the same data when a job is submitted, and it also knows which optional
fields the release being rendered actually reads.
import { ServerTemplateDataValidator, TemplateSchemaValidator, ValidatingGenerationApi, validateCreateTenantRequest } from '@epistola.app/epistola-client'
const validator = new TemplateSchemaValidator(templatesApi)
// tenant, catalog registry (`~` for the server's default, or in full: 'acme.com'), namespace, catalog key, template
await validator.validate('acme', '~', 'acme', 'invoices', 'invoice', data) // throws TemplateDataValidationException
// Against a particular release: a version range, an environment, or both together
const pinned = new TemplateSchemaValidator(
new ServerTemplateDataValidator(templatesApi, { versionRange: '^1.4.0', environmentKey: 'production' }),
)
// Or around generation, which turns the server's `template-data-invalid` problem into the same error:
const generation = new ValidatingGenerationApi(new GenerationApi(client), templatesApi)
// A request model against the constraints the contract declares on it (key patterns, lengths, ranges):
validateCreateTenantRequest({ key: 'Acme Corp', name: 'Acme' }) // throws ModelValidationException
Each failure carries a JSON Pointer into the data (/customer/email), the JSON Schema keyword
that failed, and a human-readable message — so a form can mark the field that is wrong.
Validating in-process instead
To avoid a request per check — pre-flighting a large batch, say — implement TemplateDataValidator
over the compiler of your choice and pass it instead of the API:
const validator = new TemplateSchemaValidator(new MyAjvValidator(templatesApi, '1.4.0'))
const generation = new ValidatingGenerationApi(new GenerationApi(client), new MyAjvValidator(templatesApi, '1.4.0'))
A validator that answers in-process declares preflightsGeneration = true (the default), so
ValidatingGenerationApi checks before submitting and reports every item of a batch at once, each
path prefixed items[<index>]. TtlSchemaCache is still exported for caching the fetched schema;
without it a "local" validator costs a round trip per call anyway. Templates live in releases of
their catalog, so a local validator fetches its schema from a release you name (1.4.0 above): name
the release you will render, since the server validates against that release, not against a draft.
Two things an implementation owes its callers, both described on TemplateDataValidator: an empty
array means the data is acceptable, and path is a JSON Pointer. Compilers do not agree on that
last point — Ajv's instancePath is already a pointer but names a missing property separately,
while other engines emit a JSONPath — so converting is the adapter's job. A worked Ajv adapter,
roughly forty lines, is in test/validation/local/ajvTemplateDataValidator.ts; copy it.
Be aware that a local verdict is its own: format assertion, dialect coverage and unknown-keyword
handling are the compiler's choices, so it can accept data Epistola will refuse, or refuse data
Epistola would render.
Development
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 tree
src/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.