29. Writing plugins
JsonRestApi installs plugins and runs resource methods through a small local
runtime. This guide covers the public extension surface for plugin authors.
Use this guide when you want to:
- add resource-level behavior across many resources
- add new resource methods
- compile resource metadata into reusable runtime state
- hook into query or write processing
For the internal execution path, see architecture.
Minimal plugin shape
These three blocks form a runnable example. Add them to the
starting script after installing storage,
before starting the server, on a fresh database. Later snippets are fragments
for a plugin’s install function.
const MyPlugin = {
name: 'my-plugin',
dependencies: ['rest-api'],
install ({ addHook, addResourceMethod }) {
addHook('beforeSchemaValidate', 'trim-example-name', {}, ({ context }) => {
const attributes = context.inputRecord?.data?.attributes
if (typeof attributes?.name === 'string') attributes.name = attributes.name.trim()
})
addHook('resource:added', 'compile-my-plugin', {}, ({ context, scopes }) => {
const scope = scopes[context.scopeName]
scope.vars.myPlugin = { enabled: context.scopeOptions.exampleFlag === true }
})
addResourceMethod('describeExample', async ({ scopeName, scope }) => ({
scopeName, enabled: scope.vars.myPlugin.enabled
}))
}
}
await api.use(MyPlugin)
Install registration hooks before registering the resources they configure.
await api.addResource('authors', {
schema: { name: { type: 'string', required: true } },
exampleFlag: true
})
await api.addResource('publishers', {
schema: { name: { type: 'string', required: true } }
})
await api.resources.authors.createKnexTable()
await api.resources.publishers.createKnexTable()
The custom method is available on each resource, with resource-specific state.
const authorDescription = await api.resources.authors.describeExample()
const publisherDescription = await api.resources.publishers.describeExample()
const createdAuthor = await api.resources.authors.post({
format: 'plain', data: { name: ' Ada ' }
})
console.log(authorDescription, publisherDescription, createdAuthor)
The first result is { scopeName: 'authors', enabled: true }; the second is
{ scopeName: 'publishers', enabled: false }. The created author’s name is
'Ada': the hook trims the declared attribute before attribute validation and
storage.
Export MyPlugin as a named
export when moving the declaration into its own module.
The usual pattern is:
- compile resource metadata during
resource:added - store normalized state in
scope.vars - use hooks or resource methods to apply behavior at runtime
Registration and hook ordering
A plugin has a unique name, an install function and optional dependencies.
Dependencies must already be installed. Each string names a required plugin;
an array names alternatives, for example
dependencies: ['rest-api', ['rest-api-knex', 'rest-api-anyapi-knex']].
Duplicate plugin installation rejects. Await setup calls sequentially and discard
an instance if installation fails; installation is not transactional.
The installation argument provides addApiMethod(name, handler),
addResourceMethod(name, handler) and addHook(event, name, placement, handler).
An API method name cannot replace an existing API member. Registering a shared
resource method replaces the shared definition; resource-local overrides remain.
Methods and hooks receive the common runtime envelope. Internal names scope,
scopeName and scopes refer to resources; the public collection is api.resources.
Use {} to append a hook, or specify one named beforeFunction or afterFunction
anchor in the same event. A missing anchor rejects registration. Numeric order
and per-plugin ordering options are unsupported. Handlers run sequentially over
a snapshot of the list. Returning false stops that list; throw an error to reject
an operation. The dispatcher propagates thrown values unchanged.
Resource-local hooks use the same placement contract and run only for that
resource. Register global resource:added hooks before adding resources; a
resource’s local hooks are installed after its setup event completes.
The stable extension points
resource:added
Use this to inspect scopeOptions, validate configuration, and compile resource-specific runtime state into scope.vars.
Typical uses:
- initialize resource-specific extension state
- validate non-schema resource options
Declare fields during schema:enrich (or computedSchema:enrich for computed
fields). Use schema:compiled for work that needs the compiled schema, such
as autofilter configuration; do not add fields after compilation.
Example:
addHook('resource:added', 'compile-example', {}, ({ context, scopes }) => {
const scope = scopes[context.scopeName]
const options = context.scopeOptions || {}
scope.vars.example = {
flag: options.exampleFlag === true
}
})
beforeSchemaValidate
Use this to normalize attribute values before attribute schema validation runs.
Request document validation and relationship processing have already begun.
Use the earlier beforeProcessing stage if an extension must change the input
structure before those checks, and validate any untrusted shape it reads.
Typical uses:
- inject derived input values
- apply attribute-level business checks
This hook cannot make an otherwise rejected request document valid retroactively.
The complete example above trims a declared string attribute here. It does not attempt to remove an unknown field after request validation has rejected it. For stage order and available context, use the write lifecycle reference.
knexQueryFiltering
Use this to add query constraints to the generated Knex query.
Typical uses:
- automatic scoping
- cross-table filters
- custom public filter semantics
The hook receives the current builder through context.knexQuery.query,
along with its scopeName and tableName. It also runs for nested queries.
Raw column names are unsafe to assume: ordinary resources can map logical fields
to different columns, and canonical resources use storage slots and aliases.
For equality scoping and write stamping, use AutoFilterPlugin. For mandatory resource visibility, use RowPolicyPlugin, whose policy context supplies logical column/value translation and query-purpose metadata. For a public client-selected filter, use the custom search filter contract. These mechanisms avoid duplicating storage translation in application hooks.
A filtering hook does not turn an ad hoc SQL alias into a declared field; use the query-field contract below for selected or sortable derived values.
addResourceMethod
Use this when a plugin needs a reusable method on every resource or selected resources.
Example:
addResourceMethod('introspect', async ({ vars }) => {
return {
tableName: vars.schemaInfo?.tableName,
fields: Object.keys(vars.schemaInfo?.schemaStructure || {})
}
})
The query-field seam
json-rest-api now supports a small, explicit seam for query-only read fields.
This is the seam used by QueryProjectionsPlugin, and it is the recommended pattern for plugins that need derived SQL-backed fields.
What a plugin should provide
Declare query fields during the existing schema-enrichment stage:
addHook('schema:enrich', 'declare-uppercase-name', {}, ({ context }) => {
if (context.scopeName !== 'authors') return
context.queryFields.uppercase_name = {
type: 'string',
sortable: true,
select: ({ knex, column }) => knex.raw('upper(??)', [column('first_name')])
}
})
Install the hook before registering resources. Core normalizes the final map
and rejects names that collide with attributes, computed fields, relationships
or logical IDs. If another declaration hook replaces the map, order this hook
after that one. The published definitions are available at
scope.vars.schemaInfo.queryFields; do not mutate a separate vars.queryFields
map. Canonical addKnexFields recompiles declarations and dependencies before
publication. SQL callbacks are still evaluated with each query’s context.
What core does with compiled query fields
Core will:
- include visible query fields by default in
get()andquery() - allow them in sparse fieldsets
- allow them in sorting when the plugin marks them sortable
- use them in cursor pagination with a stable
idtie-breaker - carry them through included-resource selection
- only expose declared query-field aliases on the AnyAPI path
What this seam is for
Use it for:
- SQL-backed projections
- aggregate list fields
- joined display labels
- derived fields that must participate in
ORDER BY
Do not use it for:
- writable fields
- storage/schema ownership
- migrations or introspection
- normal response-only computed fields
For the concrete projection example, see Query Projections.
Recommended plugin pattern
When you add a new plugin feature, prefer this flow:
- read plugin options at install time
- declare schema metadata during its enrichment stage; use
resource:addedfor other registration work - store only normalized runtime state in
scope.vars - use hooks or resource methods to apply behavior
That keeps the plugin declarative and avoids re-parsing configuration during requests.
Boundaries to keep clean
- Keep query-layer extensions out of
schema. - Choose input hooks according to the write lifecycle.
- Keep filtering in
knexQueryFiltering. - Keep response-only enrichment in
enrichAttributes. - Do not rely on arbitrary internal helper filenames as public API.
If a feature requires deeper integration than the public seams provide, treat that as a core extension discussion rather than a plugin hack.