JSON REST API

Autofiltering

The AutoFilterPlugin provides declarative dataset scoping for json-rest-api.

It does one job:

It does not define authentication, roles, or permissions.

Overview

Autofiltering is useful whenever a resource should be constrained by one or more persisted fields:

The plugin is generic. It does not assume:

Your application decides where scope values come from. The plugin only consumes configured resolver functions.

Installation

import { Api } from 'hooked-api'
import {
  RestApiPlugin,
  RestApiKnexPlugin,
  AutoFilterPlugin
} from 'json-rest-api'

const api = new Api({ name: 'scoped-api' })

await api.use(RestApiPlugin)
await api.use(RestApiKnexPlugin, { knex })

await api.use(AutoFilterPlugin, {
  resolvers: {
    workspace: ({ context }) => context.session?.workspaceId,
    user: ({ context }) => context.subject?.id,
  },

  presets: {
    public: { filters: [] },

    workspace: {
      filters: [
        { field: 'workspace_id', resolver: 'workspace' }
      ]
    },

    user: {
      filters: [
        { field: 'user_id', resolver: 'user' }
      ]
    },

    workspace_user: {
      filters: [
        { field: 'workspace_id', resolver: 'workspace' },
        { field: 'user_id', resolver: 'user' }
      ]
    }
  }
})

Storage Backends

AutoFilterPlugin works with either database storage plugin:

An Api instance has one storage engine. Install either storage plugin, then install AutoFilterPlugin. If an application keeps core resources in normal tables and user-defined resources in AnyAPI, create one Api instance for each storage engine. Each instance owns its resources and autofilter configuration.

Autofilter definitions always use logical schema field names. The selected storage plugin translates those fields to normal table columns or AnyAPI canonical slots.

Workspace Scoping with AnyAPI

AnyAPI’s tenantId is fixed when RestApiAnyapiKnexPlugin is installed. It identifies the metadata and canonical-storage namespace; it is not a per-request workspace value. For one AnyAPI instance serving multiple application workspaces, use a stable internal tenantId and persist workspace_id on every workspace-scoped resource:

import { Api } from 'hooked-api'
import {
  RestApiPlugin,
  RestApiAnyapiKnexPlugin,
  AutoFilterPlugin
} from 'json-rest-api'

const api = new Api({ name: 'form-data-api' })

await api.use(RestApiPlugin)
await api.use(RestApiAnyapiKnexPlugin, {
  knex,
  tenantId: 'application'
})

await api.use(AutoFilterPlugin, {
  resolvers: {
    workspace: ({ context }) => context.session?.workspaceId
  },
  presets: {
    workspace: {
      filters: [
        { field: 'workspace_id', resolver: 'workspace' }
      ]
    }
  }
})

await api.addResource('form_123', {
  schema: {
    id: { type: 'id' },
    workspace_id: { type: 'string', required: true },
    customer_name: { type: 'string', required: true },
    subscribed: { type: 'boolean' }
  },
  autofilter: 'workspace'
})

In this arrangement:

Resource Configuration

Apply a preset by name:

await api.addResource('projects', {
  schema: {
    id: { type: 'id' },
    name: { type: 'string', required: true },
    workspace_id: { type: 'string', required: true },
    user_id: { type: 'number', required: true }
  },
  autofilter: 'workspace_user'
})

Use a preset with extra filters:

await api.addResource('documents', {
  schema: {
    id: { type: 'id' },
    workspace_id: { type: 'string', required: true },
    user_id: { type: 'number', required: true },
    locale: { type: 'string', required: true }
  },
  autofilter: {
    preset: 'workspace_user',
    filters: [
      {
        field: 'locale',
        resolve: ({ context }) => context.requestState?.locale
      }
    ]
  }
})

Use inline filters with no preset:

await api.addResource('reports', {
  schema: {
    id: { type: 'id' },
    account_id: { type: 'string', required: true }
  },
  autofilter: {
    filters: [
      {
        field: 'account_id',
        resolve: ({ context }) => context.scopeValues?.accountId
      }
    ]
  }
})

Declare a public resource explicitly:

await api.addResource('system_settings', {
  schema: {
    id: { type: 'id' },
    key: { type: 'string', required: true },
    value: { type: 'string', required: true }
  },
  autofilter: 'public'
})

If a resource has no autofilter setting, the plugin does nothing for that resource.

Runtime Behavior

Query Scoping

Collection queries automatically receive all configured filters.

For a workspace_user resource:

await api.resources.projects.query(
  { simplified: false },
  {
    session: { workspaceId: 'acme' },
    subject: { id: 101 }
  }
)

behaves like:

SELECT * FROM projects
WHERE workspace_id = 'acme'
  AND user_id = 101

Single-Record Scoping

get, put, patch, and delete operate on the already-scoped dataset.

If a record is outside the current scope, it behaves as not found.

Create-Time Stamping

On POST, scoped fields are injected automatically when missing.

Example:

await api.resources.projects.post({
  inputRecord: {
    data: {
      type: 'projects',
      attributes: {
        name: 'Roadmap'
      }
    }
  },
  simplified: false
}, {
  session: { workspaceId: 'acme' },
  subject: { id: 101 }
})

Stored attributes:

{
  name: 'Roadmap',
  workspace_id: 'acme',
  user_id: 101
}

Replace/Update Consistency

On PUT:

On PATCH:

Relationship-Aware Scoping

Autofiltering also affects relationship validation because scoped single-record lookups are used when checking related resources.

So this will fail if the referenced project is outside scope:

await api.resources.tasks.post({
  inputRecord: {
    data: {
      type: 'tasks',
      attributes: { title: 'Cross-scope task' },
      relationships: {
        project: {
          data: { type: 'projects', id: '123' }
        }
      }
    }
  },
  simplified: false
}, {
  scopeValues: {
    workspaceId: 'workspace-a',
    userId: 101
  }
})

Missing Scope Values

By default, each filter is required.

If a resolver returns undefined, the plugin throws:

Missing autofilter value for resolver 'workspace' on resource 'projects'

To make a filter optional, set required: false:

{
  field: 'workspace_id',
  resolver: 'workspace',
  required: false
}

When required: false and the resolver returns undefined, that filter is skipped.

BelongsTo Foreign Keys

If the scoped field is a belongsTo foreign key with an alias, the plugin works through the JSON:API relationship shape.

Example:

user_id: {
  type: 'number',
  belongsTo: 'users',
  as: 'user'
}

The plugin will stamp or validate:

data.relationships.user.data.id

instead of treating it as a direct client-owned attribute.

Storage Mapping

Autofiltering uses the compiled storage adapter automatically.

So this works correctly:

workspace_id: {
  type: 'string',
  storage: { column: 'workspace_key' }
}

The plugin scopes using the logical field name, while Knex filtering uses the mapped storage column.

Introspection Helpers

The plugin exposes light runtime inspection helpers:

api.autofilter.getConfig()
api.autofilter.getScopeConfig('projects')

Example:

api.autofilter.getScopeConfig('projects')
// {
//   preset: 'workspace_user',
//   filters: [
//     { field: 'workspace_id', resolver: 'workspace', required: true },
//     { field: 'user_id', resolver: 'user', required: true }
//   ]
// }

Design Boundary

AutoFilterPlugin is intentionally narrower than an auth plugin.

It owns:

It does not own:

That logic belongs in higher layers or custom checkPermissions hooks.