JSON REST API - Complete API Reference

This reference provides comprehensive documentation for all methods, parameters, and features available in the json-rest-api library.

Current contract

This reference describes the v3 API. See the migration guides (to v2, to v3) and backend limits before upgrading.

Pass method controls in the first params object and application information in the optional second context object. POST/PUT/PATCH accept exactly one input key: data for plain resource values, or document for a JSON:API document. Both keys together are rejected, even if one value is undefined. Direct attribute shorthand and the removed inputRecord parameter are rejected. A plain field named data belongs inside the data argument and does not select JSON:API input.

format: 'plain' | 'jsonapi' selects only the output representation. Programmatic calls default to plain. POST/PUT/PATCH use returning: 'none' | 'minimal' | 'full', defaulting to full. Per-call options override configured resource/plugin defaults. The built-in HTTP connectors select JSON:API with full write responses independently of these defaults; resource deletion and relationship mutations return no content. Boolean representation/return aliases are removed and rejected.

The input key and output format are independent:

// Plain input and a plain result: an ordinary repository needs no JSON:API types.
async function renameArticle (id, title, context) {
  return api.resources.articles.patch({ id, data: { title } }, context)
}

// Plain input with a JSON:API response.
const wireResponse = await api.resources.articles.patch({
  id: '42', data: { title: 'Revised' }, format: 'jsonapi'
})

// JSON:API input with a plain response.
const plainResult = await api.resources.articles.patch({
  id: '42',
  document: { data: { type: 'articles', id: '42', attributes: { title: 'Revised' } } },
  format: 'plain'
})

All combinations use the same permissions, validation, hooks, storage and transaction lifecycle. Full-response preparation still runs before a write commits. The HTTP connectors pass JSON:API request bodies as document and select JSON:API output; there is no separate programmatic CRUD implementation.

Write documents may contain object-valued top-level meta, links and jsonapi members. Hooks can inspect them on the normalized context.inputRecord; they are not stored attributes. errors and included are rejected for writes: compound writes and JSON:API extensions are not enabled by accepting a document.

On a resource configured with versionField, PUT/PATCH/DELETE and relationship writes accept an expectedVersion string; unconditional writes may omit it. POST does not accept that condition. See optimistic concurrency for version-field configuration and the separate opt-in HTTP validator contract. Many-row writes refuse versioned resources; a lockVersionField with onlyIf gives them compare-and-set instead.

Read calls can borrow raw Knex transactions. Library writes must use the handle from api.transaction when grouping operations; they reject unmanaged raw transactions. Context is application-owned but enriched during each call, so use a separate context for each enlisted operation. See transaction support.

Table of Contents

  1. API setup and runtime
  2. Core API Methods
  3. Data-Access Methods
  4. Relationship Methods
  5. Hook System
  6. Query Features
  7. Configuration Options
  8. Schema Configuration
  9. Error Handling
  10. Advanced Features

API setup and runtime

Import JsonRestApi from json-rest-api. Create an instance, await each plugin installation, then register resources before accepting requests.

const api = new JsonRestApi({ name: 'catalog', logger: applicationLogger })
await api.use(RestApiPlugin)
await api.use(RestApiKnexPlugin, { knex })
await api.addResource('books', bookOptions)

The quickstart supplies a complete setup with imports and schemas. name and logger are optional. The logger accepts trace, debug, info, warn, error and fatal methods; omitted methods are silent. Configure log formatting and filtering in your logger. Old log and logging options reject.

Member Contract
api.use(plugin, options?) Installs a named plugin after checking dependencies; resolves to the API
api.addResource(name, options?) Registers a unique resource, runs setup hooks and resolves to that resource
api.resources[name] The registered resource object and its methods
api.customize({ hooks, methods, vars, helpers }) Adds global hooks, shared resource methods and API defaults; resolves to the API
api.runHooks(event, context?) Runs global handlers sequentially; resolves to false if one stops the list, otherwise true
api.vars, api.helpers Defaults inherited by resource vars and helpers

Resource options include schemas and plugin configuration, plus local hooks, methods, vars and helpers. Resource methods receive the runtime argument object, not positional data arguments; see writing plugins. Global setup hooks run before resource-local operation hooks are registered.

Shared resource methods require their resource as the receiver. Keep calls such as api.resources.books.get(params, context) intact, or bind the resource when passing a method as a callback. Local vars/helpers override inherited defaults; Object.keys lists local entries only. Shared method replacements apply to resources without their own local override.

Await setup sequentially. Setup failure is not rolled back; discard that instance and correct its configuration before starting again. Registration and customize() do not migrate stored data or rebuild arbitrary schema mutations.

TypeScript resources and hooks

The existing registration call infers ordinary attribute types on its returned resource. Install the REST/storage plugins first, as in JavaScript:

const books = await api.addResource('books', {
  schema: {
    title: { type: 'string', required: true },
    pages: { type: 'number', nullable: true }
  }
})

await books.patch({ id: '1', data: { pages: 412 } })
// Type error: pages is numeric, not a string.
// await books.patch({ id: '1', data: { pages: 'long' } })

const book = await books.get({ id: '1' })
// title: string | undefined; visibility and sparse fieldsets can omit it.
const title = book.title

ResourceSchema describes built-in field declarations. For reusable schemas, retain literal options with as const satisfies ResourceSchema. InferInput<typeof schema> and InferOutput<typeof schema> can replace manually duplicated attribute interfaces. Input properties remain optional: defaults and hooks can supply required persisted fields. Computed fields are excluded from input, and hidden fields from inferred output. Resource result types keep output properties optional for permissions and projections. The public resource ID is always id; inferred registration also omits a declared physical idProperty. An ID property known only as a dynamic string needs an explicit resource model. Optional stored fields without required: true or nullable: false also include SQL null in their output type. Input null still requires nullable: true.

import type { ResourceSchema, InferInput, InferOutput } from 'json-rest-api'

const schema = {
  title: { type: 'string', required: true },
  pages: { type: 'number' }
} as const satisfies ResourceSchema

type BookInput = InferInput<typeof schema>
type BookFields = InferOutput<typeof schema>

Inference describes built-in CRUD with the standard API defaults (plain, full). Literal resource-level format and returning options are inferred, as are per-call overrides. Arbitrary plugin installations, API-level defaults, vars mutations and replaced methods are not inferred. Keep an explicit resource map when using those features, dynamic schema enrichment, relationship fields or custom transformations:

import { JsonRestApi } from 'json-rest-api'
import type { ResourceCoreMethods } from 'json-rest-api'

interface BookFields { title: string; displayTitle: string }
interface BookInput { title?: string }
type Books = ResourceCoreMethods<BookFields, BookInput, 'books', 'jsonapi'>
const api = new JsonRestApi<{ books: Books }>()

The explicit map remains authoritative for both returned handles and api.resources. Awaited registration does not grow the static type of an existing api.resources registry. Without an explicit map, keep the inferred returned handle. Resource-local method overrides use the general runtime declarations instead of pretending the overridden methods still perform built-in CRUD.

Attribute inference omits inline belongs-to backing fields; it does not generate relationship result trees. Use the existing RelationshipMethods and explicit input/output fields for relationships, separately declared polymorphic backing fields and managed version-field input restrictions. Callback/serializer output is unknown unless supplied explicitly. Inferred values describe public representations, not every accepted coercion or every database constraint. Nested object schemas use existing schema instances, not a new nested-schema DSL.

RuntimeResourceOptions describes built-in resource settings. Plugin extensions can declare an intersection with their own option interface and pass that typed configuration, retaining a precise resource map where the plugin changes fields or methods. This does not require a runtime wrapper.

Known hooks receive phase-specific editor guidance, including library-owned read-only fields and permission/enrichment context references. Use RuntimeCustomization<AppContext> or the optional second JsonRestApi type argument to describe custom application state. HookHandler<Event, AppContext> types an individual known hook; RuntimeHook<PluginContext> describes a custom plugin hook. These declarations do not freeze context or replace runtime validation. See the hook recipes and editor guidance.

Core API Methods

QUERY - Retrieve Collections

Retrieves a collection of resources with support for filtering, sorting, pagination, and relationship inclusion.

Method Signature

const result = await api.resources[resourceType].query(params, context)

Parameters

All parameters are passed within a single params object:

{
  queryParams: {
    include: Array,      // Relationship paths to include
    fields: Object,      // Sparse fieldsets
    filters: Object,     // Filter conditions
    sort: Array,         // Sort fields
    page: Object         // Pagination parameters
  },
  format: String,          // 'plain' (default) or 'jsonapi'
  transaction: Object,   // Database transaction object
  pageSizeLimit: Number  // Server code only: this call's largest page size
}
Parameter Type Required Description
queryParams Object No Query parameters container
queryParams.include Array No Relationship paths to include (e.g., ['author', 'comments.user'])
queryParams.fields Object No Sparse fieldsets - keys are resource types, values are comma-separated field names
queryParams.filters Object No Filter conditions based on searchSchema configuration
queryParams.sort Array No Sort fields, prefix with ‘-‘ for DESC (e.g., ['title', '-created-at'])
queryParams.page Object No Pagination parameters
queryParams.page.number Number No Page number (1-based, offset pagination)
queryParams.page.size Number No Items per page
queryParams.page.after String No Cursor for forward pagination
queryParams.page.before String No Cursor for backward pagination
format String No 'plain' or 'jsonapi'; uses the configured default when omitted
transaction Object No Database transaction object
pageSizeLimit Number No A positive integer replacing the resource’s queryMaxLimit for this call, so server code reading every row can use larger pages. HTTP and Socket.IO never pass it, so their page sizes stay capped

Return Value

JSON:API Mode (format: ‘jsonapi’):

{
  data: [
    {
      type: 'articles',
      id: '1',
      attributes: {
        title: 'First Article',
        content: 'Article content...'
      },
      relationships: {
        author: {
          data: { type: 'users', id: '10' }
        }
      }
    }
  ],
  included: [
    {
      type: 'users',
      id: '10',
      attributes: {
        name: 'John Doe'
      }
    }
  ],
  meta: {
    pagination: {
      page: 1,
      pageSize: 10,
      pageCount: 5,
      total: 50,
      hasMore: true
    }
  },
  links: {
    self: '/articles?page[number]=1',
    next: '/articles?page[number]=2',
    last: '/articles?page[number]=5'
  }
}

Plain Mode (format: ‘plain’ - default):

{
  data: [
    {
      id: '1',
      title: 'First Article',
      content: 'Article content...',
      author: {
        id: '10',
        name: 'John Doe'
      }
    }
  ],
  meta: {
    pagination: {
      page: 1,
      pageSize: 10,
      pageCount: 5,
      total: 50,
      hasMore: true
    }
  }
}

HTTP Equivalent

GET /articles?include=author&fields[articles]=title,content&fields[users]=name&filter[status]=published&sort=-created-at&page[number]=1&page[size]=10
Accept: application/vnd.api+json

Examples

Basic Query:

// Get all articles (plain mode by default)
const result = await api.resources.articles.query({});

// HTTP equivalent
// GET /articles

Query with Filtering:

// Get published articles by a specific author
const result = await api.resources.articles.query({
  queryParams: {
    filters: {
      status: 'published',
      author: '10'
    }
  }
});

// HTTP equivalent
// GET /articles?filter[status]=published&filter[author]=10

Query with Sorting and Pagination:

// Get articles sorted by creation date (newest first), page 2
const result = await api.resources.articles.query({
  queryParams: {
    sort: ['-created-at', 'title'],
    page: {
      number: 2,
      size: 20
    }
  }
});

// HTTP equivalent
// GET /articles?sort=-created-at,title&page[number]=2&page[size]=20

Query with Includes and Sparse Fields:

// Get articles with author and comments, only specific fields
const result = await api.resources.articles.query({
  queryParams: {
    include: ['author', 'comments.user'],
    fields: {
      articles: 'title,summary',
      users: 'name,avatar',
      comments: 'content,created-at'
    }
  }
});

// HTTP equivalent
// GET /articles?include=author,comments.user&fields[articles]=title,summary&fields[users]=name,avatar&fields[comments]=content,created-at

JSON:API Mode Query:

// Force JSON:API response format
const result = await api.resources.articles.query({
  queryParams: {
    filters: { status: 'published' },
    include: ['author']
  },
  format: 'jsonapi'
});

// Returns full JSON:API structure with type, id, attributes, relationships

Cursor-based Pagination:

const firstPage = await api.resources.articles.query({
  queryParams: { page: { size: 10 } }
});
const cursor = firstPage.meta.pagination.cursor?.next;
if (cursor) {
  const nextPage = await api.resources.articles.query({
    queryParams: { page: { after: cursor, size: 10 } }
  });
}

GET - Retrieve Single Resource

Retrieves a single resource by its ID with optional relationship inclusion.

Method Signature

const result = await api.resources[resourceType].get(params, context)

Parameters

{
  id: String|Number,     // Required: The unique ID of the resource
  queryParams: {
    include: Array,      // Relationship paths to include
    fields: Object       // Sparse fieldsets
  },
  format: String,          // 'plain' (default) or 'jsonapi'
  transaction: Object    // Database transaction object
}
Parameter Type Required Description
id String|Number Yes The unique ID of the resource
queryParams Object No Query parameters
queryParams.include Array No Relationship paths to include
queryParams.fields Object No Sparse fieldsets for specific resource types
format String No 'plain' or 'jsonapi'; uses the configured default when omitted
transaction Object No Database transaction object

id is normalized with the effective normalizeId function before validation and lookup. If it normalizes to an empty value, the operation fails as REST_API_RESOURCE with subtype not_found.

Return Value

JSON:API Mode (format: ‘jsonapi’):

{
  data: {
    type: 'articles',
    id: '1',
    attributes: {
      title: 'Article Title',
      content: 'Full article content...'
    },
    relationships: {
      author: {
        data: { type: 'users', id: '10' }
      }
    }
  },
  included: [
    {
      type: 'users',
      id: '10',
      attributes: {
        name: 'John Doe'
      }
    }
  ]
}

Plain Mode (format: ‘plain’ - default):

{
  id: '1',
  title: 'Article Title',
  content: 'Full article content...',
  author: '10',
  author: {
    id: '10',
    name: 'John Doe'
  }
}

HTTP Equivalent

GET /articles/1?include=author&fields[articles]=title,content
Accept: application/vnd.api+json

Examples

Basic Get:

// Get article by ID (plain mode by default)
const result = await api.resources.articles.get({
  id: '1'
});

// HTTP equivalent
// GET /articles/1

Get with Relationships:

// Get article with author and comments
const result = await api.resources.articles.get({
  id: '1',
  queryParams: {
    include: ['author', 'comments']
  }
});

// HTTP equivalent
// GET /articles/1?include=author,comments

Get with Sparse Fields:

// Get article with only specific fields
const result = await api.resources.articles.get({
  id: '1',
  queryParams: {
    include: ['author'],
    fields: {
      articles: 'title,summary',
      users: 'name'
    }
  }
});

// HTTP equivalent
// GET /articles/1?include=author&fields[articles]=title,summary&fields[users]=name

JSON:API Mode Get:

// Get in JSON:API format
const result = await api.resources.articles.get({
  id: '1',
  queryParams: {
    include: ['author', 'tags']
  },
  format: 'jsonapi'
});

// Returns full JSON:API document structure

POST - Create Resource

Creates a new resource with attributes and optional relationships.

Method Signature

const result = await api.resources[resourceType].post(params, context)

Parameters

{
  data: Object,            // Plain resource values; choose data OR document
  document: Object,        // JSON:API document; never supply both
  queryParams: {
    include: Array,         // For response formatting
    fields: Object          // For response formatting
  },
  format: String,          // 'plain' (default) or 'jsonapi'
  transaction: Object,      // Database transaction object
  returning: String         // 'none', 'minimal' or 'full' (default)
}
Parameter Type Required Description
data Object One input key required Resource data to create as plain values; excludes document
document Object One input key required JSON:API input document; excludes data
data.id String No (plain) Optional explicit logical resource ID
document.data Object Yes (JSON:API) Resource data container
document.data.id String No (JSON:API) Optional explicit logical resource ID
document.data.type String Yes (JSON:API) Resource type
document.data.attributes Object No Resource attributes, subject to schema requirements/defaults
document.data.relationships Object No Related resources
queryParams Object No For includes/fields in response
format String No 'plain' or 'jsonapi'; uses the configured default when omitted
transaction Object No Database transaction object
returning String No 'none', 'minimal' or 'full'; booleans are rejected

When an explicit resource id is provided, normalizeId runs before persistence and before any follow-up record fetch used for the return payload. If the normalized id is empty, the request fails as REST_API_VALIDATION on data.id.

Return Value Behavior

POST/PUT/PATCH use the selected returning mode:

Mode Plain output JSON:API output
none undefined undefined
minimal { type, id } { data: { type, id } }
full Resource object Resource document

Full responses honor requested fieldsets/includes and visibility; they do not promise every declared field. Programmatic calls default to full. HTTP connectors choose their response behavior explicitly and map no-content writes to 204. A POST with a response body uses 201; PUT/PATCH with a body use 200.

HTTP Equivalent

POST /articles
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "articles",
    "attributes": {
      "title": "New Article",
      "content": "Article content..."
    },
    "relationships": {
      "author": {
        "data": { "type": "users", "id": "10" }
      }
    }
  }
}

Examples

Basic Create (Plain Mode):

// Create article with plain input (default mode)
const result = await api.resources.articles.post({
  data: {
    title: 'New Article',
    content: 'Article content...',
    status: 'draft',
    author: '10'
  }
});

// Returns full record by default for API calls

Create with JSON:API Format:

// Create article with JSON:API format
const result = await api.resources.articles.post({
  document: {
    data: {
      type: 'articles',
      attributes: {
        title: 'New Article',
        content: 'Article content...',
        status: 'draft'
      },
      relationships: {
        author: {
          data: { type: 'users', id: '10' }
        }
      }
    }
  },
  format: 'jsonapi'
});

Create with Explicit ID and Normalization:

// With normalizeId configured to trim and uppercase ids,
// this record is stored as ARTICLE-42
const result = await api.resources.articles.post({
  document: {
    data: {
      type: 'articles',
      id: '  article-42  ',
      attributes: {
        title: 'Canonical ID Example',
        content: 'Stored under the normalized id'
      }
    }
  },
  format: 'jsonapi'
});

Create with Multiple Relationships:

// Create article with author and tags (plain)
const result = await api.resources.articles.post({
  data: {
    title: 'New Article',
    content: 'Article content...',
    author: '10',
    tags: ['1', '2', '3']
  },
  queryParams: {
    include: ['author', 'tags']
  }
});

Create with Minimal Return:

// Create and return only ID
const result = await api.resources.articles.post({
  data: {
    title: 'New Article',
    content: 'Article content...'
  },
  returning: 'minimal'
});

// Returns (plain mode):
// {
//   type: 'articles',
//   id: '123'
// }

Create with No Return:

// Create without returning data to this programmatic caller
const result = await api.resources.articles.post({
  data: {
    title: 'New Article',
    content: 'Article content...'
  },
  returning: 'none'
});

// Returns: undefined

PUT - Replace Resource

Creates a missing target at the supplied ID or replaces a visible existing resource. Replacement applies schema required/default/nullability rules and the method’s relationship-omission semantics. See PUT and PATCH. Target visibility checks still apply.

Method Signature

const result = await api.resources[resourceType].put(params, context)

Parameters

{
  id: String|Number|BigInt, // Target ID; may instead be supplied in data.id or document.data.id
  data: Object,            // Complete plain resource values; choose data OR document
  document: Object,        // Complete JSON:API resource document
  queryParams: {
    include: Array,         // For response formatting
    fields: Object          // For response formatting
  },
  format: String,          // 'plain' (default) or 'jsonapi'
  transaction: Object,      // Database transaction object
  returning: String         // 'none', 'minimal' or 'full' (default)
}
Parameter Type Required Description
data Object One input key required Complete resource data as plain values; excludes document
document Object One input key required JSON:API input document; excludes data
id String|Number|BigInt Conditional Target ID; required if the input document does not supply one
data.id String|Number Conditional (plain) Target ID when top-level id is omitted
document.data.id String|Number Conditional (JSON:API) Target ID when top-level id is omitted
document.data.type String Yes (JSON:API) Resource type
document.data.attributes Object Yes (JSON:API) All resource attributes
document.data.relationships Object No All relationships (missing ones are nulled)
queryParams Object No For response formatting
format String No 'plain' or 'jsonapi'; uses the configured default when omitted
transaction Object No Database transaction object
returning String No 'none', 'minimal' or 'full'; booleans are rejected

data.id and document.data.id always refer to the logical resource id. idProperty and storage mapping affect the backing column name, not the API field name, and the resource id is not part of attributes.

If both the URL id and body id are present, both are normalized before the equality check. If either id normalizes to an empty value, the operation fails before storage is touched.

Return Value

Returns according to returning: none, minimal or full. See the POST return-value table.

HTTP Equivalent

PUT /articles/1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "articles",
    "id": "1",
    "attributes": {
      "title": "Updated Title",
      "content": "New content...",
      "status": "published"
    },
    "relationships": {
      "author": {
        "data": { "type": "users", "id": "10" }
      }
    }
  }
}

Examples

Basic Replace (Plain):

// Replace entire article (plain mode)
const result = await api.resources.articles.put({
  data: {
    id: '1',
    title: 'Completely New Title',
    content: 'Entirely new content',
    status: 'published',
    author: '10'
    // Note: All attributes must be provided
  }
});

Replace with JSON:API Format:

// Replace article with JSON:API format
const result = await api.resources.articles.put({
  document: {
    data: {
      type: 'articles',
      id: '1',
      attributes: {
        title: 'Updated Article',
        content: 'Updated content',
        status: 'published'
      },
      relationships: {
        author: {
          data: { type: 'users', id: '20' } // Changed author
        },
        tags: {
          data: [] // Remove all tags
        }
      }
    }
  },
  format: 'jsonapi'
});

Replace and Remove Relationships:

// Replace and explicitly remove relationships
const result = await api.resources.articles.put({
  data: {
    id: '1',
    title: 'Article Without Author',
    content: 'Content...',
    status: 'draft',
    author: null,  // Remove author
    tags: []       // Remove all tags
  }
});

PATCH - Update Resource

Partially updates an existing resource. Only provided attributes and relationships are modified.

Method Signature

const result = await api.resources[resourceType].patch(params, context)

Parameters

{
  id: String|Number|BigInt, // Target ID; may instead be supplied in data.id or document.data.id
  data: Object,            // Partial plain resource values; choose data OR document
  document: Object,        // Partial JSON:API resource document
  queryParams: {
    include: Array,         // For response formatting
    fields: Object          // For response formatting
  },
  format: String,          // 'plain' (default) or 'jsonapi'
  transaction: Object,      // Database transaction object
  returning: String         // 'none', 'minimal' or 'full' (default)
}
Parameter Type Required Description
data Object One input key required Partial resource data as plain values; excludes document
document Object One input key required JSON:API input document; excludes data
id String|Number|BigInt Conditional Target ID; required if the input document does not supply one
data.id String|Number Conditional (plain) Target ID when top-level id is omitted
document.data.id String|Number Conditional (JSON:API) Target ID when top-level id is omitted
document.data.type String Yes (JSON:API) Resource type
document.data.attributes Object No Attributes to update
document.data.relationships Object No Relationships to update
queryParams Object No For response formatting
format String No 'plain' or 'jsonapi'; uses the configured default when omitted
transaction Object No Database transaction object
returning String No 'none', 'minimal' or 'full'; booleans are rejected

If both the URL id and body id are present, both are normalized before the equality check. If either id normalizes to an empty value, the operation fails before storage is touched.

Return Value

Returns according to returning: none, minimal or full. See the POST return-value table.

HTTP Equivalent

PATCH /articles/1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "articles",
    "id": "1",
    "attributes": {
      "status": "published"
    }
  }
}

Examples

Basic Update (Plain):

// Update only the status (plain mode)
const result = await api.resources.articles.patch({
  data: {
    id: '1',
    status: 'published'
  }
});

// Only status is updated, other fields remain unchanged

Update Multiple Attributes:

// Update title and content
const result = await api.resources.articles.patch({
  data: {
    id: '1',
    title: 'Updated Title',
    content: 'Updated content only',
    updated_at: new Date().toISOString()
  }
});

Update with JSON:API Format:

// Update with JSON:API format
const result = await api.resources.articles.patch({
  document: {
    data: {
      type: 'articles',
      id: '1',
      attributes: {
        status: 'published',
        published_at: new Date().toISOString()
      },
      relationships: {
        author: {
          data: { type: 'users', id: '30' }
        }
      }
    }
  },
  format: 'jsonapi'
});

Update Relationships Only:

// Change author and add tags (plain)
const result = await api.resources.articles.patch({
  data: {
    id: '1',
    author: '30',
    tags: ['3', '4', '5']
  }
});

Remove Optional Relationship:

// Set featured_image to null
const result = await api.resources.articles.patch({
  data: {
    id: '1',
    featured_image_id: null
  }
});

DELETE - Remove Resource

Permanently deletes a resource from the system.

Method Signature

const result = await api.resources[resourceType].delete(params, context)

Parameters

{
  id: String|Number,    // Required: ID of resource to delete
  transaction: Object   // Database transaction object
}
Parameter Type Required Description
id String|Number Yes ID of resource to delete
transaction Object No Database transaction object

id is normalized with the effective normalizeId function before validation and lookup. If it normalizes to an empty value, the operation fails as REST_API_RESOURCE with subtype not_found.

Return Value

Returns undefined (204 No Content)

HTTP Equivalent

DELETE /articles/1
Accept: application/vnd.api+json

Examples

Basic Delete:

// Delete article by ID
await api.resources.articles.delete({
  id: '1'
});

// Returns undefined (no content)

// HTTP equivalent
// DELETE /articles/1

Delete with Transaction:

await api.transaction(async transaction => {
  // Delete article
  await api.resources.articles.delete({
    id: '1',
    transaction
  });
  
  // Delete related comments
  await api.resources.comments.delete({
    id: '10',
    transaction
  });
  
});

Note on Transaction Auto-commit: Writes without a transaction own their completion. To compose writes, pass the handle supplied by api.transaction; the helper awaits the callback, commits and then runs completion hooks, or rolls back when the unit fails. Raw transactions and savepoints cannot own library writes.


AGGREGATE - Grouped Totals

Groups the records a collection query would select and computes measures for each group, on ordinary and AnyAPI storage alike.

Method Signature

await api.resources.sales.aggregate(params, context)

Parameters

Parameter Type Description
groupBy (string | object)[] Fields to group by, or date buckets { field, bucket, timezone, as }. Omit for one row over all selected records.
measures object Output names mapped to { count \| countDistinct \| sum \| avg \| min \| max: field, where? }. Only count accepts '*'.
queryParams.filters object Search schema filters, validated as for query.
queryParams.sort string[] Grouped field or measure names, - for descending. Grouped fields break ties.
queryParams.page object size and number, for grouped results only. size defaults to queryDefaultLimit and cannot exceed queryMaxLimit.
transaction Knex transaction Optional transaction to read in.

sum and avg take numeric fields; min and max also take strings, dates and times. Grouping and counting accept any scalar stored field, including belongs-to foreign keys. Fields that are hidden, virtual, computed, or that have a getter or storage.serialize are rejected, because SQL would group or sum values other than the ones clients read. The exception is a date or dateTime field with storage.serialize: it may be bucketed and take min and max, whose results are read back as that field’s values. normallyHidden fields are allowed. In a system context, a hidden ownership (autofilter) field may also be grouped and take countDistinct, so a job can find which owners have rows; other callers are refused it, since one whose optional owner filter does not resolve would see every owner.

Conditional measures

A measure’s where limits the rows it counts or adds up, so one call can return “this week” next to “this month”. Every entry must hold:

measures: {
  open: { count: '*', where: { status: 'open' } },
  bigOpen: { sum: 'amount', where: { status: 'open', amount: { gte: 1000 } } },
  late: { count: '*', where: { status: ['overdue', 'disputed'] } },
  unassigned: { count: '*', where: { owner_id: null } }
}

A condition is a value, an array (IN), null, or an object of eq, ne, gt, gte, lt, lte, in and notIn. Fields may be any stored scalar field aggregates accept; values are translated as filter values are. The SQL is fn(CASE WHEN … THEN value END), so conditional and plain measures share one statement.

Date buckets

A groupBy entry { field, bucket, timezone, as } groups a date or dateTime field by 'day', 'week', 'month' or 'year'. The group value is the 'YYYY-MM-DD' of the bucket’s first day; weeks start on Monday. The output name is as, default the field name.

await api.resources.sales.aggregate({
  groupBy: [{ field: 'closedAt', bucket: 'month', timezone: 'Europe/Rome', as: 'month' }],
  measures: { total: { sum: 'amount' } },
  queryParams: { sort: ['month'] }
}, context)
// { data: [{ month: '2026-01-01', total: 1250 }, ...] }

timezone is 'UTC' (default), a fixed offset such as '-09:00', or an IANA name. PostgreSQL has zone data; MySQL and MariaDB need their time zone tables loaded (checked once per name); SQLite takes offsets only. date fields have no time of day and take no time zone. Buckets need table storage; AnyAPI resources refuse them.

Return Value

{
  data: [{ salesperson: 'Ann', total: 150.5, count: 2 }],
  meta: { page: { size: 20, number: 1, hasMore: false } } // grouped results only
}

Both formats return these plain rows. Group values and min/max use the field’s public representation; counts are numbers, and sum/avg are numbers except on decimal fields (precision and scale), which keep the driver’s value. NULL sorts as the lowest value on every database.

Security

aggregate checks permissions as a query (method: 'query', with originalContext.method === 'aggregate'). Row policies, autofilters and other knexQueryFiltering hooks run with queryPurpose: 'aggregate'. The aggregate reads only the IDs the filtered query selects, so joins added by filters or policies never count a record twice.

Examples

const totals = await api.resources.sales.aggregate({
  groupBy: ['salesperson'],
  measures: { total: { sum: 'amount' }, count: { count: '*' }, avg: { avg: 'amount' } },
  queryParams: { filters: { closed: true }, sort: ['-total'], page: { size: 10 } }
}, { subject })

Data-Access Methods

Methods for work that does not fit one page or one record: reports, imports and jobs. They keep the caller’s ownership filters (autofilter) and row policies. The guides have the details: many-row writes and scoped reads, locks and system work. Many-row writes work on table and AnyAPI storage; on AnyAPI, onConflict takes only keys: ['id']. Row locks and claims need table storage (RestApiKnexPlugin); AnyAPI resources refuse them with RestApiBulkWriteUnsupportedError. queryAll and scopedQuery work on both storages; on AnyAPI the scoped builder reads the canonical rows, whose fields live in storage slots.

scopedQuery - Scoped Read-Only Builder

const { query } = await api.resources.entries.scopedQuery(
  { filters, alias, purpose, transaction }, context)

Resolves to { query }, a Knex builder over a derived table of the resource’s rows with ownership filters, row policy and the search filters applied, so conditions the caller adds (orWhere included) only narrow them. It is wrapped because a builder is thenable. purpose is 'report' (default; includes shared rows), 'bulk-write', 'lock' or 'claim'; row policies receive it as queryPurpose. The caller needs query permission. The builder refuses writes, locking reads, table changes, clearing predicates, set operations, common table expressions and access to its Knex client, on clones too (RestApiScopedQueryReadOnlyError); joined tables are not scoped. It guards against mistakes, not hostile code.

queryAll - Every Matching Record

const { data, included } = await api.resources.entries.queryAll(
  { queryParams, pageSize, rowLimit, format, transaction }, context)

Reads every record query would select, in cursor pages of pageSize rows (default 1,000) in id order or queryParams.sort. queryParams takes filters, sort, fields and include, but not page. Past rowLimit rows it throws RestApiRowLimitError. Works on both storages.

Many-Row Writes

await resource.postMany({ data | document, onConflict, returning, format, queryParams, chunkSize, transaction }, context)
await resource.patchMany({ data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
await resource.patchWhereIds({ ids, data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
await resource.patchWhere({ filters | all: true, data | document, onlyIf, lockMode, returning, format, queryParams, chunkSize, transaction }, context)
await resource.deleteMany({ ids, missing, returning, chunkSize, transaction }, context)
await resource.deleteWhere({ filters | all: true, returning, chunkSize, transaction }, context)
Parameter Description
data / document postMany, patchMany: an array of plain records, or { data: [resource objects] }; patchMany rows carry id. patchWhereIds, patchWhere: one record of changes.
ids / filters / all The records to change: listed ids, or non-empty search filters validated as query validates them, or all: true for every row the caller can see.
returning 'none' (default), 'minimal' ({ data: [{ type, id }] }), 'full' ({ data: [records] }, not for deletes) or 'count' ({ meta: { count } }, the number of rows written, with no row data).
format, queryParams Output format, fields and include of full records.
onConflict Upsert: { keys, action: 'ignore' \| 'merge', merge? }. keys must include every ownership field, and a unique index must cover them; rows may not carry an id or repeat keys. On a resource with no ownership fields and no row policy, keys: ['id'] (or the idProperty) keys the upsert on the id, and every row carries one; keys compare exactly (a match only by collation fails with 409); only a match on the keys counts, and a clash on another unique index fails the batch. merge updates only the fields each row supplies (never defaultTo values), runs the patch checks (checkPermissions with method: 'patch') on the records it updates and is refused under a row policy.
onlyIf Compare-and-set: only rows whose stored values equal these change and are returned.
missing Id lists only: 'throw' (default; RestApiRecordsNotFoundError, 404) or 'skip' for ids the caller cannot see. patchWhere and deleteWhere refuse it: they write the rows their selection finds, with the scope and filters inside each write, and return only the rows written.
chunkSize Rows per statement (default 500), lowered to the database’s parameter limit.
lockMode patchWhere only: the row lock its selection takes before the write, as for the lock reads below.
transaction A transaction from api.transaction to join; otherwise the call owns one.

Each call is all-or-nothing. Every row is validated, stamped, passed through setters and authorized with checkPermissions (method post, patch or delete, originalContext.bulkIndex the row’s position); belongs-to targets are checked with one read per related type; then one statement runs per chunk. Patches refuse the id, ownership fields (also through a polymorphic relationship) and the lockVersionField, and accept real knex.raw() values for stored, non-relationship fields. The UPDATE, DELETE and lock statements repeat the caller’s ownership conditions, so a row that moved to another owner after the visibility read is left alone and counts as missing. Deletes remove the records’ many-to-many links (pivot rows, or AnyAPI’s links). Batch hooks run once per call: beforeProcessingPostMany, beforeDataCallPostMany, afterDataCallPostMany, the PatchMany trio and beforeDataCallDeleteMany / afterDataCallDeleteMany.

RestApiBulkWriteUnsupportedError (422) is thrown before writing for versioned resources (and those whose writes advance a version), per-record write hooks not registered with { batch: true }, rows that set many-to-many or reverse relationships, file fields, or positions. BulkOperationsPlugin exposes postMany, patchMany and deleteMany as POST, PATCH and DELETE /RESOURCE/bulk.

lockByIds, lockWhere and claim - Row Locks

await api.transaction(async transaction => {
  const { data: locked } = await resource.lockByIds({ ids, transaction, missing, lockMode, format, queryParams: { fields } }, context)
  const { data: matching } = await resource.lockWhere({ filters, transaction, lockMode, format, queryParams: { fields } }, context)
  const { data: taken } = await resource.claim({ filters, sort, limit, transaction, lockMode, format, queryParams: { fields } }, context)
})

All three need a transaction (RestApiLockOutsideTransactionError otherwise) and return the records as the locking read saw them, through the read hooks of query (enrichRecord, finish, finishQuery) and checkDataPermissions for each row. The locking read is the first statement and applies the scope itself, so plain reads later in the transaction see what a previous lock holder committed. lockByIds locks the visible rows among ids in id order (FOR UPDATE), sorting lists longer than its 1,000-id statements first; hidden or missing ids throw RestApiRecordsNotFoundError unless missing: 'skip'. On MySQL and MariaDB it locks every listed id, hidden ones too, until commit, so pass ids from a scoped read, never a raw request list. lockWhere locks the visible rows the filters select in id order, waiting for other transactions and including rows that start to match meanwhile; more than 1,000 throw RestApiRowLimitError. On MySQL and MariaDB it has no ORDER BY and forces an index that starts with the whole ownership columns (or, without ownership conditions, a filtered column), and throws RestApiBulkWriteUnsupportedError when the table has none. claim takes up to limit (default 1, at most 1,000) matching rows that no other transaction holds (SKIP LOCKED), in sort order (stored fields, default ['id']). A row policy that joins tables is refused for all three. On SQLite, which has one writer, they take the database’s write lock with a write that changes nothing, then read.

lockMode picks the row lock: 'update' (default) takes FOR UPDATE, and 'noKeyUpdate' takes FOR NO KEY UPDATE on PostgreSQL, which still excludes other writers of the rows but lets other transactions insert or change rows whose foreign keys reference them. Use it when the transaction will not change the locked rows’ primary key or the unique columns foreign keys reference. MySQL, MariaDB and SQLite take their usual lock for either mode.

A resource option lockVersionField: 'lock_version' names a stored integer that every update (single, many-row and upsert merge) sets to column + 1. Callers cannot set it; use it with onlyIf.

System Contexts

import { createSystemContext, isSystemContext, readOnlyQueryBuilder } from 'json-rest-api'

const context = createSystemContext('nightly report', { jobId })

A system context skips autofilter ownership predicates; creates must set every ownership field explicitly. Row policies receive system: true. The marker is a private symbol that object spread keeps and serialization drops; a context with system: true but no marker throws REST_API_SYSTEM_CONTEXT_FORGED. Autofilter filters may be shared: true (on a nullable field): reads include rows whose field is NULL, while many-row writes, locks and single writes stay on the owner’s rows.


Relationship Methods

Retrieves the actual related resources with full data, not just identifiers.

Method Signature

const result = await api.resources[resourceType].getRelated(params, context)

Parameters

{
  id: String|Number,           // Required: Parent resource ID
  relationshipName: String,    // Required: Name of the relationship
  format: String,             // plain or jsonapi
  queryParams: Object,         // Selection; collection filters/sort/page for to-many
  transaction: Object          // Database transaction object
}
Parameter Type Required Description
id String|Number Yes Parent resource ID
relationshipName String Yes Name of the relationship
format String No plain or jsonapi; uses the configured default
queryParams Object No Selection options; collection filters/sort/page only for to-many
transaction Object No Database transaction object

id is normalized with the effective normalizeId function before validation and lookup. If it normalizes to an empty value, the operation fails as REST_API_RESOURCE with subtype not_found.

Return Value

Uses the selected format (default plain). A to-one relationship returns one resource or null; a to-many relationship returns a collection envelope. JSON:API wraps either cardinality under data. Collection filters/sort/page apply to to-many related resources; to-one reads accept selection options.

HTTP Equivalent

GET /articles/1/author
GET /articles/1/comments?page[size]=10&sort=-created-at
Accept: application/vnd.api+json

Examples

Get Related To-One:

// Get author of article
const result = await api.resources.articles.getRelated({
  id: '1',
  relationshipName: 'author'
});

// Returns single resource (plain mode by default):
// {
//   id: '10',
//   name: 'John Doe',
//   email: 'john@example.com'
// }

// HTTP equivalent
// GET /articles/1/author

Get Related To-Many with Pagination:

// Get comments with pagination
const result = await api.resources.articles.getRelated({
  id: '1',
  relationshipName: 'comments',
  queryParams: {
    page: { size: 5, number: 1 },
    sort: ['-created-at']
  }
});

// Returns paginated collection

// HTTP equivalent
// GET /articles/1/comments?page[size]=5&page[number]=1&sort=-created-at

getRelationship - Retrieve Relationship Identifiers

Retrieves only the resource identifiers for a relationship, not the full resource data.

Method Signature

const result = await api.resources[resourceType].getRelationship(params, context)

Parameters

{
  id: String|Number,           // Required: Parent resource ID
  relationshipName: String,    // Required: Name of the relationship
  transaction: Object          // Database transaction object
}

id is normalized with the effective normalizeId function before validation and lookup. If it normalizes to an empty value, the operation fails as REST_API_RESOURCE with subtype not_found.

Return Value

JSON:API relationship document with resource identifiers only

HTTP Equivalent

GET /articles/1/relationships/author
GET /articles/1/relationships/tags
Accept: application/vnd.api+json

Examples

Get To-One Relationship:

// Get author relationship
const result = await api.resources.articles.getRelationship({
  id: '1',
  relationshipName: 'author'
});

// Returns:
// {
//   data: { type: 'users', id: '10' }
// }

// HTTP equivalent
// GET /articles/1/relationships/author

Get To-Many Relationship:

// Get tags relationship
const result = await api.resources.articles.getRelationship({
  id: '1',
  relationshipName: 'tags'
});

// Returns:
// {
//   data: [
//     { type: 'tags', id: '1' },
//     { type: 'tags', id: '2' },
//     { type: 'tags', id: '3' }
//   ]
// }

// HTTP equivalent
// GET /articles/1/relationships/tags

postRelationship - Add to Relationship

Adds new members to a to-many relationship without affecting existing members.

Method Signature

const result = await api.resources[resourceType].postRelationship(params, context)

Parameters

{
  id: String|Number,           // Required: Parent resource ID
  relationshipName: String,    // Required: Name of the relationship
  relationshipData: Array,     // Required: Array of resource identifiers
  transaction: Object          // Database transaction object
}

The parent id is normalized with the parent resource’s normalizer. Each relationshipData[*].id is normalized with the related target resource’s normalizer before validation and persistence.

Return Value

Returns undefined (204 No Content)

HTTP Equivalent

POST /articles/1/relationships/tags
Content-Type: application/vnd.api+json

{
  "data": [
    { "type": "tags", "id": "4" },
    { "type": "tags", "id": "5" }
  ]
}

Examples

Add Tags to Article:

// Add new tags without removing existing ones
await api.resources.articles.postRelationship({
  id: '1',
  relationshipName: 'tags',
  relationshipData: [
    { type: 'tags', id: '4' },
    { type: 'tags', id: '5' }
  ]
});

// Existing tags remain, new tags are added

If normalizeId is configured, both the parent id and each relationship identifier are normalized before the relationship is written.


patchRelationship - Replace Relationship

Completely replaces a relationship. For to-one relationships, sets the new related resource. For to-many relationships, replaces all members.

Method Signature

const result = await api.resources[resourceType].patchRelationship(params, context)

Parameters

{
  id: String|Number,                    // Required: Parent resource ID
  relationshipName: String,             // Required: Name of the relationship
  relationshipData: Object|Array|null,  // Required: New relationship data
  transaction: Object                   // Database transaction object
}

The parent id is normalized with the parent resource’s normalizer. Each relationship identifier inside relationshipData is normalized with the related target resource’s normalizer before validation and persistence.

Return Value

Returns undefined (204 No Content)

HTTP Equivalent

PATCH /articles/1/relationships/author
Content-Type: application/vnd.api+json

{
  "data": { "type": "users", "id": "20" }
}

Examples

Replace To-One Relationship:

// Change article author
await api.resources.articles.patchRelationship({
  id: '1',
  relationshipName: 'author',
  relationshipData: { type: 'users', id: '20' }
});

Replace To-Many Relationship:

// Replace all tags
await api.resources.articles.patchRelationship({
  id: '1',
  relationshipName: 'tags',
  relationshipData: [
    { type: 'tags', id: '1' },
    { type: 'tags', id: '2' }
  ]
});

// All previous tags are removed, only specified tags remain

Clear Relationship:

// Remove all tags
await api.resources.articles.patchRelationship({
  id: '1',
  relationshipName: 'tags',
  relationshipData: []
});

// Remove author
await api.resources.articles.patchRelationship({
  id: '1',
  relationshipName: 'author',
  relationshipData: null
});

deleteRelationship - Remove from Relationships

Removes specific members from a to-many relationship.

Method Signature

const result = await api.resources[resourceType].deleteRelationship(params, context)

Parameters

{
  id: String|Number,           // Required: Parent resource ID
  relationshipName: String,    // Required: Name of the relationship
  relationshipData: Array,     // Required: Array of resource identifiers to remove
  transaction: Object          // Database transaction object
}

The parent id is normalized with the parent resource’s normalizer. Each relationshipData[*].id is normalized with the related target resource’s normalizer before validation and persistence.

Return Value

Returns undefined (204 No Content)

HTTP Equivalent

DELETE /articles/1/relationships/tags
Content-Type: application/vnd.api+json

{
  "data": [
    { "type": "tags", "id": "2" },
    { "type": "tags", "id": "3" }
  ]
}

Examples

Remove Specific Tags:

// Remove specific tags from article
await api.resources.articles.deleteRelationship({
  id: '1',
  relationshipName: 'tags',
  relationshipData: [
    { type: 'tags', id: '2' },
    { type: 'tags', id: '3' }
  ]
});

// Only specified tags are removed, others remain

Hook System

Register hooks with api.customize({ hooks }), or with a resource’s extras when declaring it. Handlers receive an injected argument object containing context; they do not receive that context as the entire first argument. The library does not provide the api.resource(...).hook(...) API used by older examples in this reference.

await api.customize({
  hooks: {
    beforeSchemaValidatePost: {
      functionName: 'trim-article-title',
      handler: ({ context }) => {
        if (context.scopeName !== 'articles') return;
        const attributes = context.inputRecord.data.attributes;
        if (typeof attributes?.title === 'string') attributes.title = attributes.title.trim();
      }
    }
  }
});

Complete Hook Execution Order

POST, PUT and PATCH retain their method-specific behavior within this sequence:

  1. Set up the transaction and response options; run beforeProcessing, then the method-specific processing hook.
  2. Validate the request/relationships and perform the method’s existence checks. Run beforeSchemaValidate, then its method-specific hook; validate attributes; run method-specific afterSchemaValidate, then afterSchemaValidate.
  3. Authorize the write; run beforeDataCall, then its method-specific hook.
  4. Lock the required records, apply setters and perform the storage operation.
  5. Run method-specific afterDataCall, then afterDataCall; update relationships and refresh minimal stored data.
  6. Prepare the selected response; run finish, then its method-specific hook.
  7. An owning operation commits and awaits afterCommit. Managed participants defer completion to their owner.

returning: 'full' performs a nested GET during response preparation. Its GET hooks observe method: 'get', while outer write hooks retain their write method. The nested read keeps authentication and transaction identity. None/minimal responses do not imply that validation, setters or finish hooks are skipped.

GET and QUERY run beforeData and their method-specific hook before reading. They run enrichment and finish hooks over the internal JSON:API record before final output conversion. GET additionally runs data-permission checks and enrichRecordWithRelationships. DELETE and relationship writes have their own sequences; use the hook and lifecycle guide for those contracts rather than extrapolating POST’s stages.

Hook Context Objects

Ordinary operation hooks share the caller’s mutable context. They may add custom properties for later hooks and change documented write values at the appropriate stage. Mutations remain visible to the caller after completion. Use a separate context for every independent operation, especially concurrent calls.

Ordinary CRUD permission hooks receive a wrapper whose originalContext points to that operation context. Relationship permission hooks receive the operation directly. Read-enrichment wrappers expose the read context as parentContext. Library-created nested calls generally use a shallow copy: top-level replacements are local to the child, while referenced application objects and caches remain shared. This is not deep isolation. See the complete hook contract for precise hook arguments, inheritance and reserved fields.

Context availability is stage-specific:

Stage Fields and use
Write processing/schema validation method, scopeName, inputRecord, format, returning, queryParams, schemaInfo, transaction, db; either public input becomes the normalized JSON:API context.inputRecord. Modify context.inputRecord.data.attributes before validation when preparing input.
After attribute validation context.inputRecord.data.attributes contains validated values; setters run later. originalInputAttributes retains the pre-validation attribute snapshot.
After storage write POST has assigned its storage ID; after-data hooks see transformed input attributes. Method-specific PUT/PATCH behavior remains visible.
Read enrichment/finish record is the internal JSON:API document. Final normalization/field filtering and plain conversion happen after finish.
Write finish responseRecord holds the selected output; minimal stored data is separately available. A full response may already have run nested GET hooks.
Completion afterCommit follows acknowledged commit; afterRollback follows acknowledged rollback. A managed participant finishes its data work before its owner’s completion hooks.

Application authentication is not defined by the library. Pass trusted application context from the caller/transport; do not infer it from payload data. Use row policies for mandatory row visibility. Do not treat a single GET permission hook as a universal query/relationship guard.

An ordinary failure stops later stages. A post-commit failure cannot undo the write; unknown outcomes require reconciliation. Original and secondary diagnostics remain subject to the transaction/error contract.

knexQueryFiltering

Query customization operates on native builders with explicit storage mapping. Use the query hook migration contract for aliases, logical/physical fields and canonical tenant/resource scoping. Keep mandatory predicates in the supported policy/filtering mechanisms. A raw query fragment is not automatically translated or made tenant-safe.

Query Features

Filtering

The library supports flexible filtering through the filters parameter in query operations.

Basic Filtering

// Simple equality filter
const result = await api.resources.articles.query({
  queryParams: {
    filters: {
      status: 'published',
      author: '10'
    }
  }
});

// HTTP equivalent
// GET /articles?filter[status]=published&filter[author]=10

Operator-based Filtering

Name filters explicitly in searchSchema, selecting the underlying field and operator. Field type names and filterOperator values follow their actual contracts; an arbitrary operators array does not register filter aliases.

searchSchema: {
  publishedAfter: { type: 'dateTime', actualField: 'published_at', filterOperator: '>=' },
  titleContains: { type: 'string', actualField: 'title', filterOperator: 'contains' },
  above: { type: 'number', actualField: 'view_count', filterOperator: '>' },
  articleIds: { type: 'array', actualField: 'id', filterOperator: 'in' }
}

With those declarations:

const result = await api.resources.articles.query({
  queryParams: { filters: { titleContains: 'javascript', above: 100 } }
});

Array Filters (IN operator)

const result = await api.resources.articles.query({
  queryParams: { filters: { articleIds: ['1', '2', '3'] } }
});

Use the structured-query transport contract when encoding non-scalar filter values for HTTP. Declared field equality/search capabilities and mandatory row policies still apply.

Custom Filter Logic

Declare custom filters in the resource’s searchSchema. applyFilter receives the native builder, validated filter input and explicit mapping helpers:

searchSchema: {
  minimumRank: {
    type: 'number',
    applyFilter(query, input, { column, value }) {
      query.where(column('rank'), '>=', value('rank', input));
    }
  }
}

Here rank must be a declared stored field. Mapping helpers resolve the active alias and storage representation in either storage mode. Callbacks run synchronously and must mutate the supplied builder. Raw SQL and extra joins remain responsible for their own physical columns and visibility semantics. See custom-filter migration.

Sorting

Control the order of results using the sort parameter.

Basic Sorting

// Sort by single field (ascending)
const result = await api.resources.articles.query({
  queryParams: {
    sort: ['title']
  }
});

// Sort by single field (descending)
const result = await api.resources.articles.query({
  queryParams: {
    sort: ['-created_at']
  }
});

// HTTP equivalent
// GET /articles?sort=title
// GET /articles?sort=-created_at

Multi-field Sorting

// Sort by multiple fields
const result = await api.resources.articles.query({
  queryParams: {
    sort: ['-featured', '-created_at', 'title']
  }
});

// HTTP equivalent (comma-separated)
// GET /articles?sort=-featured,-created_at,title
// Sort by related resource fields (if configured)
const result = await api.resources.articles.query({
  queryParams: {
    sort: ['author.name', '-category.priority']
  }
});

Pagination

queryParams.page Behavior
Omitted or {} Capped default limit, no pagination metadata
{ number: 2, size: 20 } Offset page 2
{ size: 20 } First cursor page
{ after: cursor, size: 20 } Forward cursor page
{ before: cursor, size: 20 } Backward cursor page

Use one of number, after or before. Sizes/numbers must be positive integers; empty or malformed cursors reject. Treat cursors as opaque and follow generated links to preserve the resource, selection, filters, sort and effective size. Do not construct base64 cursor payloads yourself.

Offset Pagination

const result = await api.resources.articles.query({
  queryParams: { page: { number: 2, size: 20 } }
});
// With counts enabled and 150 matching records:
// result.meta.pagination = { page: 2, pageSize: 20, pageCount: 8, total: 150, hasMore: true }

Pagination metadata is under meta.pagination. With counts disabled, it includes page, pageSize and hasMore, without totals: the page reads one extra row instead of running a count query, so it is one statement and links to first, prev and next but not last. The library supplies pagination links appropriate to the available count/boundary information.

Cursor Pagination

const firstPage = await api.resources.articles.query({
  queryParams: { page: { size: 20 } }
});
// Follow firstPage.links.next through the HTTP client, or use the returned
// cursor with the same query configuration for a programmatic next-page call.

Cursor pages use an extra row for hasMore; they never issue an offset count query. See pagination and ordering for cursor metadata, link traversal and ordering guarantees.

Pagination Configuration

await api.use(RestApiPlugin, {
  queryDefaultLimit: 20,
  queryMaxLimit: 100,
  enablePaginationCounts: true
});

Sparse Fieldsets

Request only specific fields to reduce payload size:

// Request specific fields for articles
const result = await api.resources.articles.query({
  queryParams: {
    fields: {
      articles: 'title,summary,published_at'
    }
  }
});

// With includes - specify fields for each type
const result = await api.resources.articles.query({
  queryParams: {
    include: ['author', 'category'],
    fields: {
      articles: 'title,summary',
      users: 'name,avatar',
      categories: 'name,slug'
    }
  }
});

// HTTP equivalent
// GET /articles?fields[articles]=title,summary&fields[users]=name,avatar

An unknown field raises RestApiFieldsetError with the stable code REST_API_FIELDSET_INVALID and HTTP status 400. The same error is preserved for primary resources and included relationships, so transports can classify it without parsing error messages.

Load related resources in a single request:

Basic Includes

// Include single relationship
const result = await api.resources.articles.query({
  queryParams: {
    include: ['author']
  }
});

// Include multiple relationships
const result = await api.resources.articles.query({
  queryParams: {
    include: ['author', 'category', 'tags']
  }
});

// HTTP equivalent
// GET /articles?include=author,category,tags

Nested Includes

// Include nested relationships
const result = await api.resources.articles.query({
  queryParams: {
    include: ['author.profile', 'comments.user', 'category.parent']
  }
});

// Deep nesting (limited by includeDepthLimit)
const result = await api.resources.articles.query({
  queryParams: {
    include: ['comments.user.profile.avatar']
  }
});

// HTTP equivalent
// GET /articles?include=comments.user.profile.avatar

Include with Filtering

Use target-resource policies and supported query/filter configuration to control which related records are visible. Filtering included after fetching does not remove corresponding linkage, authorize the target rows, or establish correct per-parent limits. The library does not provide an afterDataQuery hook for that purpose. See row policies, include configuration and the query hook contract above.

Per-Parent Limits (Window Includes)

A to-many relationship can include only the first few related records of each parent, such as the newest snapshot per booking. Configure it on the relationship, not in the query:

relationships: {
  snapshots: {
    type: 'hasMany', target: 'snapshots', foreignKey: 'booking_id',
    include: { strategy: 'window', limit: 1, orderBy: ['-revision'] }
  }
}

include: ['snapshots'] then returns at most limit visible snapshots per booking, ordered by orderBy, in one query. The default (standard) strategy applies one limit to the whole included set instead. Window includes need database window functions (api.knex.capabilities.windowFunctions). See window includes.

Configuration Options

Plugin Configuration

await api.use(RestApiPlugin, {
  format: 'plain',
  returning: 'full',
  queryDefaultLimit: 20,
  queryMaxLimit: 100,
  includeDepthLimit: 3,
  enablePaginationCounts: true,
  normalizeId: value => {
    if (value === null || value === undefined) return null;
    const normalized = String(value).trim();
    return normalized ? normalized.toUpperCase() : null;
  }
});

Schema Configuration

Resource Schema Structure

Pass the resource name separately from its options. schema is the field map; relationships and search configuration are sibling resource options.

await api.addResource('articles', {
  tableName: 'articles',
  sortableFields: ['id', 'title'],
  schema: {
    id: { type: 'id' },
    title: { type: 'string', required: true, max: 200, search: true },
    content: { type: 'string', required: true },
    published_at: { type: 'dateTime', nullable: true },
    metadata: { type: 'object', nullable: true },
    internal_notes: { type: 'string', hidden: true },
    preview: { type: 'boolean', virtual: true },
    word_count: {
      type: 'number', computed: true, dependencies: ['content'],
      compute: ({ attributes }) => attributes.content.trim().split(/\s+/).filter(Boolean).length
    }
  },
  searchSchema: {
    titleContains: { type: 'string', actualField: 'title', filterOperator: 'contains' }
  }
});
await api.resources.articles.createKnexTable();

Stored belongs-to fields declare belongsTo: 'users' and as: 'author' on their schema field. Reverse and many-to-many relationships live in the sibling relationships map, for example { type: 'hasMany', target: 'comments', foreignKey: 'article_id' }. A many-to-many definition specifies target, through, foreignKey and otherKey; register its target and pivot resources. Use the relationship guides for complete related declarations, nullability and cardinality rules.

Schema compilation snapshots declarations. Install enrichment hooks before registration; changing an earlier declaration object does not recompile a live resource. See schema compilation and table/schema helper contracts.

Important Schema Features

ID Property Configuration

// Physical primary key column: user_id
await api.addResource('users', {
  idProperty: 'user_id',
  schema: {
    id: { type: 'id', required: true, storage: { column: 'user_id' } },
    email: { type: 'string', required: true },
    displayName: { type: 'string' }
  }
});

idProperty names the physical primary-key column for table-backed resources. At the API layer, the resource id remains id, so writes use data.id or document.data.id, reads return id or data.id, and the resource id is not part of attributes.

Resource ID Normalization

normalizeId canonicalizes resource identifiers before the library reads, writes, validates, or links records.

The default normalizer:

  • trims surrounding whitespace from strings
  • converts finite numbers and bigints to strings
  • rejects values that normalize to an empty identifier

Provide a custom normalizer when your ids need additional canonicalization:

await api.use(RestApiPlugin, {
  normalizeId: (value) => {
    if (value === null || value === undefined) {
      return null
    }

    const normalized = String(value).trim()
    return normalized ? normalized.toUpperCase() : null
  }
});

Resource-level overrides take precedence over the plugin-level default:

await api.addResource('countries', {
  normalizeId: (value) => {
    if (value === null || value === undefined) {
      return null
    }

    const normalized = String(value).trim()
    return normalized ? normalized.toLowerCase() : null
  },
  schema: {
    name: { type: 'string', required: true }
  }
});

Normalization is applied to:

  • parent ids used by get, put, patch, delete, getRelated, and getRelationship
  • explicit POST, PUT, and PATCH document ids
  • relationship identifiers used by postRelationship, patchRelationship, and deleteRelationship

Relationship identifiers use the normalizer of the related target resource, not the parent resource.

If an id normalizes to an empty value:

  • existing-resource operations fail as REST_API_RESOURCE with subtype not_found
  • explicit POST document ids fail as REST_API_VALIDATION on data.id

Storage Column Naming

Table-backed resources use snake_case physical columns by default. For example, displayName maps to display_name.

Use storage.column when a field needs a specific column name:

await api.addResource('profiles', {
  schema: {
    id: { type: 'id' },
    displayName: { type: 'string', required: true },
    legacyRef: { type: 'string', storage: { column: 'legacy_profile_ref' } }
  }
});

If physical columns should match logical field names exactly for an entire resource, use storage: { naming: 'exact' }.

Virtual Fields

Mark an individual schema field with virtual: true, for example preview: { type: 'boolean', virtual: true }. It is validated but omitted from database writes; it is not a persisted UI-state field. Use a setter when a virtual input needs to prepare other stored attributes. A virtualFields array is not the resource declaration contract. See virtual-field behavior.

Email and UUID Fields

type: 'email' and type: 'uuid' store trimmed strings and reject values that are not an email address or a UUID. Filters with a text operator (like, contains, startsWith, endsWith) accept fragments. Registration rejects any type json-rest-schema cannot cast, so a misspelt type fails at addResource rather than on the first write.

Transaction ID Fields (PostgreSQL)

type: 'xid8' is PostgreSQL’s 64-bit transaction id, the value pg_current_xact_id() returns, for a column a table stamps with the transaction that last wrote each row. Values are decimal text below 2^64 (a bigint is accepted too) and are stored and returned as canonical text, without leading zeros; anything else fails with RestApiValidationError before a statement runs, in writes and in filters. Filters compare in the database as xid8: =, !=, <, <=, >, >=, and in or between through an array search field whose actualField is the xid8 field; text search operators are refused at registration. aggregate takes min and max of it, not sum or avg. createKnexTable makes an xid8 column. Only Knex storage on PostgreSQL has the type: registration refuses an xid8 field, or a search field on one, on any other database and on AnyAPI storage.

await api.addResource('messages', {
  schema: {
    body: { type: 'string', required: true },
    changed: { type: 'xid8' } // DEFAULT pg_current_xact_id(), set again by an UPDATE trigger
  },
  searchSchema: {
    changedSince: { type: 'xid8', actualField: 'changed', filterOperator: '>=' }
  }
})

Retired Fields

retired: true keeps a stored field readable, filterable and sortable while rejecting every value written to it, including null, with the retired_field rule. required and defaultTo no longer apply, and PUT leaves its stored value alone. Only stored attributes can be retired, not belongs-to fields. AnyAPI resources can retire fields at run time with retireKnexFields.

Field Transformations

Getters and setters receive a value and an injected context. Computed fields receive the context object with attributes, not a bare record positional argument. Declare dependencies when another field’s value is needed.

schema: {
  temperature_c: { type: 'number' },
  temperature_f: {
    type: 'number', computed: true, dependencies: ['temperature_c'],
    compute: ({ attributes }) => attributes.temperature_c * 9 / 5 + 32
  },
  settings: { type: 'object', nullable: true }
}

Object/array storage already serializes and decodes JSON; do not add JSON.stringify/JSON.parse callbacks solely to duplicate that conversion. Use a synchronous storage.serialize when writes and filter comparisons need a custom storage representation. Getters/setters may be asynchronous; their failures propagate under the documented error contract. See field transformations and serializer migration.

Error Handling

The library uses standard JSON:API error format:

Error Response Format

HTTP connectors return JSON:API errors arrays. Fields depend on the mapped error; a programmatic error’s code is not copied into every HTTP error object. For example, a resource not-found error maps to:

{
  errors: [{ status: '404', title: 'Not Found', detail: 'Article not found' }]
}

Common Error Types

Programmatic error/code Default HTTP status
RestApiValidationError / REST_API_VALIDATION 422
RestApiResourceError, subtype not_found 404
RestApiResourceError, subtype forbidden 403
RestApiResourceError, subtype conflict 409
Other resource/relationship subtypes 400
RestApiPayloadError 400, or 413 for a size rejection
REST_API_FIELDSET_INVALID, REST_API_INCLUDE_INVALID 400
REST_API_VERSION_CONFLICT 409
REST_API_PRECONDITION_FAILED 412
RestApiRecordsNotFoundError (subtype not_found) 404
RestApiBulkWriteUnsupportedError / REST_API_BULK_WRITE_UNSUPPORTED 422
RestApiLockOutsideTransactionError (a validation error) 422
RestApiBulkInsertIdsError / REST_API_BULK_INSERT_IDS 500
RestApiScopedQueryReadOnlyError / REST_API_SCOPED_QUERY_READ_ONLY programmatic only
RestApiRowLimitError / REST_API_ROW_LIMIT 500
REST_API_SYSTEM_CONTEXT_FORGED 500
Unexpected or temporal output failure 500

A 500 answer carries the error’s message only for the library’s own RestApi…Error classes. Any other error, a database error or a plain Error thrown by a hook, answers with the detail An unexpected error occurred: a database error’s message repeats the SQL and its values. The connector’s HTTP request error or Fastify request error log entry records only the name and code of a database error.

Validation violations produce individual HTTP errors with their message and field location; generic validation errors retain their available details. Relationship validation/access failures follow their actual typed classification, not a universal INVALID_RELATIONSHIP code. Write errors additionally carry transaction outcome metadata as described below.

Custom Error Handling

Typed API errors thrown by getters, setters, computed fields, file detectors, or upload adapters retain their original code and details. For example, throwing new RestApiResourceError('Upload forbidden', { subtype: 'forbidden' }) from an upload adapter produces a 403 response and still runs temporary-file cleanup.

Unexpected setter/getter/computed failures reject the operation with their original cause and context: { scopeName, fieldName, phase }; on writes, this diagnostic error is the outer RestApiWriteError’s cause. HTTP connectors return 500. Getters no longer retain an untransformed value after failure, and computed fields no longer substitute null. Use RestApiValidationError for intentional validation rejection (422). Full write-response enrichment fails before owned commit; borrowed transactions remain the owner’s responsibility. See the callback migration guide for examples and intentional fallback handling.

Resource, relationship, many-row and canonical registry write failures use the root export RestApiWriteError. Its immutable transactionOutcome is none, pending, committed, rolledBack or unknown. Its cause preserves the original thrown value, including frozen errors, while classification fields such as code, subtype and details remain directly readable. Read errors retain their existing contract.

HTTP JSON:API write errors include meta.transactionOutcome. A failed call can represent a committed write when a later hook rejects. Retrying that call can duplicate its effects; neither an HTTP status nor an unknown outcome authorizes automatic replay. See the transaction migration guide for all five meanings and cause-chain handling. If acknowledgement is lost, unknown requires application reconciliation before replay or file deletion; see commit uncertainty. Consumer migration remains separate.

import { RestApiValidationError } from 'json-rest-api';

await api.customize({
  hooks: {
    beforeSchemaValidatePost: {
      functionName: 'validate-article-title',
      handler: ({ context }) => {
        if (context.scopeName !== 'articles') return;
        const title = context.inputRecord.data.attributes?.title;
        if (typeof title === 'string' && title.length < 10) {
          throw new RestApiValidationError('Title too short', { fields: ['title'] });
        }
      }
    }
  }
});

For HTTP customization use the documented transport response/error hooks. There is no library-wide api.hook('errorTransform', ...) API. Preserve typed error classification, original causes and write outcomes when customizing output.


Advanced Features

Transaction Support

api.transaction(callback, context = {}) owns one top-level transaction and awaits completion hooks before returning the callback value unchanged. The callback receives a real Knex transaction for library operations and raw SQL. Await every operation and use a separate context object per enlisted operation.

// Automatic transaction (recommended)
const result = await api.resources.articles.post({
  data: {
    title: 'New Article',
    content: 'Content...'
  }
  // No transaction provided - library creates and auto-commits
});

// Several writes in one managed unit
const article = await api.transaction(async transaction => {
  // Create article
  const article = await api.resources.articles.post({
    data: {
      title: 'New Article',
      content: 'Content...'
    },
    format: 'plain',
    transaction
  });
  
  // Create related comments
  for (const commentData of comments) {
    await api.resources.comments.post({
      data: {
        content: commentData.content,
        article_id: article.id
      },
      format: 'plain',
      transaction
    });
  }
  
  return article;
});

Return normally to commit; throw to roll back. A caught participating write or observed SQL failure still aborts the unit. Do not call commit() or rollback() inside the callback. Another api.transaction() call creates an independent top-level unit; compose work by forwarding the existing handle. Raw Knex transactions remain available for direct SQL and read-only API calls.

See the managed contract and migration guide.

Knex Schema Helpers

When a resource is backed by RestApiKnexPlugin, its scope also exposes table-management helpers:

await api.resources.articles.createKnexTable()

const snapshot = await api.resources.articles.introspectKnexTableSnapshot()
const createMigration = await api.resources.articles.generateKnexMigration()
const diff = await api.resources.articles.generateKnexMigrationDiff()

Available helpers:

  • createKnexTable()
  • addKnexFields({ fields })
  • alterKnexFields({ fields })
  • introspectKnexTableSnapshot()
  • generateKnexMigration()
  • generateKnexMigrationDiff()

generateKnexMigrationDiff() returns:

{
  migration,
  warnings,
  plan
}

Important boundaries:

  • this is a live-table snapshot diff, not migration-history reconstruction
  • addKnexFields() and alterKnexFields() are field-only helpers
  • indexes, foreign keys, and check constraints belong on the full table schema surface

Generated migrations are drafts for review. By default, the diff preserves undeclared columns, indexes and foreign keys and reports them in warnings. Intentional removals require the corresponding boolean under options: allowDropColumns, allowDropIndexes or allowDropForeignKeys. Changing an explicitly named index or foreign key can still replace it; inspect plan and warnings before execution. A column drop cannot retain a dependent index or foreign key. Full snapshots reject generated columns and SQLite partial or expression indexes; the guide lists further dialect restrictions. Diff migrations do not generate a working down; write the recovery steps as part of the migration review.

For full examples and dialect notes, see Knex Schema and Migrations.

Batch Operations

Write many records with the many-row methods: one statement per chunk, all-or-nothing, with the same validation and permissions as single writes. Group them with other calls in api.transaction:

await api.transaction(async transaction => {
  await api.resources.articles.postMany({ data: articlesData, transaction })
  await api.resources.articles.patchWhereIds({ ids: staleIds, data: { status: 'archived' }, transaction })
})

When each record needs its own hooks (or the resource refuses many-row writes), call the single-record methods in a loop inside one api.transaction.

Computed Fields

Declare computed fields in schema, with computed: true, dependencies and compute: ({ id, attributes, context }) => value. Computed values are not stored columns. Callbacks can return promises; requested fieldsets determine which computed values and their dependencies are evaluated. Dependencies establish read preparation/order, not a persistent reactive cache.

The resource schema example above declares word_count. Request it through queryParams.fields, for example { articles: 'title,word_count' }. See field transformations for hidden dependencies, getter ordering and callback context.

Polymorphic Relationships

Declare a polymorphic belongs-to relationship under the resource’s sibling relationships option:

relationships: {
  commentable: {
    belongsToPolymorphic: {
      types: ['articles', 'videos', 'photos'],
      typeField: 'commentable_type',
      idField: 'commentable_id'
    }
  }
}

For a JSON:API write, send the selected target as relationship linkage, for example relationships: { commentable: { data: { type: 'articles', id: '1' } } } inside the document’s data. The target type must be declared and the referenced record must satisfy visibility/validation rules. Reverse polymorphic relationships use their declared via relationship. See the polymorphic guide for complete schemas and reverse declarations.

Soft Deletes

Soft deletion is an application policy, not a built-in softDelete resource option. Declare a nullable timestamp/marker and explicitly PATCH it when archiving. Changing context.method during DELETE does not change the executing method into PATCH. Apply a row policy for normal visibility and design a separate authorized restore path; an arbitrary include_deleted filter does not enable bypassing that policy. See row policies and the transaction contract when archiving must update related records.

Field-Level Permissions

Declare hidden or normallyHidden output fields according to their intended visibility. Output omission is not write authorization. Use trusted application context and the appropriate validation/permission hook to reject unauthorized attribute changes before storage. Global customizations must restrict themselves to the intended context.scopeName; write attributes at schema-validation stages are in context.inputRecord.data.attributes.

See field transformations and visibility and row policies. Computed values must obey the selected fieldset and the application’s authorization rules too.

Prefer declared cross-resource search paths so the library can prepare aliases and apply target-resource visibility. A native join in a custom filtering hook does not automatically gain those permission checks. Custom SQL must use the active context.knexQuery builder/alias and explicit storage mappings, with bindings for values. See native query builders and query projections.

Database-Specific Features

Both Knex plugins expose the detected window-function capability as api.knex.capabilities.windowFunctions. Per-parent include limits require that capability; the library rejects those limits when detection reports no support. The operation’s context.db is a Knex connection or transaction. It does not provide supportsWindowFunctions or supportsJsonb flags.

Use declared scalar JSON-key query fields for structured-value filtering, sorting and cursors. Whole object/array fields are not generic query keys; custom raw SQL remains specific to its actual driver and physical storage. See the query projection guide and backend capabilities for supported operations, tests and limitations. A dialect branch in a helper is not evidence that every server version or storage combination has been verified.