A typed API layer on top of @lcf.vs/collection.
@lcf.vs/collection-api projects application records and API affordances to Collection+JSON 1.0 documents. Standard Collection+JSON members are emitted according to the 1.0 specification; relative URI references supplied to the API layer are resolved to absolute URIs before they are exposed.
The standard Collection+JSON vocabulary is preserved unchanged: collection, items, data, links, queries, template, error, their standard properties, and version 1.0 keep their specification-defined meaning.
@lcf.vs/collection also provides additive foreign markup. collection-api preserves these extensions instead of removing them, including:
collection.scriptsand script metadata such assrc,type, andintegrity;error.details;- link metadata such as
typeandintegrity; query.linkTemplatesandtemplate.linkTemplatesfor link-oriented input metadata (declared withwithLinkTemplates(...)).
Clients that do not understand these extensions can ignore them while still consuming the standard Collection+JSON representation. Link-oriented input descriptors are deliberately exposed as linkTemplates rather than links, so the extension does not redefine the standard links member.
For standard Collection+JSON output, collection-api also guards the constraints that matter at projection time: collection version 1.0, absolute URI output for href, required query/link relations, valid render values, required data names, and Collection+JSON scalar values (string, finite number, boolean, or null).
Final projected documents are also passed through @lcf.vs/collection's canonical validate() function, so the base package remains the single authority for Collection+JSON 1.0 output conformance.
collection-api preserves foreign markup provided by @lcf.vs/collection, including extensions declared by consumers. A field, link, query, template, script, or root collection can therefore carry a custom extension through projection without collection-api needing to know its vocabulary.
import * as collection from '@lcf.vs/collection'
import api from '@lcf.vs/collection-api'
const permissions = collection.extension({
name: 'permissions',
validate (value, { path }) {
if (!value || typeof value !== 'object' || typeof value.read !== 'boolean') {
throw new TypeError(`${path} must contain a boolean read property`)
}
}
})
const fields = {
id: collection.property.withName('id'),
displayName: collection.property
.withName('name')
.withExtension(permissions, { read: true })
}
const target = api
.withCollection(api.collection
.withHref('https://api.example.test')
.withExtension(permissions, { read: true }))
.withType({
fields,
name: 'accounts'
})
const result = target.types().accounts.items(
'https://api.example.test/accounts',
[{ id: 1, displayName: 'Lucie' }]
)
collection.validate(result, {
extensions: [permissions]
})collection-api always validates the standard Collection+JSON contract itself. Third-party extension semantics remain opt-in and descriptor-driven: validate a final or parsed representation with collection.validate(document, { extensions }) when those extension contracts must also be enforced. No global extension registration is involved.
Generated structures also expose extension injection points instead of forcing foreign metadata into data or links:
withType({ typeExtensions })adds static foreign markup to the generated type collection;withType({ itemExtensions(record) })adds per-record foreign markup to each generated item;type.items(href, records, resultExtensions)adds foreign markup to a projected result collection (for example pagination/cursors);api.error(href, error, errorExtensions)adds foreign markup to the generated error object;- each source error detail may include an
extensionsobject for foreign markup on that detail.
The extension maps use the descriptor name as their key:
const target = api
.withCollection(api.collection.withHref('https://api.example.test'))
.withType({
fields,
name: 'accounts',
typeExtensions: {
[permissions.name]: { read: true }
},
itemExtensions (record) {
return {
[permissions.name]: {
read: record.visible
}
}
}
})
const result = target.types().accounts.items(
'https://api.example.test/accounts',
records,
{
[pagination.name]: {
next: 'cursor-2'
}
}
)collection-api does not require application records to be plain JavaScript objects. The default adapter reads instance[key] and reconstructs a plain object, but a type can instead receive an immutable adapter created with api.instanceAdapter(...):
const map = api.instanceAdapter({
construct (values) {
return new Map(Object.entries(values))
},
read (instance, key) {
return instance.get(key)
}
})
const target = api
.withCollection(api.collection.withHref('https://api.example.test'))
.withType({
adapter: map,
fields: {
id: collection.property.withName('id'),
displayName: collection.property.withName('name'),
githubUrl: collection.link.withRel('github')
},
name: 'accounts'
})The same fields mapping is bidirectional:
const accounts = target.types().accounts
const record = new Map([
['id', 1],
['displayName', 'Lcf.vs'],
['githubUrl', 'https://github.com/Lcfvs']
])
const document = accounts.items(accounts.collection.href, [record])
const rebuilt = accounts.instance(document)items(...) reads application instances through adapter.read(instance, key, context). instance(...) decodes one Collection+JSON item or template and calls adapter.construct(values, context). instances(...) reconstructs all items in a Collection+JSON collection document (or an array of items). Known fields are mapped back to their source keys; unknown Collection+JSON fields are ignored so a newer producer does not automatically break an older consumer.
For a write request, a standard top-level template document can be materialized directly:
const account = accounts.instance(parsedRequest)where parsedRequest has the standard Collection+JSON write form { "template": { ... } }. A one-item collection document can likewise be passed directly to instance(...). A standalone parsed template whose shape is otherwise indistinguishable from an item can be made explicit with instance(template, { kind: 'template' }); options.extensions is forwarded to collection.validate() as usual.
Adapters are deliberately model-agnostic. An Etched integration can close over an Etched model without either package knowing about the other:
const etchedAccount = api.instanceAdapter({
construct (values) {
return fulfill(Account, values)
},
read (instance, key) {
return instance[key]
}
})The same mechanism works for classes, factory functions, Maps, ORM entities, immutable records, or any other object model. construct and read must currently be synchronous; collection-api never coerces or normalizes their values. The adapter descriptor itself is frozen and there is no global adapter registry.
The adapter callbacks receive frozen context objects. read receives { field, key, kind, name }, where kind is property, link, or identity. construct receives { document, fields, name, source }. This permits advanced adapters to use field metadata while keeping the default path trivial.
Each generated type exposes adapter() for backwards-compatible instance-adapter introspection, alongside runtime(), typeHref(), itemHref(), buildHref(), parseHref(), fields(), queries(), name(), items(), instance(), and instances().
Runnable example: npm run example-adapter.
URI construction is independent from instance construction. api.hrefAdapter(...) creates an immutable adapter for an arbitrary URI model, while api.href(model, values?) creates an immutable URI contract. The optional values(source, context) callback explicitly selects the values required by that URI model; collection-api never derives URI parameters implicitly from fields.
const routes = api.hrefAdapter({
build (model, values) {
return values === undefined
? model.toString()
: model.fill(values).toString()
},
parse (model, href) {
return model.parse(href)
}
})
const accountHref = api.href(
accountById,
(account, { read }) => ({
segments: {
id: read(account, 'id')
}
})
)Every URI adapter exposes synchronous build; parse is optional. api.hrefAdapter() supplies the historical string/function builder when build is omitted, while custom URI-model adapters normally provide it explicitly. The model itself is opaque to collection-api. The adapter decides how a model is built or parsed, while collection-api still resolves the returned URI reference and validates the observable Collection+JSON URI.
The selector receives a frozen context containing the URI base, label, model, source, type name when available, and a read(instance, key, context?) helper backed by the active instance adapter. This keeps the three views independent:
application instance --instance adapter--> selected URI values
Collection+JSON fields --------------------> representation only
URI model + selected values --------------> href adapter --> URI
Omitting the values selector passes undefined to the adapter. This is useful for already-complete/static URI models and deliberately avoids silently passing every application field to URI construction.
Instance and URI adapters remain separate capabilities, but may be grouped in an immutable local runtime:
const etchedRuntime = api.runtime({
instances: etchedInstances,
errors: etchedErrors, // optional; defaults to instances
hrefs: etchedUrls
})
const target = api
.withRuntime(etchedRuntime)
.withCollection(api.collection.withHref('https://api.example.test'))
.withType({
fields,
itemHref: accountHref,
name: 'accounts'
})api.runtime() returns the active runtime. Calling api.runtime(options) creates a new runtime descriptor. withRuntime(runtime) returns a new API value and must be used before declaring types, so one API graph cannot silently mix runtime semantics. A type may still override only its instance adapter with the existing withType({ adapter }) option; its URI adapter remains the runtime URI capability.
There is no global runtime or adapter registry. Different API values can use different runtimes in the same process. errors uses the same generic adapter protocol as instances and defaults to the selected instances adapter. It can be different when domain objects and application errors use different object models. Runtime keys other than instances, errors, and hrefs are preserved untouched, so the same immutable runtime value may carry capabilities understood by other interpreters (for example diagnostics or serialization) without collection-api owning their semantics.
The historical callback form remains supported:
typeHref(name, rootHref, context)
itemHref(instance, typeHref, context)An api.href(...) descriptor is the reusable alternative. Generated types expose typeHref() and itemHref() for introspection, buildHref(descriptor, source, context?), parseHref(descriptor, uri, context?), and runtime().
URI descriptors can be used anywhere collection-api resolves an URI, including type and item hrefs, type links, query hrefs, link-template hrefs, scripts, generated result collection hrefs, field links, root affordances, and error target hrefs. For example, a link field can own its URI contract instead of requiring the application instance to contain a pre-built URL:
const avatarHref = api.href(
avatarByAccount,
(account, { read }) => ({
segments: {
account: read(account, 'id')
}
})
)
const fields = {
id: collection.property.withName('id'),
avatar: collection.link
.withHref(avatarHref)
.withRel('avatar')
}If a link field has no configured href, the historical behaviour remains: its source value is read through the instance adapter and interpreted as the link URI. That value may itself be an api.href(...) descriptor.
@etchedjs/url fits this mechanism without any dependency or special case in collection-api: its models expose fill(...), parse(...), and toString(), so an adapter is only a few lines:
import url from '@etchedjs/url'
const etchedUrls = api.hrefAdapter({
build (model, values) {
return values === undefined
? model.toString()
: model.fill(values).toString()
},
parse (model, href) {
return model.parse(href)
}
})A single URL model can therefore be shared by server routing code, Collection+JSON affordances, clients, documentation, tests, or another runtime without duplicating route strings. collection-api only exposes the build/parse capability; it does not become a router.
Runnable generic example: npm run example-href.
Collection+JSON defines the occurrence of an error (code, title, message). collection-api can additionally describe which application errors an affordance is allowed to produce without inventing a universal business taxonomy.
api.errorType(...) creates an immutable local contract:
const invalidAccount = api.errorType({
code: 'INVALID_ACCOUNT',
details: [fields.displayName, fields.githubUrl],
validate (error, { read }) {
if (typeof read(error, 'message') !== 'string') {
throw new TypeError('message must be a string')
}
}
})details is optional. As an array, its entries may be Collection+JSON detail-name strings or existing field descriptors; property name and link name/rel are converted to the observable detail names. When declared, a producer cannot emit a detail referring to another field.
When application field keys differ from their observable names, use the mapping form so error details remain independent from the Collection+JSON projection too:
const invalidAccount = api.errorType({
code: 'INVALID_ACCOUNT',
details: {
displayName: fields.displayName, // occurrence key -> data name "name"
githubUrl: fields.githubUrl // occurrence key -> link rel "github"
}
})An occurrence may then contain details.displayName, while the wire representation contains { "name": "name", ... }; errorInstance() maps it back to displayName on reconstruction.
Error types have no global registry. errorType.of(value) explicitly associates an arbitrary application occurrence with its contract:
const occurrence = fulfill(InvalidAccount, {
message: 'Invalid account',
details: {
name: fulfill(InvalidField, {
message: 'Invalid account name'
})
}
})
const response = accounts.error(
accounts.queries().create,
invalidAccount.of(occurrence)
)The occurrence does not need to carry code; the declaration owns that protocol identity. Passing the projected query object rather than only its href also selects that query's producer error contract.
A type can declare a default error set:
api.withType({
errors: [invalidAccount, accountConflict],
fields,
name: 'accounts'
})or narrower sets for individual queries without introducing another operation ontology:
api.withType({
fields,
name: 'accounts',
queries,
queryErrors: {
create: [invalidAccount, accountConflict],
searchByName: [invalidAccount]
}
})Each configured registry is immutable and prototype-free. api.errorTypes([...]) exposes the same registry constructor when a consumer needs to compose one directly. A projected query with declarations exposes query.errors() at runtime; functions are not serialized, so the Collection+JSON representation remains standard. Applications that want to publish error declarations can expose their own documentary foreign-markup extension.
Producer validation is deliberately closed once a relevant set is declared: type.error(...) rejects an undeclared code/type. With no declaration, the historical generic error projection remains available.
Consumer validation is deliberately open. type.errorInstance(document) resolves a known query/type contract from the error document URI and code when possible, validates it, and reconstructs the configured application error model. A future unknown server code remains consumable as a generic Collection+JSON error instead of breaking an older client.
const error = accounts.errorInstance(received)A specific descriptor may also be forced with errorInstance(source, { type }).
Errors are not required to be native Error objects or POJOs. errorType({ adapter }) accepts the same generic adapter created by api.instanceAdapter(...):
const etchedErrors = api.instanceAdapter({
construct (values) {
return fulfill(ApiError, values)
}
})
const invalidAccount = api.errorType({
adapter: etchedErrors,
code: 'INVALID_ACCOUNT'
})Because the default read(instance, key) is ordinary property access, an Etched model exposing its fulfilled values as properties only needs construct. Classes, Maps, factories, ORM objects, or another model system work the same way.
For a whole API runtime:
const runtime = api.runtime({
instances: etchedInstances,
errors: etchedErrors,
hrefs: etchedUrls
})If errors is omitted it defaults to instances; an individual errorType may still override it. A per-type withType({ adapter }) override changes that type's domain-instance adapter only; error occurrences continue to use runtime.errors unless their errorType overrides it explicitly. Error details may be an object keyed by detail name, a Map, or an array of detail instances. Detail instances are read through the same selected error adapter.
Validation never translates error codes to HTTP statuses, retry policy, severity, localization, authorization, trace IDs, or coercion. Those remain application policy or optional extensions. The same INVALID_ACCOUNT contract can therefore be used over HTTP, Discord, a queue, or in-memory calls.
import * as collection from '@lcf.vs/collection'
import api from '@lcf.vs/collection-api'
const fields = {
id: collection.property
.withName('id')
.withPrompt('Account ID'),
displayName: collection.property
.withName('name')
.withPrompt('Account name'),
githubUrl: collection.link
.withPrompt('Account GitHub')
.withRel('github')
}
const queries = {
create: collection.query
.withData([fields.displayName])
.withLinkTemplates([fields.githubUrl])
.withHref('/accounts/create')
.withPrompt('Create an account')
.withRel('create')
}
const scripts = [
collection.script
.withSrc('/accounts.js')
]
const template = collection.template
.withData([
fields.id,
fields.displayName
])
.withLinkTemplates([
fields.githubUrl
])
const result = api
.withCollection(api.collection
.withHref('https://api.localhost')
.withLinks([
collection.link
.withHref('https://docs.localhost')
.withRel('help')
]))
.withType({
fields,
name: 'accounts',
queries,
scripts,
template
})The key in fields is the source key passed to the type adapter. With the default adapter it is an application object property; a custom adapter may interpret it differently. The descriptor controls the Collection+JSON representation, so a source key such as displayName can intentionally be projected as a data element named name.
types() is an immutable, prototype-free registry. Each type exposes adapter(), errors(), errorInstance(), runtime(), typeHref(), itemHref(), buildHref(), parseHref(), fields(), queries(), name(), items(href, records, resultExtensions?), instance(source, options?), and instances(source, options?). queries() exposes the projected queries, including their resolved absolute href values.
withType() accepts either immutable api.href(...) descriptors or the historical typeHref(name, rootHref, context) and itemHref(instance, typeHref, context) callbacks when the default /type/id addressing convention is not suitable, plus typeExtensions and itemExtensions(record) for generated foreign markup.
{
"collection": {
"version": "1.0",
"href": "https://api.localhost",
"links": [
{
"href": "https://docs.localhost",
"rel": "help"
}
],
"items": [
{
"data": [
{
"name": "name",
"value": "accounts"
}
],
"href": "https://api.localhost/accounts"
}
]
}
}{
"collection": {
"links": [
{
"href": "https://api.localhost/accounts",
"rel": "collection"
}
],
"version": "1.0",
"href": "https://api.localhost/accounts/create",
"items": [
{
"href": "https://api.localhost/accounts/1",
"data": [
{
"name": "id",
"prompt": "Account ID",
"value": 1
},
{
"name": "name",
"prompt": "Account name",
"value": "Lcf.vs"
}
],
"links": [
{
"prompt": "Account GitHub",
"rel": "github",
"href": "https://github.com/Lcfvs"
}
]
}
]
}
}{
"collection": {
"links": [
{
"href": "https://api.localhost",
"rel": "collection"
}
],
"version": "1.0",
"href": "https://api.localhost/accounts",
"queries": [
{
"data": [
{
"name": "name",
"prompt": "Account name"
}
],
"href": "https://api.localhost/accounts/create",
"prompt": "Create an account",
"rel": "create",
"linkTemplates": [
{
"prompt": "Account GitHub",
"rel": "github"
}
]
},
{
"data": [
{
"name": "name",
"prompt": "Account name"
}
],
"href": "https://api.localhost/accounts/search-by-name",
"prompt": "Search an account",
"rel": "search"
}
],
"scripts": [
{
"src": "https://api.localhost/accounts.js"
}
],
"template": {
"data": [
{
"name": "id",
"prompt": "Account ID"
},
{
"name": "name",
"prompt": "Account name"
}
],
"linkTemplates": [
{
"prompt": "Account GitHub",
"rel": "github"
}
]
}
}
}{
"collection": {
"href": "https://api.localhost/accounts/create",
"links": [
{
"href": "https://api.localhost/accounts",
"rel": "collection"
}
],
"error": {
"code": "INVALID_ACCOUNT",
"message": "Invalid account",
"details": [
{
"name": "name",
"message": "Invalid account name"
}
]
},
"version": "1.0"
}
}npm testThe test suite covers declared producer/open consumer error contracts, arbitrary error adapters, query-specific error sets, URI normalization, immutable registries/runtimes/adapters, explicit URI-value selection, URI build/parse adapters, source-field aliases, inherited getters, POJO/Map/custom-instance round trips, template-to-instance construction, null, encoded identifiers, extension preservation, and rejection of invalid Collection+JSON scalar values.