UNPKG

apialize

Version:

Turn a database model into a production ready REST(ish) CRUD API in a few lines.

1,728 lines (1,423 loc) 63.9 kB
# apialize Turn a Sequelize‑like model into a production‑ready REST(ish) CRUD API in a few lines. Drop‑in Express routers for list / read / create / update / patch / destroy with: - Pluggable middleware (auth, ownership, validation) - Simple primary identifier assumption (`id` column, configurable) - Per‑model default pagination + ordering (`page_size`, `orderby`, `orderdir`) - Automatic equality filtering from query string (`?field=value`) - Consistent response shapes + 404 handling No heavy abstractions: you keep full control of Express and your models. Works with Sequelize or any model object that implements the required methods. --- ## Contents 1. Installation 2. Quick start 3. API reference (helpers, options) 4. Response formats 5. Filtering, pagination, ordering 6. Middleware and `req.apialize` 7. Input Validation 8. Pre/Post Hooks and Query Control 9. Related models with `single(..., { related: [...] })` 10. Member routes (follow-up routes on a single resource) 11. Nested related routes (recursion) 12. Bulk delete on related collections ## 1. Installation ```bash npm install apialize # or yarn add apialize ``` Peer expectations: you provide an Express app and a “Sequelize‑like” model exposing the following methods (same signatures as Sequelize): - `findAndCountAll(options)` - `findOne(options)` - `create(values, options)` - `update(values, options)` (static) - `destroy(options)` (static) Instances returned by `create` / `findOne` can optionally implement `.get({ plain: true })`; if not present, objects are shallow‑copied. --- ## 2. Quick start ```js const express = require('express'); const bodyParser = require('body-parser'); const { crud } = require('apialize'); const { Thing } = require('./models'); // Sequelize model example const app = express(); app.use(bodyParser.json()); // Mount full CRUD at /things (uses default identifier = "id") app.use('/things', crud(Thing)); app.listen(3000, () => console.log('API on :3000')); ``` You instantly get: | Method | Path | Helper | Description | | ------ | ----------- | --------- | ---------------------------------------------- | | GET | /things | `list` | List + count (with optional filters) | | GET | /things/:id | `single` | Fetch one (404 if not found) | | POST | /things | `create` | Create (201) returns `{ success: true, id }` | | PUT | /things/:id | `update` | Full replace (unspecified fields null/default) | | PATCH | /things/:id | `patch` | Partial update (only provided fields) | | DELETE | /things/:id | `destroy` | Delete (404 if nothing affected) | --- ## 3. API reference All helpers return an `express.Router` you mount under a base path: ```js const { list, single, create, search, update, patch, destroy, crud, } = require('apialize'); ``` Individual mounting (choose only what you need): ```js app.use('/widgets', create(Widget)); // POST /widgets app.use('/widgets', search(Widget)); // POST /widgets/search app.use('/widgets', list(Widget)); // GET /widgets app.use('/widgets', single(Widget)); // GET /widgets/:id app.use('/widgets', update(Widget)); // PUT /widgets/:id app.use('/widgets', patch(Widget)); // PATCH /widgets/:id app.use('/widgets', destroy(Widget)); // DELETE /widgets/:id ``` Bundled mounting: ```js app.use('/widgets', crud(Widget)); // Exposes all endpoints: // GET /widgets (list) // GET /widgets/:id (single) // POST /widgets (create) // POST /widgets/search (search) // PUT /widgets/:id (update) // PATCH /widgets/:id (patch) // DELETE /widgets/:id (destroy) ``` `crud(model, options)` is sugar that internally mounts every operation with shared configuration + shared/global middleware. ### Helper signatures Each helper accepts `(model, options = {}, modelOptions = {})` unless otherwise stated. `options.middleware` is an array of Express middleware. `modelOptions` are passed through to Sequelize calls (`attributes`, `include`, etc.). #### `modelOptions.scopes` You can apply Sequelize scopes declaratively by including a `scopes` array in `modelOptions`. Scopes are applied automatically before pre-hooks run, allowing you to filter data at the model level: ```js // Define scopes in your model User.addScope('active', { where: { status: 'active' } }); User.addScope('byTenant', (tenantId) => ({ where: { tenant_id: tenantId } })); User.addScope('featured', { where: { is_featured: true } }); // Apply single scope app.use( '/users', list( User, {}, { scopes: ['active'], // Only show active users } ) ); // Apply multiple scopes (combined with AND logic) app.use( '/users', list( User, {}, { scopes: ['active', 'featured'], // Only show active AND featured users } ) ); // Apply parameterized scopes app.use( '/users', single( User, {}, { scopes: [ 'active', { name: 'byTenant', args: [req.user.tenantId] }, // Parameterized scope ], } ) ); // Works with write operations to restrict which records can be modified app.use( '/users', update( User, {}, { scopes: ['active'], // Only allow updates to active users (404 if inactive) } ) ); app.use( '/users', destroy( User, {}, { scopes: ['byTenant', 'active'], // Only allow deletion of active users in tenant } ) ); ``` **Key behaviors:** - **Applied before pre-hooks**: Scopes are applied first, then pre-hooks can add additional filtering - **All operations**: Works with `list`, `single`, `create`, `update`, `patch`, `destroy`, and `search` - **Combinable**: Can be combined with other `modelOptions` like `attributes`, `include`, etc. - **Write operation restrictions**: For `single`, `update`, `patch`, and `destroy`, scopes limit which records can be accessed/modified (returns 404 if no matching record found) - **AND logic**: Multiple scopes are combined with AND logic - **Error handling**: Invalid scopes are logged as errors but don't prevent the operation from continuing ```js // Example: Multi-tenant application with role-based access app.use( '/documents', list( Document, {}, { scopes: [ { name: 'byTenant', args: [req.user.tenantId] }, 'published', { name: 'byAccessLevel', args: [req.user.role] }, ], attributes: ['id', 'title', 'created_at'], include: [{ model: User, as: 'author', attributes: ['name'] }], } ) ); ``` - `list(model, options?, modelOptions?)` - `single(model, options?, modelOptions?)` - `create(model, options?, modelOptions?)` - `search(model, options?, modelOptions?)` // POST body-driven list variant - `update(model, options?, modelOptions?)` - `patch(model, options?, modelOptions?)` - `destroy(model, options?, modelOptions?)` - `crud(model, options?)` // composition helper For `single()`, `update()`, `patch()`, and `destroy()` the `options` object supports: - `middleware`: array of middleware functions - `id_mapping`: string mapping URL param to a field (default `'id'`) - `validate` (create/update/patch only): boolean, enables automatic Sequelize validation on request body (default `true`) - `param_name` (single only): change the name of the URL parameter used by `single()` for the record id (default `'id'`) - `member_routes` (single only): array of follow-up routes that run after the single record is loaded. Each item is an object `{ path, handler, method = 'get', middleware = [] }`. Passing an empty object `{}` as the second argument is ignored (backwards compatibility). Any function argument is treated as middleware. ### Options Helper options are deliberately minimal. `crud()` accepts: | Option | Type | Default | Description | | ------------ | ------ | ------- | -------------------------------------------------------------------------- | | `middleware` | array | `[]` | Global middleware applied (in order) to every operation. | | `routes` | object | `{}` | Per‑operation extra middleware: `{ list: [fnA], create: [fnB, fnC] }` etc. | Example: ```js const opts = { middleware: [authenticate], routes: { list: [rateLimitList], create: [validateBody], // search uses default path '/search' under crud. You can attach middleware here. search: [requireRole('analyst')], }, }; app.use('/widgets', crud(Widget, opts)); ``` ### Create options The `create(model, options?, modelOptions?)` helper also supports: - `allow_bulk_create` (boolean, default `false`) - When the request body is an array and this flag is `true`, `create` will insert all records in a single transaction using the model's `bulkCreate` and return an array of created objects. - When `false` (the default) and the request body is an array, the request is rejected with `400 { success: false, error: "Bulk create disabled" }`. - Identifier mapping is respected for array responses: if `id_mapping` is set (e.g., `'external_id'`), each returned object will also have `id` set to that mapped value. - `validate` (boolean, default `true`) - When `true` (the default), enables automatic Sequelize model validation on request body data before other middleware runs. - Validation runs on the input data using `model.build(data).validate()` for single objects or each item in arrays. - For `PATCH` operations, only validates the fields being updated (partial validation). - If validation fails, returns `400 { success: false, error: "Validation failed", details: [...] }` where `details` contains an array of validation error objects with `field`, `message`, and `value` properties. - When `false`, no automatic validation is performed - validation occurs at the Sequelize level during save operations. ### Identifier mapping apialize assumes your public identifier is an `id` column. For record operations (`single`, `update`, `patch`, `destroy`), customize which field the URL parameter maps to using `id_mapping`: ```js // Default behavior - maps :id parameter to 'id' field app.use('/items', single(Item)); app.use('/items', update(Item)); app.use('/items', patch(Item)); app.use('/items', destroy(Item)); // Custom mapping - maps :id parameter to 'external_id' field app.use('/items', single(Item, { id_mapping: 'external_id' })); app.use('/items', update(Item, { id_mapping: 'external_id' })); app.use('/items', patch(Item, { id_mapping: 'external_id' })); app.use('/items', destroy(Item, { id_mapping: 'external_id' })); // Example: GET /items/abc-123 will query WHERE external_id = 'abc-123' // PUT /items/abc-123 will update WHERE external_id = 'abc-123' // PATCH /items/abc-123 will update WHERE external_id = 'abc-123' // DELETE /items/abc-123 will delete WHERE external_id = 'abc-123' ``` For related model filtering and ordering, see the `relation_id_mapping` option documented in the filtering sections below. Pagination & ordering precedence (within `list()`): 1. Query parameters (`api:page_size`, `api:order_by`, `api:order_dir`) 2. Model defaults (`page_size`, `orderby`, `orderdir` on `model.apialize` – only these pagination/ordering keys are used) 3. Hard‑coded fallbacks: page_size 100, ordering by `id` ascending. --- ## 4. Response formats Success responses: - `list` → `{ success: true, meta: { page, page_size, total_pages, count[, order] }, data: [...] }` - `meta.order` is included only when `metaShowOrdering: true` is set in list options. - `single` → `{ success: true, record: { ... } }` - `create` → `201 { success: true, id }` - `update` → `{ success: true }` - `patch` → `{ success: true, id }` - `destroy` → `{ success: true, id }` Not found: `404 { success: false, error: "Not Found" }`. Bad request (invalid filter/order column or type): `400 { success: false, error: "Bad request" }`. --- ## 5. Filtering, pagination, ordering Every ordinary query parameter becomes a simple equality in `where` (unless already set by earlier middleware). For string attributes, equality is case-insensitive by default (translated to `ILIKE` on Postgres, `LIKE` elsewhere without wildcards). Reserved keys are NOT turned into filters: - `api:page` – 1‑based page (default 1) - `api:page_size` – page size (default 100) - `api:order_by` – comma separated field list. Supports `-field` for DESC, `+field` for ASC, plain field uses global direction. - `api:order_dir` – fallback direction (`ASC` | `DESC`) applied to fields without an explicit `+`/`-` (default `ASC`). Pagination sets `limit` & `offset`. Ordering translates to a Sequelize `order` array like `[["score","DESC"],["name","ASC"]]`. Response structure: ```jsonc { "success": true, "meta": { "page": 2, "page_size": 25, "total_pages": 9, "count": 215 }, "data": [ { "id": 26, ... } ] } ``` Example (filter + pagination + ordering): `GET /items?type=fruit&api:page=2&api:page_size=25&api:order_by=-score,name` => ```js model.findAndCountAll({ where: { type: 'fruit' }, limit: 25, offset: 25, order: [ ['score', 'DESC'], ['name', 'ASC'], ], }); ``` If you don't supply `api:order_by`, results default to ascending by `id` (ensuring stable pagination): `ORDER BY id ASC`. The applied ordering is echoed back in `meta.order` as an array of `[field, direction]` pairs. Ordering examples: | Query | Resulting order | | -------------------------------------- | ------------------------ | | `api:order_by=name` | name ASC | | `api:order_by=name&api:order_dir=DESC` | name DESC | | `api:order_by=-score,name` | score DESC then name ASC | | `api:order_by=-score,+name` | score DESC then name ASC | ### Filtering on included models (dotted paths) When you pass Sequelize `include` options to `list()` via the third argument (`modelOptions`) or in a pre hook, you can filter on attributes of included associations using dotted paths. apialize translates these to the `$alias.attribute$` form supported by Sequelize. Example: ```js // Mount list on Album and include Artist under alias 'artist' app.use( '/albums', list(Album, {}, { include: [{ model: Artist, as: 'artist' }] }) ); // GET /albums?artist.name=prince // Default equality on string fields is case-insensitive, so this matches 'Prince'. // You can also use operators: // GET /albums?artist.name:ieq=PRINCE // case-insensitive equality // GET /albums?artist.name:contains=inc // substring match ``` If a dotted path doesn’t match an included alias/attribute, the request returns `400 Bad request`. ### Relation ID mapping for filtering and ordering The `relation_id_mapping` option allows filters and ordering on related model 'id' fields to be mapped to custom fields (e.g., `external_id`). This is particularly useful when your related models use custom public identifiers instead of internal database IDs. Configuration: ```js // Configure list with relation_id_mapping app.use( '/songs', list( Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }, { include: [ { model: Artist, as: 'artist' }, { model: Album, as: 'album' }, ], } ) ); // Now artist.id filters will use artist.external_id instead of artist.id // GET /songs?artist.id=artist-beethoven // Uses artist.external_id // GET /songs?album.id=album-symphony-5 // Uses album.external_id // GET /songs?api:order_by=artist.id // Orders by artist.external_id ``` The mapping applies to: - **Equality filters**: `?artist.id=value` → `artist.external_id = value` - **Operator filters**: `?artist.id:in=val1,val2` → `artist.external_id IN (val1, val2)` - **Ordering**: `?api:order_by=artist.id` → `ORDER BY artist.external_id` - **Foreign key flattening**: Foreign key values in response data are replaced with mapped ID values #### Foreign key flattening in responses When `relation_id_mapping` is configured, apialize automatically replaces foreign key values in response data with their corresponding mapped ID values. This provides consistency when using external IDs throughout your API: ```js // Configure with relation_id_mapping app.use( '/albums', list(Album, { relation_id_mapping: [{ model: Artist, id_field: 'external_id' }], }) ); // Example response data transformation: // Database: { id: 1, title: 'Symphony No. 5', artist_id: 123 } // Response: { id: 1, title: 'Symphony No. 5', artist_id: 'artist-beethoven' } ``` **Automatic foreign key detection:** Foreign keys are automatically detected using common naming patterns and replaced with external IDs: - `{model_name}_id` → Uses the model's `id_field` value - `{model_name}Id` → Camel case variant - `{model_name}_key` → Alternative suffix pattern - `{model_name}Key` → Camel case key variant ```js // Multiple foreign key mapping example app.use( '/songs', list(Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); // Response transformation: // Database: { id: 1, title: 'Track 1', artist_id: 123, album_id: 456 } // Response: { id: 1, title: 'Track 1', artist_id: 'artist-beethoven', album_id: 'album-symphony-5' } ``` **How it works:** 1. **Detection**: Identifies foreign key fields by matching patterns against configured models 2. **Bulk lookup**: Efficiently fetches all needed external IDs in batch queries 3. **Replacement**: Substitutes internal IDs with external IDs in the response data 4. **Error handling**: Gracefully handles missing mappings by keeping original values Only affects `.id` field references and foreign key mappings; other fields like `artist.name` work normally. Can be combined with regular `id_mapping` for the root model: ```js app.use( '/songs', list(Song, { id_mapping: 'external_id', // Root model uses external_id for id relation_id_mapping: [ // Related models also use external_id for id { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); ``` #### Complete example: Music streaming API with external IDs ```js // Models with both internal IDs and external IDs const Artist = sequelize.define('Artist', { id: { type: DataTypes.INTEGER, primaryKey: true }, external_id: { type: DataTypes.STRING, unique: true }, // e.g. 'artist-beethoven' name: DataTypes.STRING, }); const Album = sequelize.define('Album', { id: { type: DataTypes.INTEGER, primaryKey: true }, external_id: { type: DataTypes.STRING, unique: true }, // e.g. 'album-symphony-5' title: DataTypes.STRING, artist_id: DataTypes.INTEGER, // Foreign key to artist }); const Song = sequelize.define('Song', { id: { type: DataTypes.INTEGER, primaryKey: true }, external_id: { type: DataTypes.STRING, unique: true }, // e.g. 'song-movement-1' title: DataTypes.STRING, album_id: DataTypes.INTEGER, // Foreign key to album artist_id: DataTypes.INTEGER, // Foreign key to artist }); // Configure API with relation_id_mapping app.use( '/songs', list(Song, { id_mapping: 'external_id', relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); // API behavior examples: // GET /songs?artist.id=artist-beethoven // → Filters by artist.external_id = 'artist-beethoven' // GET /songs // → Response includes foreign key flattening: // [ // { // "id": "song-movement-1", // Root model uses external_id // "title": "Symphony No. 5 - Movement 1", // "artist_id": "artist-beethoven", // Flattened from internal 123 → external_id // "album_id": "album-symphony-5" // Flattened from internal 456 → external_id // } // ] ``` #### Multi-level filtering and ordering (list) You can filter and order by attributes multiple levels deep as long as the nested includes are present. ```js // Album → Artist → Label app.use( '/albums', list( Album, { metaShowOrdering: true }, { include: [ { model: Artist, as: 'artist', include: [{ model: Label, as: 'label' }], }, ], } ) ); // Filter: label name (case-insensitive equality by default) // GET /albums?artist.label.name=warner // Order: first by label name, then by artist name // GET /albums?api:order_by=artist.label.name,artist.name ``` Complex operators via middleware: ```js const { Op, literal } = require('sequelize'); function onlyOdd(req, _res, next) { req.apialize.options.where[Op.and] = literal('value % 2 = 1'); next(); } app.use('/numbers', list(NumberModel, onlyOdd)); ``` Add your own sorting / advanced operator grammar (e.g. parse `api:sort=-created_at,name`). ### List filtering operators (colon syntax) Beyond raw equality (`?field=value`), the list endpoint supports operator-style filters using the `field:operator=value` syntax. Multiple filters are implicitly ANDed. Supported operators: - Equality/inequality: `eq`, `=`, `neq`, `!=` - Case-insensitive equality: `ieq` - Comparisons: `gt`, `gte`, `lt`, `lte` - Sets: `in` (comma-separated), `not_in` (comma-separated) - Strings: `contains`, `icontains`, `starts_with`, `ends_with`, `not_contains`, `not_icontains`, `not_starts_with`, `not_ends_with` - Booleans: raw equality works (`?active=true`); for completeness, `is_true` and `is_false` are also accepted (e.g., `?active:is_true=true`) Examples: - `GET /items?name:icontains=display` → case-insensitive substring match - `GET /items?score:gte=2` → numeric comparison - `GET /items?category:in=A,B` → set membership (comma-separated) - `GET /items?name:not_icontains=auto&api:order_by=id` → excludes case-insensitive matches, ordered by id - `GET /items?name:starts_with=dis` → prefix match - `GET /items?name:ends_with=lay` → suffix match - `GET /items?category:not_in=tools,vehicles` → not in set - `GET /items?name:ieq=alpha` → case-insensitive equality (matches `Alpha` and `alpha`) --- ## 6. Middleware and `req.apialize` You can attach middleware at three levels: 1. Global (via `crud` `middleware` option) 2. Per operation (via `crud` `routes.<op>` arrays) 3. Inline for a single helper (`list(Model, auth, audit)`) All middleware run after the library automatically initializes `req.apialize` and merges query/body data. `req.apialize` structure: ```js req.apialize = { options: { where: { /* merged filters */ }, limit, offset, order, }, values: { /* merged body values for create/updates */ }, }; ``` Ownership / authorization middleware can safely merge additional filters and values: ```js function ownership(req, _res, next) { const userId = req.user.id; req.apialize.options.where.user_id = userId; // restrict if (['POST', 'PUT', 'PATCH'].includes(req.method)) { req.apialize.values.user_id = userId; // enforce } next(); } ``` Update semantics: - `PUT` (update) performs a full replace: for any attribute not provided, the value is set to the model's `defaultValue` if defined, otherwise `null` (identifier is preserved). - `PATCH` updates only provided, valid attributes. If body is empty, it verifies existence and returns success. --- ## 7. Input Validation apialize supports automatic input validation using your Sequelize model's validation rules. Validation is **enabled by default** for operations that accept request body data and runs before all other middleware, providing consistent error responses. ### Validation is Enabled by Default Validation is automatically enabled for `create`, `update`, and `patch` operations. No configuration is needed: ```js const { create, update, patch } = require('apialize'); // Validation is enabled by default - no configuration needed app.use('/users', create(User)); app.use('/users', update(User)); app.use('/users', patch(User)); // Or use crud for all operations app.use('/users', crud(User)); ``` ### Disabling Validation If you need to disable validation for specific operations, set `validate: false`: ```js // Disable validation for create operations app.use('/users', create(User, { validate: false })); // Disable validation for specific operations via crud app.use( '/users', crud(User, { routes: { create: { validate: false }, update: { validate: false }, }, }) ); ``` ### Model Validation Rules Define validation rules in your Sequelize model as usual: ```js const User = sequelize.define('User', { name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: { msg: 'Name cannot be empty' }, len: { args: [2, 50], msg: 'Name must be 2-50 characters' }, }, }, email: { type: DataTypes.STRING, allowNull: false, validate: { isEmail: { msg: 'Must be a valid email address' }, }, }, age: { type: DataTypes.INTEGER, validate: { min: { args: [0], msg: 'Age must be positive' }, max: { args: [120], msg: 'Age must be realistic' }, }, }, }); ``` ### Validation Behavior - **Create**: Validates the entire request body against all model rules - **Update**: Validates the entire request body (full replacement) - **Patch**: Validates only the fields being updated (partial validation) - **Bulk Create**: Validates each item in the array individually ### Validation Error Response When validation fails, a `400` response is returned: ```js { "success": false, "error": "Validation failed", "details": [ { "field": "email", "message": "Must be a valid email address", "value": "invalid-email" }, { "field": "age", "message": "Age must be positive", "value": -5 } ] } ``` ### Validation Timing The validation middleware runs **before** all other middleware in this order: 1. `apializeContext` (parses query/body data) 2. **Validation middleware** (when `validate: true`) 3. Your custom middleware 4. Main operation handler This ensures invalid data is rejected early, before any business logic or database operations. --- ## 8. Pre/Post Hooks and Query Control All operations (`list`, `single`, `create`, `update`, `patch`, `destroy`) support optional pre/post processing hooks that provide powerful control over database queries and response formatting. ### Hook Configuration Hooks can be configured as either: - **Single function**: `pre: async (context) => { ... }` - **Array of functions**: `pre: [fn1, fn2, fn3]` (executed in order) ```js // Single function (simple) app.use( '/items', list(Item, { pre: async (ctx) => { /* single pre hook */ }, post: async (ctx) => { /* single post hook */ }, }) ); // Array of functions (advanced) app.use( '/items', list(Item, { pre: [ async (ctx) => { /* first pre hook */ }, async (ctx) => { /* second pre hook */ }, async (ctx) => { /* third pre hook */ }, ], post: [ async (ctx) => { /* first post hook */ }, async (ctx) => { /* second post hook */ }, ], }) ); // Mixed configuration app.use( '/items', update(Item, { pre: async (ctx) => { /* single pre hook */ }, post: [ async (ctx) => { /* first post hook */ }, async (ctx) => { /* second post hook */ }, ], }) ); ``` ### Hook Execution Flow 1. **Model scopes** are applied first (from `modelOptions.scopes`) - Scopes filter data at the model level before any hooks run - Multiple scopes are combined with AND logic 2. **Pre hooks** run before the database query - Execute in array order if multiple hooks provided - Can modify query options (`where`, `include`, `attributes`, etc.) on top of applied scopes - Return value from last pre hook is stored in `context.preResult` - All hooks receive the same context object 3. **Database query** executes with modified options (scopes + pre hook modifications) 4. **Post hooks** run after the query and response construction - Execute in array order if multiple hooks provided - Can modify the response payload before it's sent to client - Have access to `context.preResult` from pre hooks ### Transaction Management - Automatic transaction lifecycle for operations that support it - Transaction starts before pre hooks and commits after post hooks - Automatic rollback on any error during hooks or query execution - Transaction available as `context.transaction` in all hooks ### Context Object The context object provides access to request data, model, options, and more: ```js { req, res, // Express request/response objects request, // Alias to req for convenience model, // Sequelize-like model options, // Operation options passed in modelOptions, // Model options passed in apialize, // Direct reference to req.apialize (for convenience) idMapping, // Effective id mapping transaction, // Sequelize transaction (if available) preResult, // Result from last pre hook (undefined initially) payload, // Response payload (available in post hooks) // Operation-specific properties page, pageSize, // (list) pagination info appliedFilters, // (list) filters derived from query existing, // (update/patch) loaded record before save nextValues, // (update/patch) values to be saved // Helper functions (available directly on context for convenience) applyWhere, // Apply where conditions (overwrites existing keys) applyScope, // Apply Sequelize scopes (when model available) applyMultipleWhere, // Apply multiple where conditions at once applyWhereIfNotExists, // Apply conditions only if they don't exist applyScopes, // Apply multiple scopes in sequence (when model available) removeWhere, // Remove specific where conditions replaceWhere, // Replace entire where clause } ``` ### Context Helper Functions The context object in pre/post hooks includes built-in helper functions for convenience. These same functions are also available on `req.apialize` for use in middleware: #### `context.applyWhere(additionalWhere)` | `req.apialize.applyWhere(additionalWhere)` Apply additional where conditions to the existing where clause. New conditions overwrite existing conditions for the same keys: ```js // In pre/post hooks app.use( '/items', list(Item, { pre: async (context) => { // Simple where conditions - use context directly for convenience context.applyWhere({ tenant_id: context.req.user.tenantId, status: 'active', }); // With Sequelize operators const { Op } = require('sequelize'); context.applyWhere({ price: { [Op.gt]: 0 }, created_at: { [Op.gte]: new Date('2024-01-01') }, }); // Later calls overwrite earlier ones for the same keys context.applyWhere({ status: 'published' }); // status becomes 'published' return { tenantFiltered: true }; }, }) ); // In middleware (req.apialize helpers are automatically available) const tenantMiddleware = (req, res, next) => { req.apialize.applyWhere({ tenant_id: req.user.tenantId }); next(); }; app.use( '/items', list(Item, { middleware: [tenantMiddleware], }) ); ``` **Behavior:** - Last condition wins for the same key - Simple and predictable overwrite behavior - Available in middleware, pre hooks, and post hooks - For complex AND logic, build the condition explicitly: ```js // Instead of multiple calls, build complex conditions explicitly req.apialize.applyWhere({ [Op.and]: [ { price: { [Op.gte]: 100 } }, { price: { [Op.lte]: 500 } }, { category: 'electronics' }, ], }); ``` #### `context.applyScope(scope, ...args)` | `req.apialize.applyScope(scope, ...args)` Apply Sequelize scopes to modify query options (only available when model is present): ```js // Define scopes in your model Item.addScope('byTenant', (tenantId) => ({ where: { tenant_id: tenantId }, })); Item.addScope('activeOnly', { where: { status: 'active' }, }); // Use in pre hooks app.use( '/items', list(Item, { pre: async (context) => { // Apply parameterized scope - use context directly context.applyScope('byTenant', context.req.user.tenantId); // Apply simple scope context.applyScope('activeOnly'); return { scopesApplied: true }; }, }) ); ``` #### `context.applyWhereIfNotExists(conditionalWhere)` Apply where conditions only if they don't already exist: ```js app.use( '/items', list(Item, { pre: async (context) => { // Always apply tenant filtering context.applyWhere({ tenant_id: context.req.user.tenantId }); // Only apply default status if user hasn't specified one context.applyWhereIfNotExists({ status: 'active' }); return { conditionalFiltersApplied: true }; }, }) ); ``` #### `context.applyMultipleWhere(whereConditions)` Apply multiple where conditions at once: ```js app.use( '/items', list(Item, { pre: async (context) => { const conditions = [ { tenant_id: context.req.user.tenantId }, { status: 'active' }, { price: { [Op.gt]: 0 } }, ]; context.applyMultipleWhere(conditions); return { multipleFiltersApplied: true }; }, }) ); ``` #### `context.applyScopes(scopes)` Apply multiple scopes in sequence: ```js app.use( '/items', list(Item, { pre: async (context) => { const scopes = [ 'activeOnly', { name: 'byTenant', args: [context.req.user.tenantId] }, 'withCategory', ]; context.applyScopes(scopes); return { multipleScopesApplied: true }; }, }) ); ``` #### `context.removeWhere(keysToRemove)` & `context.replaceWhere(newWhere)` Remove or replace where conditions: ```js app.use( '/items', list(Item, { pre: async (context) => { // Add base conditions context.applyWhere({ tenant_id: context.req.user.tenantId, status: 'active', }); // Remove status filter for admin users if (context.req.user.role === 'admin') { context.removeWhere('status'); } // Or completely replace where clause if (context.req.user.role === 'superadmin') { context.replaceWhere({}); // See everything } return { adminAccess: true }; }, }) ); ``` #### Multi-tenant Example with Helper Functions ```js app.use( '/items', crud(Item, { routes: { list: { pre: async (context) => { const user = context.req.user; // Base tenant isolation (always applied) context.applyScope('byTenant', user.tenantId); // Role-based filtering switch (user.role) { case 'admin': // Admin sees all items in tenant break; case 'manager': // Manager sees department items context.applyScope('byDepartment', user.departmentId); break; case 'user': // User sees only their own items context.applyWhere({ created_by: user.id }); break; } // Apply common filters context.applyScope('activeOnly'); // Handle special query parameters if (context.req.query.archived === 'true') { context.removeWhere('status'); context.applyWhere({ archived_at: { [Op.not]: null } }); } return { tenantId: user.tenantId, role: user.role, filtersApplied: true, }; }, }, create: { pre: async (context) => { const user = context.req.user; // Auto-inject tenant and user info if (!context.req.apialize.values) { context.req.apialize.values = {}; } Object.assign(context.req.apialize.values, { tenant_id: user.tenantId, created_by: user.id, department_id: user.departmentId, status: 'active', }); return { autoFieldsInjected: true }; }, }, }, }) ); ``` ### Query Control in Pre Hooks Pre hooks can dynamically modify database queries either by using the built-in helper functions (recommended) or by directly manipulating `ctx.apialize.options`: #### Controlling WHERE Clauses (Recommended: Helper Functions) ```js app.use( '/items', list(Item, { pre: [ async (ctx) => { // Using helper functions (recommended) ctx.applyWhere({ tenant_id: ctx.req.user.tenant_id }); return { step: 1 }; }, async (ctx) => { // Apply multiple conditions with operators const { Op } = require('sequelize'); ctx.applyWhere({ status: 'active', price: { [Op.gt]: 0 }, }); return { step: 2 }; }, ], }) ); ``` #### Controlling WHERE Clauses (Manual Approach) ```js app.use( '/items', list(Item, { pre: [ async (ctx) => { // Manual manipulation (still supported) ctx.apialize.options.where.tenant_id = ctx.req.user.tenant_id; return { step: 1 }; }, async (ctx) => { // Add additional status filter with Sequelize operators const { Op } = require('sequelize'); ctx.apialize.options.where.status = 'active'; ctx.apialize.options.where.price = { [Op.gt]: 0 }; return { step: 2 }; }, ], }) ); ``` #### Controlling INCLUDE Clauses (Relations) ```js app.use( '/items', single(Item, { pre: [ async (ctx) => { // Dynamically include related models based on user permissions ctx.apialize.options.include = [{ model: Category, as: 'category' }]; return { step: 1 }; }, async (ctx) => { // Modify included model attributes based on user role if (ctx.req.user.role !== 'admin') { ctx.apialize.options.include[0].attributes = ['name', 'description']; } return { step: 2 }; }, ], }) ); ``` #### Controlling ATTRIBUTES (Field Selection) ```js app.use( '/items', single(Item, { pre: [ async (ctx) => { // Start with basic fields ctx.apialize.options.attributes = ['id', 'name', 'external_id']; return { step: 1 }; }, async (ctx) => { // Add additional fields based on user permissions if (ctx.req.user.role === 'admin') { ctx.apialize.options.attributes.push('internal_notes', 'cost'); } if (ctx.req.user.role === 'manager') { ctx.apialize.options.attributes.push('status'); } return { step: 2 }; }, ], }) ); ``` ### Response Control in Post Hooks Post hooks can modify the response payload before it's sent to the client: ```js app.use( '/items', list(Item, { pre: async (ctx) => { return { startTime: Date.now() }; }, post: [ async (ctx) => { // Add metadata to response ctx.payload.meta.generated_by = 'apialize'; ctx.payload.meta.query_time_ms = Date.now() - ctx.preResult.startTime; }, async (ctx) => { // Add user-specific data ctx.payload.meta.user_id = ctx.req.user.id; ctx.payload.meta.permissions = ctx.req.user.permissions; }, ], }) ); ``` ### Real-World Examples #### Multi-tenant Application ```js app.use( '/items', crud(Item, { routes: { list: { pre: async (ctx) => { // Enforce tenant isolation ctx.apialize.options.where.tenant_id = ctx.req.user.tenant_id; }, }, create: { pre: async (ctx) => { // Auto-inject tenant ID ctx.apialize.values.tenant_id = ctx.req.user.tenant_id; ctx.apialize.values.created_by = ctx.req.user.id; }, }, }, }) ); ``` #### Role-based Field Access ```js app.use( '/users', single(User, { pre: [ async (ctx) => { // Base fields for all users const baseFields = ['id', 'name', 'email']; ctx.apialize.options.attributes = [...baseFields]; return { role: ctx.req.user.role }; }, async (ctx) => { // Add fields based on role if (ctx.preResult.role === 'admin') { ctx.apialize.options.attributes.push( 'internal_id', 'created_at', 'last_login' ); } else if (ctx.preResult.role === 'manager') { ctx.apialize.options.attributes.push('department', 'hire_date'); } }, ], post: async (ctx) => { // Add computed fields ctx.payload.record.display_name = ctx.payload.record.name.toUpperCase(); ctx.payload.record.can_edit = ctx.req.user.id === ctx.payload.record.id || ctx.req.user.role === 'admin'; }, }) ); ``` #### Audit and Logging ```js app.use( '/sensitive-data', destroy(SensitiveData, { pre: async (ctx) => { // Log access attempt await AuditLog.create({ user_id: ctx.req.user.id, action: 'DELETE_ATTEMPT', resource_id: ctx.req.params.id, timestamp: new Date(), }); return { audit_id: result.id }; }, post: async (ctx) => { // Log successful deletion await AuditLog.create({ user_id: ctx.req.user.id, action: 'DELETE_SUCCESS', resource_id: ctx.req.params.id, related_audit_id: ctx.preResult.audit_id, timestamp: new Date(), }); }, }) ); ``` #### Dynamic Include with Caching ```js app.use( '/products', list(Product, { pre: [ async (ctx) => { // Check if client wants expanded data const expand = ctx.req.query.expand; if (expand) { ctx.apialize.options.include = []; if (expand.includes('category')) { ctx.apialize.options.include.push({ model: Category, as: 'category', attributes: ['name', 'slug'], }); } if (expand.includes('reviews') && ctx.req.user.role !== 'guest') { ctx.apialize.options.include.push({ model: Review, as: 'reviews', limit: 5, order: [['created_at', 'DESC']], }); } } return { expanded: expand }; }, ], post: async (ctx) => { // Add cache headers for expanded queries if (ctx.preResult.expanded) { ctx.res.set('Cache-Control', 'public, max-age=300'); // 5 minutes } // Add expansion info to response ctx.payload.meta.expanded = ctx.preResult.expanded || []; }, }) ); ``` ### Error Handling Hooks automatically participate in transaction rollback: ```js app.use( '/items', update(Item, { pre: async (ctx) => { // Validation that can fail if (!ctx.req.user.can_edit) { throw new Error('Insufficient permissions'); } // Transaction will be rolled back automatically }, post: async (ctx) => { // Any error here also triggers rollback await notifyWebhook(ctx.payload); }, }) ); // Destroy with hooks app.use( '/items', destroy(Item, { pre: async (ctx) => { // e.g., check permissions or enqueue audit }, post: async (ctx) => { ctx.payload.deleted = true; }, }) ); ``` Note: The final HTTP response body is taken from `context.payload` so your `post()` hook can modify it. --- ## Search (body-driven filtering via POST) `search(model, options?, modelOptions?)` exposes a POST route that returns the same response shape as `list`, but accepts complex boolean filters in the request body instead of using query parameters. Mount it under a separate subpath to avoid colliding with `create` (which also uses POST): ```js app.use('/items/search', search(Item)); // POST /items/search // With scopes applied automatically before search filters app.use( '/items/search', search( Item, {}, { scopes: ['active', { name: 'byTenant', args: [req.user.tenantId] }], } ) ); // Only search within active items in user's tenant ``` Request body shape: ```jsonc { "filters": { // implicit AND of keys when no boolean wrapper provided "and": [ { "status": "active" }, { "or": [{ "category": "electronics" }, { "name_contains": "display" }], }, { "price": { "gte": 100, "lt": 500 } }, ], }, "ordering": [{ "orderby": "price", "direction": "asc" }], "paging": { "page": 1, "size": 50 }, } ``` ### Filtering on included models (dotted paths) When you pass Sequelize `include` options to `search()` via the third argument (`modelOptions`) or in a pre hook, you can filter on attributes of included associations using dotted paths. apialize will translate these to the `$alias.attribute$` form supported by Sequelize. Example: ```js // Mount search on Album and include Artist under alias 'artist' app.use( '/albums', search(Album, {}, { include: [{ model: Artist, as: 'artist' }] }) ); // POST /albums/search with filters on included model // { "filters": { "artist.name": { "icontains": "beethoven" } } } ``` Supported operators and boolean grouping work the same as for top‑level attributes. If a dotted path doesn’t match an included alias/attribute, the request returns `400 Bad request`. ### Relation ID mapping for search filtering and ordering The `relation_id_mapping` option works with `search()` endpoints in the same way as `list()`, allowing filters and ordering on related model 'id' fields to be mapped to custom fields (e.g., `external_id`). Configuration: ```js // Configure search with relation_id_mapping app.use( '/songs/search', search( Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }, { include: [ { model: Artist, as: 'artist' }, { model: Album, as: 'album' }, ], } ) ); ``` Usage in search requests: ```js // POST /songs/search - Filter by artist.id using external_id { "filters": { "artist.id": "artist-beethoven" // Uses artist.external_id } } // POST /songs/search - Complex filters with operators { "filters": { "album.id": { "in": ["album-symphony-5", "album-requiem"] // Uses album.external_id } } } // POST /songs/search - Ordering by relation id field { "ordering": [ { "orderby": "artist.id", "direction": "DESC" } // Orders by artist.external_id ] } ``` The mapping applies to the same cases as in `list()`: - **Equality filters**: `"artist.id": "value"` → `artist.external_id = value` - **Operator filters**: `"artist.id": { "in": [...] }` → `artist.external_id IN (...)` - **Ordering**: `"orderby": "artist.id"` → `ORDER BY artist.external_id` - **Foreign key flattening**: Foreign key values in response data are replaced with mapped ID values ```js // Search response with foreign key flattening // POST /songs/search returns: { "success": true, "data": [ { "id": 1, "title": "Symphony No. 5 - Movement 1", "artist_id": "artist-beethoven", // Mapped from internal ID "album_id": "album-sym5" // Mapped from internal ID } ] } ``` Foreign key flattening works the same way as in `list()` operations - see the detailed documentation in the filtering section above. #### Multi-level filtering and ordering (search) ```js // Album → Artist → Label app.use( '/albums', search( Album, { metaShowOrdering: true }, { include: [ { model: Artist, as: 'artist', include: [{ model: Label, as: 'label' }], }, ], } ) ); // Filter by label name (default case-insensitive equality) // POST /albums/search // { "filters": { "artist.label.name": "warner" } } // Order by label desc, then artist asc, then title asc // POST /albums/search // { // "ordering": [ // { "orderby": "artist.label.name", "direction": "DESC" }, // { "orderby": "artist.name", "direction": "ASC" }, // { "orderby": "title", "direction": "ASC" } // ] // } // meta.order echoes readable paths, e.g.: // [["artist.label.name","DESC"],["artist.name","ASC"],["title","ASC"]] ``` ### Filtering operators (what you can express in filters) You can express complex filters using an operator-object form per field. Top-level keys are implicitly ANDed; provide explicit boolean arrays `and: [...]` and `or: [...]` for grouping. Operator-object form (supported keys): - Equality: `eq`, `=` - Inequality: `neq`, `!=` - Comparisons: `gt`, `>`, `gte`, `>=`, `lt`, `<`, `lte`, `<=` - Sets: `in`, `not_in` - Strings: `contains`, `icontains`, `starts_with`, `ends_with`, `not_contains`, `not_icontains`, `not_starts_with`, `not_ends_with` - Booleans: `is_true`, `is_false` (raw equality `"active": true` is also supported) Examples (operator-object form): ```jsonc { "filters": { "price": { "gte": 100, "lt": 500 }, "status": { "neq": "archived" }, "category": { "in": ["A", "B"] }, "name": { "icontains": "display" }, "title": { "not_contains": "draft" }, "sku": { "not_starts_with": "TMP-" }, "ext": { "not_ends_with": ".bak" }, "active": { "is_true": true }, }, } ``` Boolean grouping: ```jsonc { "filters": { "and": [ { "status": "active" }, { "or": [{ "p