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' }
      ]
    }
  }
})

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.