JSON REST API

Writing Plugins

json-rest-api is built as a set of plugins on top of hooked-api. This guide covers the public extension surface for plugin authors.

Use this guide when you want to:

For internal architecture notes and maintainer-oriented details, see docs/ONBOARDING.md.

Minimal plugin shape

export const MyPlugin = {
  name: 'my-plugin',
  dependencies: ['rest-api'],

  install ({ addHook, addScopeMethod, helpers, log, pluginOptions = {} }) {
    addHook('scope:added', 'compile-my-plugin', {}, ({ context, scopes }) => {
      const scope = scopes[context.scopeName]
      scope.vars.myPlugin = { enabled: true }
    })

    addScopeMethod('doSomething', async ({ scopeName, scope, params, context }) => {
      return { scopeName, enabled: scope.vars.myPlugin?.enabled === true }
    })
  }
}

The usual pattern is:

  1. compile resource metadata during scope:added
  2. store normalized state in scope.vars
  3. use hooks or scope methods to apply behavior at runtime

The stable extension points

scope:added

Use this to inspect scopeOptions, validate configuration, and compile resource-specific runtime state into scope.vars.

Typical uses:

Example:

addHook('scope:added', 'compile-example', {}, ({ context, scopes }) => {
  const scope = scopes[context.scopeName]
  const options = scope.scopeOptions || {}

  scope.vars.example = {
    flag: options.exampleFlag === true
  }
})

beforeSchemaValidate

Use this to normalize or strip write input before schema validation runs.

Typical uses:

Example:

addHook('beforeSchemaValidate', 'strip-output-only-input', {}, ({ context }) => {
  const attributes = context.inputRecord?.data?.attributes
  if (!attributes) return

  delete attributes.output_only_field
})

knexQueryFiltering

Use this to add query constraints to the generated Knex query.

Typical uses:

Example:

addHook('knexQueryFiltering', 'scope-by-workspace', {}, async ({ context }) => {
  const query = context.knexQuery?.query
  if (!query) return

  query.where('workspace_id', context.session.workspaceId)
})

knexQueryFiltering is the right seam for filtering. It is not the right seam for turning ad hoc SQL aliases into first-class fields.

addScopeMethod

Use this when a plugin needs a reusable method on every resource or selected resources.

Example:

addScopeMethod('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

Compile resource-level definitions into:

scope.vars.queryFields = {
  full_name: {
    type: 'string',
    sortable: true,
    hidden: false,
    normallyHidden: false,
    select: ({ knex, db, context, scopeName, tableName, fieldName, schemaInfo, adapter, column, ref }) => {
      return knex.raw(
        "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
        [column('first_name'), column('last_name')]
      )
    }
  }
}

What core will do with scope.vars.queryFields

Once a plugin sets scope.vars.queryFields, core will:

What this seam is for

Use it for:

Do not use it for:

For the concrete projection example, see Query Projections.

When you add a new plugin feature, prefer this flow:

  1. read plugin options at install time
  2. validate and compile per-resource config in scope:added
  3. store only normalized runtime state in scope.vars
  4. use hooks or scope methods to apply behavior

That keeps the plugin declarative and avoids re-parsing configuration during requests.

Boundaries to keep clean

If a feature requires deeper integration than the public seams provide, treat that as a core extension discussion rather than a plugin hack.