14. Custom resource methods
The built-in resource CRUD lifecycle currently supports the two Knex storage
plugins: ordinary tables and canonical AnyAPI storage. It is not a generic
remote-service or NoSQL adapter interface. Assigning eight data* helpers does
not supply a complete backend.
The previous in-memory example did exactly that and fails its first POST with
No transaction factory is installed. Supplying a dummy transaction would not
make its changes atomic or implement the remaining storage requirements.
Choose the integration boundary
| Need | Existing approach |
|---|---|
| Disposable, in-memory resource CRUD with normal JSON:API behavior | Use better-sqlite3 with filename: ':memory:' and RestApiKnexPlugin. |
| Multiple logical resources in shared SQL tables | Use RestApiAnyapiKnexPlugin; initialize its canonical schema and select a fixed tenant namespace. |
| Application-specific calls to a service or other data source | Add an explicit plugin method and implement its input, authorization and result contract. |
| A new storage engine behind all resource methods | Treat it as a core integration project and verify the full storage/transaction contract below. |
See initial setup for a complete in-memory SQLite example, and backend capabilities for the tested SQL combinations and their limits. In-memory SQLite remains a database; it provides real transactions rather than pretending a JavaScript Map can roll back.
A custom service method
This example uses local resource methods without installing database
storage. Run the three blocks together in an ESM file in an application with
json-rest-api installed. The injected loader returns a small
fixed result so the example is executable without an external service.
Replace that loader with application-owned service access when integrating.
import { JsonRestApi } from 'json-rest-api'
import { RestApiPlugin } from 'json-rest-api'
const serviceApi = new JsonRestApi({ name: 'service-example' })
await serviceApi.use(RestApiPlugin)
const AvailabilityPlugin = {
name: 'availability-service',
dependencies: ['rest-api'],
install ({ addResourceMethod, pluginOptions }) {
addResourceMethod('lookupAvailability', async ({ params, context }) => {
if (context.auth?.canCheckAvailability !== true) throw new Error('Availability access denied')
if (typeof params.sku !== 'string' || !params.sku.trim()) throw new Error('sku is required')
return pluginOptions.loadAvailability(params.sku)
})
}
}
await serviceApi.use(AvailabilityPlugin, {
loadAvailability: async sku => ({ sku, available: sku === 'BOOK-1' })
})
The method explicitly checks its application authorization and input. The loader is trusted application configuration, not client-supplied code.
await serviceApi.addResource('catalog', {
schema: { sku: { type: 'string', required: true } }
})
Registration provides the scope for the custom method; it does not install storage or make the built-in CRUD methods usable.
const availability = await serviceApi.resources.catalog.lookupAvailability(
{ sku: 'BOOK-1' },
{ auth: { canCheckAvailability: true } }
)
console.log(availability)
The result is { sku: 'BOOK-1', available: true }. This method has its own return
shape. It does not automatically acquire resource CRUD validation, permissions,
JSON:API formatting, includes, pagination, transactions or HTTP routes. A custom
HTTP endpoint must translate and authorize its request and response explicitly.
Do not route a remote write through a local transaction and claim the remote
service participates in its rollback.
See Writing Plugins for registration and custom methods, and hooks for the distinct built-in resource lifecycle.
What a complete storage integration must cover
The current implementation depends on more than individual CRUD helpers:
- Schema compilation, logical IDs, field/column translation, serializers and storage metadata must agree across reads, writes, filters and sorting.
dataGetMinimalsupports both individual and batched lookup paths. Visibility checks and relationship validation need the same policy behavior as normal reads, including hidden/missing equivalence.- Queries must preserve authorized counts, stable pagination, sparse fields, computed dependencies, includes and per-parent limits. Filtering a response after pagination does not satisfy this contract.
- Relationship mutations need target validation, membership storage, reverse changes, locks and version invalidation. Core currently uses Knex builders and storage adapters directly for parts of these operations.
- Writes need an actual transaction factory and the managed ownership contract, including acknowledged commit/rollback, uncertain completion, completion hooks and failure diagnostics. Remote side effects need their own semantics.
- Bulk operations and schema changes must preserve their documented atomicity and publication boundaries. Supporting a single-record happy path is not sufficient evidence for the backend.
The source checkout’s plugins/core/lib/storage/storage-types.d.ts describes
internal Knex-oriented boundaries; it is not a storage-agnostic public driver
specification. The ordinary and canonical storage plugins are the current
implementations to inspect. Do not copy an outdated list of helpers as a
substitute for reviewing their callers.
A new backend needs conformance tests for its supported capabilities and explicit
rejections for unsupported operations. Use the source checkout’s
tests/README.md and contributing to select the relevant
fixtures and matrices. The current library does not ship a verified generic
in-memory Map, MongoDB, DynamoDB or remote JSON:API backend.