Skip to main content

API Document

Overview

ApiDocument is the root object of an OPRA application. It holds three things together:

  • A type registry — all named data types available to the API
  • A reference map — linked external documents accessible under namespace aliases
  • An API definition — the HTTP, MQ, or WebSocket transport configuration

Every field type, every operation parameter, and every response body is ultimately resolved through an ApiDocument. You create one at application startup and pass it to the transport adapter.


Creating a Document

Use ApiDocumentFactory.createDocument() to build an ApiDocument asynchronously. The factory resolves all type references, validates the schema, and returns a fully initialised document.

→ Full API: ApiDocument

import { ApiDocumentFactory } from '@opra/common';

const document = await ApiDocumentFactory.createDocument({
info: {
title: 'Customer API',
version: '1.0',
},
types: [Customer, Address, Gender],
api: {
transport: 'http',
name: 'CustomerApi',
url: '/api',
controllers: [CustomersController],
},
});

createDocument accepts either an init object (shown above) or a URL pointing to a remote OPRA schema JSON.

// Load from a remote schema URL
const document = await ApiDocumentFactory.createDocument('https://api.example.com/opra.json');

Init Options

The init object passed to createDocument mirrors the Schema structure but accepts TypeScript classes and instances directly instead of raw JSON.

FieldTypeDescription
infoDocumentInfoHuman-readable metadata. → See: Schema > Document Info
typesDataTypeInitSourcesTypes to register. Accepts a class, array, or record.
referencesRecord<string, ReferenceThunk>Linked external documents keyed by namespace alias.
apiHttpApi | MQApi | WSApiAPI transport configuration.

info

const document = await ApiDocumentFactory.createDocument({
info: {
title: 'Customer API',
version: '1.0',
description: 'Manages customer records and orders.',
contact: [{ name: 'API Support', email: '[email protected]' }],
license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' },
},
});

types

Types can be provided as an array of classes or as a record keyed by type name:

// Array form — name is derived from each class
const document = await ApiDocumentFactory.createDocument({
types: [Customer, Address, Gender],
});

// Record form — explicit name override
const document = await ApiDocumentFactory.createDocument({
types: {
Customer,
ShippingAddress: Address,
},
});

Any class decorated with @ComplexType(), @SimpleType(), or registered via EnumType() is accepted.


Registering Types

Types can be registered at multiple levels of the document hierarchy. A type is accessible from the node where it is registered and from all of its descendants — but not from parent or sibling nodes.

ApiDocument → types visible everywhere in the document
└── api → types visible to all controllers and operations
└── HttpController → types visible to all operations in this controller
└── HttpOperation → types visible only within this operation

Root-level types

Types registered at the root are available everywhere in the document. This is the right place for shared domain types used across multiple controllers or operations.

import { ComplexType, ApiField } from '@opra/common';

@ComplexType({ description: 'Customer record' })
class Customer {
@ApiField({ readonly: true }) declare _id?: number;
@ApiField({ required: true }) declare givenName: string;
@ApiField({ required: true }) declare familyName: string;
@ApiField() declare email?: string;
}

@ComplexType()
class Address {
@ApiField({ required: true }) declare city: string;
@ApiField() declare street?: string;
}

const document = await ApiDocumentFactory.createDocument({
// Available to every controller and operation in the document
types: [Customer, Address],
api: { ... },
});

Controller-level types

Types declared on a controller are accessible only within that controller and its operations. Other controllers cannot see them. Use .UseType() on the @HttpController() decorator to register types at controller scope.

import { ComplexType, ApiField, HttpController, HttpOperation } from '@opra/common';

@ComplexType()
class CustomerFilter {
@ApiField() declare givenName?: string;
@ApiField() declare familyName?: string;
@ApiField() declare email?: string;
}

// CustomerFilter is only visible within CustomersController and its operations
@(HttpController({ path: '/customers' })
.UseType(CustomerFilter))
export class CustomersController {
// operations ...
}

// CustomerFilter is NOT accessible here
@HttpController({ path: '/orders' })
export class OrdersController {
// operations ...
}

const document = await ApiDocumentFactory.createDocument({
types: [Customer], // shared — visible everywhere
api: {
transport: 'http',
name: 'CustomerApi',
controllers: [CustomersController, OrdersController],
},
});

Operation-level types

Types declared on an operation are scoped to that single operation — they are not visible to sibling operations or the parent controller. Use .UseType() on the @HttpOperation() decorator.

import { ComplexType, ApiField, HttpController, HttpOperation } from '@opra/common';

@ComplexType()
class SearchResult {
@ApiField() declare items?: Customer[];
@ApiField() declare total?: number;
}

@HttpController({ path: '/customers' })
export class CustomersController {

// SearchResult is only visible within this operation
@(HttpOperation.Get()
.UseType(SearchResult)
.Response(200, { type: SearchResult }))
async search(ctx: HttpContext) {
// ...
}

@HttpOperation.Get('/:id')
async get(ctx: HttpContext) {
// SearchResult is NOT accessible here
}
}

Lookup and scoping rules

When resolving a type name, OPRA walks up the hierarchy from the current node until it finds a match:

  1. Current node's types
  2. Parent node's types
  3. … up to the root document's types
  4. Built-in types in the opra namespace

This means a child node can shadow a root-level type by registering a type with the same name — the nearest definition wins.

// Verify registration
document.types.has('Customer'); // true
document.types.has(Customer); // true

Built-in primitive types (string, number, boolean, date, …) are always available — they are registered automatically in the internal opra namespace and do not need to be listed in types.


References & Namespaces

Use references to link external ApiDocument instances into the current document. Each reference is assigned a namespace alias; types from the linked document are then accessible as namespace:TypeName.

import { ApiDocumentFactory } from '@opra/common';
import { CustomerModelsDocument } from './customer-models/index.js';

const document = await ApiDocumentFactory.createDocument({
info: { title: 'Order API', version: '1.0' },
references: {
// 'cm' becomes the namespace alias for the linked document
cm: () => CustomerModelsDocument.create(),
},
api: {
transport: 'http',
name: 'OrderApi',
controllers: [OrdersController],
},
});

Each value in references is a thunk — a zero-argument function returning a Promise<ApiDocument>. Thunks are resolved lazily during createDocument.

Reference types are accessed by prefixing the type name with the namespace:

// Resolves 'Customer' from the 'cm' namespace
const customerType = document.node.getDataType('cm:Customer');

Type Lookup

After creation, resolve types at runtime through document.node:

// Returns undefined if not found
const type = document.node.findDataType('Customer');

// Throws if not found
const type = document.node.getDataType('Customer');

// Typed variants — throw if the type exists but is the wrong kind
const complexType = document.node.getComplexType('Customer');
const simpleType = document.node.getSimpleType('email');
const enumType = document.node.getEnumType('Gender');
const arrayType = document.node.getArrayType('Tags');

All lookup methods accept a string name, a constructor reference, or an enum object:

document.node.getDataType('Customer'); // by name
document.node.getDataType(Customer); // by constructor
document.node.getDataType(Gender); // by enum object

An optional scope argument filters by scope pattern:

// Only resolves if the type is visible in the 'public' scope
const type = document.node.getDataType('Customer', 'public');

Codec Generation

Every DataType exposes generateCodec() to produce a validator function from the valgen library. Call it on a resolved type to get an encoder or decoder.

const customerType = document.node.getComplexType('Customer');

// Decoder — validates and coerces inbound request data
const decode = customerType.generateCodec('decode');

// Encoder — validates and serializes outbound response data
const encode = customerType.generateCodec('encode');

const customer = decode({ givenName: 'Jane', familyName: 'Doe' });

Codec options

OptionTypeDescription
scopestringApply field visibility rules for the given scope.
partialboolean | 'deep'Treat all fields as optional. Useful for PATCH operations.
projectionstring[] | '*'Limit which fields are included in the output.
ignoreReadonlyFieldsbooleanStrip readonly fields from the input (decode only).
ignoreWriteonlyFieldsbooleanStrip writeonly fields from the output (encode only).
allowPatchOperatorsbooleanAccept patch operator expressions (e.g. $set, $unset).
// Decode a PATCH body — all fields optional, readonly fields stripped
const patchDecode = customerType.generateCodec('decode', {
partial: true,
ignoreReadonlyFields: true,
});

// Encode for the 'public' scope — hides fields restricted to 'db' or 'admin'
const publicEncode = customerType.generateCodec('encode', {
scope: 'public',
ignoreWriteonlyFields: true,
});

Exporting the Schema

Serialize the document to its JSON schema representation with export() or toJSON():

const schema = document.export();
// → OpraSchema.ApiDocument — plain JSON-serializable object

const json = JSON.stringify(document); // calls toJSON() internally

The exported object matches the Schema format exactly and can be written to a file, served over HTTP, or used to initialise a client-side document.

import { writeFileSync } from 'node:fs';

writeFileSync('opra.json', JSON.stringify(document.export(), null, 2));

An optional scope filter limits which types and fields appear in the export:

// Export only what is visible in the 'public' scope
const publicSchema = document.export({ scope: 'public' });