json-rest-api 1.0.29 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +744 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +733 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +103 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
- package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +37 -0
- package/index.js +3 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +70 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -177
- package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +120 -56
- package/plugins/core/lib/anyapi/stored-definition.js +109 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +13 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +128 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1077
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -395
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +36 -68
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +37 -81
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/anyapi.d.ts +30 -0
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +65 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -148
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -155
- package/tests/fixtures/api-configs.js +0 -2566
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-boundaries.test.js +0 -154
- package/tests/pagination-contract.test.js +0 -122
- package/tests/pagination-cursor-multifield.test.js +0 -641
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/temporal-boundaries.test.js +0 -208
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,1107 +0,0 @@
|
|
|
1
|
-
# Creating Custom Storage Plugins for JSON REST API
|
|
2
|
-
|
|
3
|
-
This guide explains how to create your own storage plugin for the JSON REST API library. Whether you want to use in-memory storage, connect to a remote API, or integrate with a NoSQL database, this guide will show you how to implement the required interface.
|
|
4
|
-
|
|
5
|
-
## Table of Contents
|
|
6
|
-
|
|
7
|
-
1. [Introduction](#introduction)
|
|
8
|
-
2. [The Storage Contract](#the-storage-contract)
|
|
9
|
-
3. [JSON:API Response Format](#jsonapi-response-format)
|
|
10
|
-
4. [Complete Example: In-Memory Storage Plugin](#complete-example-in-memory-storage-plugin)
|
|
11
|
-
5. [Complete Example: Remote API Storage Plugin](#complete-example-remote-api-storage-plugin)
|
|
12
|
-
6. [Advanced Topics](#advanced-topics)
|
|
13
|
-
7. [Testing Your Storage Plugin](#testing-your-storage-plugin)
|
|
14
|
-
8. [Common Pitfalls & Best Practices](#common-pitfalls--best-practices)
|
|
15
|
-
|
|
16
|
-
## Introduction
|
|
17
|
-
|
|
18
|
-
Storage plugins are the bridge between the JSON REST API library and your data source. The REST API plugin handles all the HTTP routing, validation, permissions, and JSON:API formatting, while your storage plugin is responsible for actually storing and retrieving data.
|
|
19
|
-
|
|
20
|
-
### Why Create a Custom Storage Plugin?
|
|
21
|
-
|
|
22
|
-
- **In-Memory Storage**: For testing, prototyping, or caching
|
|
23
|
-
- **Remote APIs**: Proxy requests to another REST API or microservice
|
|
24
|
-
- **NoSQL Databases**: Connect to MongoDB, DynamoDB, or other document stores
|
|
25
|
-
- **Custom Logic**: Implement complex business rules or data transformations
|
|
26
|
-
- **Hybrid Storage**: Combine multiple data sources
|
|
27
|
-
|
|
28
|
-
### How It Works
|
|
29
|
-
|
|
30
|
-
The REST API plugin defines a contract of 8 helper methods that your storage plugin must implement. When a request comes in:
|
|
31
|
-
|
|
32
|
-
1. The REST API plugin handles the HTTP request
|
|
33
|
-
2. It validates the input and checks permissions
|
|
34
|
-
3. It calls your storage helper method with a context object
|
|
35
|
-
4. Your helper returns data in JSON:API format
|
|
36
|
-
5. The REST API plugin enriches the response and sends it back
|
|
37
|
-
|
|
38
|
-
## The Storage Contract
|
|
39
|
-
|
|
40
|
-
Your storage plugin must implement these 8 helper methods by assigning them to the `helpers` object:
|
|
41
|
-
|
|
42
|
-
### 1. `dataExists`
|
|
43
|
-
Check if a resource exists.
|
|
44
|
-
|
|
45
|
-
```javascript
|
|
46
|
-
helpers.dataExists = async ({ scopeName, context }) => {
|
|
47
|
-
// Parameters:
|
|
48
|
-
// - scopeName: string - The resource type (e.g., 'articles')
|
|
49
|
-
// - context.id: string|number - The resource ID to check
|
|
50
|
-
// - context.schemaInfo.tableName: string - Storage identifier
|
|
51
|
-
// - context.schemaInfo.idProperty: string - Primary key field name
|
|
52
|
-
// - context.db: any - Database connection (if using transactions)
|
|
53
|
-
|
|
54
|
-
// Returns: boolean - true if exists, false otherwise
|
|
55
|
-
};
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
### 2. `dataGet`
|
|
59
|
-
Retrieve a single resource with full JSON:API features.
|
|
60
|
-
|
|
61
|
-
```javascript
|
|
62
|
-
helpers.dataGet = async ({ scopeName, context, runHooks }) => {
|
|
63
|
-
// Parameters:
|
|
64
|
-
// - scopeName: string - The resource type
|
|
65
|
-
// - context.id: string|number - The resource ID
|
|
66
|
-
// - context.queryParams.include: string[] - Related resources to include
|
|
67
|
-
// - context.queryParams.fields: object - Sparse fieldsets
|
|
68
|
-
// - context.schemaInfo: object - Schema information
|
|
69
|
-
|
|
70
|
-
// Returns: JSON:API document with single resource
|
|
71
|
-
// {
|
|
72
|
-
// data: { type, id, attributes, relationships },
|
|
73
|
-
// included: [...] // if includes requested
|
|
74
|
-
// }
|
|
75
|
-
};
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### 3. `dataGetMinimal`
|
|
79
|
-
Retrieve minimal resource data (used for permission checks).
|
|
80
|
-
|
|
81
|
-
```javascript
|
|
82
|
-
helpers.dataGetMinimal = async ({ scopeName, context }) => {
|
|
83
|
-
// Parameters: Same as dataGet but ignores queryParams
|
|
84
|
-
|
|
85
|
-
// Returns: JSON:API resource object or null
|
|
86
|
-
// {
|
|
87
|
-
// type: 'articles',
|
|
88
|
-
// id: '123',
|
|
89
|
-
// attributes: { ... },
|
|
90
|
-
// relationships: { ... } // only belongsTo relationships
|
|
91
|
-
// }
|
|
92
|
-
};
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
### 4. `dataQuery`
|
|
96
|
-
Query multiple resources with filtering, sorting, and pagination.
|
|
97
|
-
|
|
98
|
-
```javascript
|
|
99
|
-
helpers.dataQuery = async ({ scopeName, context, runHooks }) => {
|
|
100
|
-
// Parameters:
|
|
101
|
-
// - context.queryParams.filters: object - Filter conditions
|
|
102
|
-
// - context.queryParams.sort: string[] - Sort fields (prefix - for DESC)
|
|
103
|
-
// - context.queryParams.page: object - Pagination (size, number/after/before)
|
|
104
|
-
// - context.queryParams.include: string[] - Related resources
|
|
105
|
-
// - context.queryParams.fields: object - Sparse fieldsets
|
|
106
|
-
|
|
107
|
-
// Returns: JSON:API document with resource array
|
|
108
|
-
// {
|
|
109
|
-
// data: [...],
|
|
110
|
-
// included: [...],
|
|
111
|
-
// meta: { ... }, // pagination info
|
|
112
|
-
// links: { ... } // pagination links
|
|
113
|
-
// }
|
|
114
|
-
};
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### 5. `dataPost`
|
|
118
|
-
Create a new resource.
|
|
119
|
-
|
|
120
|
-
```javascript
|
|
121
|
-
helpers.dataPost = async ({ scopeName, context }) => {
|
|
122
|
-
// Parameters:
|
|
123
|
-
// - context.inputRecord: JSON:API document with new resource
|
|
124
|
-
// - context.schemaInfo: object - Schema information
|
|
125
|
-
|
|
126
|
-
// Returns: string|number - The ID of created resource
|
|
127
|
-
};
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
### 6. `dataPut`
|
|
131
|
-
Replace an entire resource (or create with specific ID).
|
|
132
|
-
|
|
133
|
-
```javascript
|
|
134
|
-
helpers.dataPut = async ({ scopeName, context }) => {
|
|
135
|
-
// Parameters:
|
|
136
|
-
// - context.id: string|number - Resource ID
|
|
137
|
-
// - context.inputRecord: JSON:API document with full resource
|
|
138
|
-
// - context.isCreate: boolean - true if creating, false if updating
|
|
139
|
-
|
|
140
|
-
// Returns: void (throws error if not found when updating)
|
|
141
|
-
};
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
### 7. `dataPatch`
|
|
145
|
-
Partially update a resource.
|
|
146
|
-
|
|
147
|
-
```javascript
|
|
148
|
-
helpers.dataPatch = async ({ scopeName, context }) => {
|
|
149
|
-
// Parameters:
|
|
150
|
-
// - context.id: string|number - Resource ID
|
|
151
|
-
// - context.inputRecord: JSON:API document with partial updates
|
|
152
|
-
|
|
153
|
-
// Returns: void (throws error if not found)
|
|
154
|
-
};
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### 8. `dataDelete`
|
|
158
|
-
Delete a resource.
|
|
159
|
-
|
|
160
|
-
```javascript
|
|
161
|
-
helpers.dataDelete = async ({ scopeName, context }) => {
|
|
162
|
-
// Parameters:
|
|
163
|
-
// - context.id: string|number - Resource ID
|
|
164
|
-
|
|
165
|
-
// Returns: { success: true } (throws error if not found)
|
|
166
|
-
};
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
## JSON:API Response Format
|
|
170
|
-
|
|
171
|
-
Your storage plugin must return data in proper JSON:API format. Here are the key structures:
|
|
172
|
-
|
|
173
|
-
### Single Resource Format
|
|
174
|
-
|
|
175
|
-
```javascript
|
|
176
|
-
{
|
|
177
|
-
data: {
|
|
178
|
-
type: 'articles',
|
|
179
|
-
id: '123',
|
|
180
|
-
attributes: {
|
|
181
|
-
title: 'My Article',
|
|
182
|
-
content: 'Article content...',
|
|
183
|
-
publishedAt: '2024-01-15T10:00:00Z'
|
|
184
|
-
},
|
|
185
|
-
relationships: {
|
|
186
|
-
author: {
|
|
187
|
-
data: { type: 'users', id: '456' }
|
|
188
|
-
},
|
|
189
|
-
tags: {
|
|
190
|
-
data: [
|
|
191
|
-
{ type: 'tags', id: '1' },
|
|
192
|
-
{ type: 'tags', id: '2' }
|
|
193
|
-
]
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
### Collection Format
|
|
201
|
-
|
|
202
|
-
```javascript
|
|
203
|
-
{
|
|
204
|
-
data: [
|
|
205
|
-
{ type: 'articles', id: '1', attributes: {...}, relationships: {...} },
|
|
206
|
-
{ type: 'articles', id: '2', attributes: {...}, relationships: {...} }
|
|
207
|
-
],
|
|
208
|
-
meta: {
|
|
209
|
-
page: 1,
|
|
210
|
-
pageSize: 20,
|
|
211
|
-
pageCount: 5,
|
|
212
|
-
total: 100
|
|
213
|
-
},
|
|
214
|
-
links: {
|
|
215
|
-
first: '/articles?page[number]=1&page[size]=20',
|
|
216
|
-
last: '/articles?page[number]=5&page[size]=20',
|
|
217
|
-
next: '/articles?page[number]=2&page[size]=20'
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
### With Included Resources
|
|
223
|
-
|
|
224
|
-
```javascript
|
|
225
|
-
{
|
|
226
|
-
data: { type: 'articles', id: '123', ... },
|
|
227
|
-
included: [
|
|
228
|
-
{
|
|
229
|
-
type: 'users',
|
|
230
|
-
id: '456',
|
|
231
|
-
attributes: {
|
|
232
|
-
name: 'John Doe',
|
|
233
|
-
email: 'john@example.com'
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
type: 'tags',
|
|
238
|
-
id: '1',
|
|
239
|
-
attributes: {
|
|
240
|
-
name: 'Technology'
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
]
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
## Complete Example: In-Memory Storage Plugin
|
|
248
|
-
|
|
249
|
-
Here's a fully functional in-memory storage plugin:
|
|
250
|
-
|
|
251
|
-
```javascript
|
|
252
|
-
export const InMemoryStoragePlugin = {
|
|
253
|
-
name: 'in-memory-storage',
|
|
254
|
-
dependencies: ['rest-api'],
|
|
255
|
-
|
|
256
|
-
install({ helpers, scopes, log }) {
|
|
257
|
-
// In-memory data store
|
|
258
|
-
const dataStore = new Map();
|
|
259
|
-
|
|
260
|
-
// Helper to get collection for a scope
|
|
261
|
-
const getCollection = (scopeName) => {
|
|
262
|
-
if (!dataStore.has(scopeName)) {
|
|
263
|
-
dataStore.set(scopeName, new Map());
|
|
264
|
-
}
|
|
265
|
-
return dataStore.get(scopeName);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
// Helper to generate IDs
|
|
269
|
-
let nextId = 1;
|
|
270
|
-
const generateId = () => String(nextId++);
|
|
271
|
-
|
|
272
|
-
// 1. CHECK EXISTS
|
|
273
|
-
helpers.dataExists = async ({ scopeName, context }) => {
|
|
274
|
-
const collection = getCollection(scopeName);
|
|
275
|
-
return collection.has(String(context.id));
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
// 2. GET SINGLE RESOURCE
|
|
279
|
-
helpers.dataGet = async ({ scopeName, context }) => {
|
|
280
|
-
const collection = getCollection(scopeName);
|
|
281
|
-
const record = collection.get(String(context.id));
|
|
282
|
-
|
|
283
|
-
if (!record) {
|
|
284
|
-
const { RestApiResourceError } = await import('../../lib/rest-api-errors.js');
|
|
285
|
-
throw new RestApiResourceError('Resource not found', {
|
|
286
|
-
subtype: 'not_found',
|
|
287
|
-
resourceType: scopeName,
|
|
288
|
-
resourceId: context.id
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Build JSON:API response
|
|
293
|
-
const data = {
|
|
294
|
-
type: scopeName,
|
|
295
|
-
id: String(context.id),
|
|
296
|
-
attributes: { ...record.attributes },
|
|
297
|
-
relationships: {}
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
// Add relationships
|
|
301
|
-
const scope = scopes[scopeName];
|
|
302
|
-
const schemaRelationships = scope.vars.schemaInfo.schemaRelationships;
|
|
303
|
-
|
|
304
|
-
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
305
|
-
if (relDef.type === 'belongsTo' && record.attributes[relDef.foreignKey]) {
|
|
306
|
-
data.relationships[relName] = {
|
|
307
|
-
data: {
|
|
308
|
-
type: relDef.resource,
|
|
309
|
-
id: String(record.attributes[relDef.foreignKey])
|
|
310
|
-
}
|
|
311
|
-
};
|
|
312
|
-
} else if (relDef.type === 'hasMany' && record.relationships?.[relName]) {
|
|
313
|
-
data.relationships[relName] = {
|
|
314
|
-
data: record.relationships[relName].map(id => ({
|
|
315
|
-
type: relDef.resource,
|
|
316
|
-
id: String(id)
|
|
317
|
-
}))
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Handle includes
|
|
323
|
-
const included = [];
|
|
324
|
-
if (context.queryParams.include?.length > 0) {
|
|
325
|
-
for (const includePath of context.queryParams.include) {
|
|
326
|
-
const relName = includePath.split('.')[0];
|
|
327
|
-
const relationship = data.relationships[relName];
|
|
328
|
-
|
|
329
|
-
if (relationship?.data) {
|
|
330
|
-
const relData = Array.isArray(relationship.data)
|
|
331
|
-
? relationship.data
|
|
332
|
-
: [relationship.data];
|
|
333
|
-
|
|
334
|
-
for (const rel of relData) {
|
|
335
|
-
const relCollection = getCollection(rel.type);
|
|
336
|
-
const relRecord = relCollection.get(rel.id);
|
|
337
|
-
if (relRecord) {
|
|
338
|
-
included.push({
|
|
339
|
-
type: rel.type,
|
|
340
|
-
id: rel.id,
|
|
341
|
-
attributes: { ...relRecord.attributes }
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Apply sparse fieldsets
|
|
350
|
-
if (context.queryParams.fields?.[scopeName]) {
|
|
351
|
-
const fields = context.queryParams.fields[scopeName].split(',');
|
|
352
|
-
data.attributes = Object.fromEntries(
|
|
353
|
-
Object.entries(data.attributes).filter(([key]) => fields.includes(key))
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return {
|
|
358
|
-
data,
|
|
359
|
-
...(included.length > 0 && { included })
|
|
360
|
-
};
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
// 3. GET MINIMAL
|
|
364
|
-
helpers.dataGetMinimal = async ({ scopeName, context }) => {
|
|
365
|
-
const collection = getCollection(scopeName);
|
|
366
|
-
const record = collection.get(String(context.id));
|
|
367
|
-
|
|
368
|
-
if (!record) return null;
|
|
369
|
-
|
|
370
|
-
const data = {
|
|
371
|
-
type: scopeName,
|
|
372
|
-
id: String(context.id),
|
|
373
|
-
attributes: { ...record.attributes },
|
|
374
|
-
relationships: {}
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
// Only include belongsTo relationships for minimal
|
|
378
|
-
const scope = scopes[scopeName];
|
|
379
|
-
const schemaRelationships = scope.vars.schemaInfo.schemaRelationships;
|
|
380
|
-
|
|
381
|
-
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
382
|
-
if (relDef.type === 'belongsTo' && record.attributes[relDef.foreignKey]) {
|
|
383
|
-
data.relationships[relName] = {
|
|
384
|
-
data: {
|
|
385
|
-
type: relDef.resource,
|
|
386
|
-
id: String(record.attributes[relDef.foreignKey])
|
|
387
|
-
}
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
return data;
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
// 4. QUERY RESOURCES
|
|
396
|
-
helpers.dataQuery = async ({ scopeName, context }) => {
|
|
397
|
-
const collection = getCollection(scopeName);
|
|
398
|
-
let records = Array.from(collection.values());
|
|
399
|
-
|
|
400
|
-
// Apply filters
|
|
401
|
-
if (context.queryParams.filters) {
|
|
402
|
-
records = records.filter(record => {
|
|
403
|
-
return Object.entries(context.queryParams.filters).every(([field, value]) => {
|
|
404
|
-
// Support nested field filtering (e.g., author.name)
|
|
405
|
-
if (field.includes('.')) {
|
|
406
|
-
// For simplicity, skip nested filters in this example
|
|
407
|
-
return true;
|
|
408
|
-
}
|
|
409
|
-
return record.attributes[field] === value;
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Apply sorting
|
|
415
|
-
if (context.queryParams.sort?.length > 0) {
|
|
416
|
-
records.sort((a, b) => {
|
|
417
|
-
for (const sortField of context.queryParams.sort) {
|
|
418
|
-
const desc = sortField.startsWith('-');
|
|
419
|
-
const field = desc ? sortField.substring(1) : sortField;
|
|
420
|
-
|
|
421
|
-
const aVal = a.attributes[field];
|
|
422
|
-
const bVal = b.attributes[field];
|
|
423
|
-
|
|
424
|
-
if (aVal < bVal) return desc ? 1 : -1;
|
|
425
|
-
if (aVal > bVal) return desc ? -1 : 1;
|
|
426
|
-
}
|
|
427
|
-
return 0;
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Calculate pagination
|
|
432
|
-
const page = context.queryParams.page || {};
|
|
433
|
-
const pageSize = Math.min(page.size || 20, 100);
|
|
434
|
-
const pageNumber = page.number || 1;
|
|
435
|
-
const total = records.length;
|
|
436
|
-
const pageCount = Math.ceil(total / pageSize);
|
|
437
|
-
|
|
438
|
-
// Apply pagination
|
|
439
|
-
const start = (pageNumber - 1) * pageSize;
|
|
440
|
-
const paginatedRecords = records.slice(start, start + pageSize);
|
|
441
|
-
|
|
442
|
-
// Build response
|
|
443
|
-
const data = paginatedRecords.map((record, index) => ({
|
|
444
|
-
type: scopeName,
|
|
445
|
-
id: record.id,
|
|
446
|
-
attributes: { ...record.attributes },
|
|
447
|
-
relationships: {}
|
|
448
|
-
}));
|
|
449
|
-
|
|
450
|
-
// Add relationships to each record
|
|
451
|
-
const scope = scopes[scopeName];
|
|
452
|
-
const schemaRelationships = scope.vars.schemaInfo.schemaRelationships;
|
|
453
|
-
|
|
454
|
-
data.forEach((item, index) => {
|
|
455
|
-
const record = paginatedRecords[index];
|
|
456
|
-
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
457
|
-
if (relDef.type === 'belongsTo' && record.attributes[relDef.foreignKey]) {
|
|
458
|
-
item.relationships[relName] = {
|
|
459
|
-
data: {
|
|
460
|
-
type: relDef.resource,
|
|
461
|
-
id: String(record.attributes[relDef.foreignKey])
|
|
462
|
-
}
|
|
463
|
-
};
|
|
464
|
-
} else if (relDef.type === 'hasMany' && record.relationships?.[relName]) {
|
|
465
|
-
item.relationships[relName] = {
|
|
466
|
-
data: record.relationships[relName].map(id => ({
|
|
467
|
-
type: relDef.resource,
|
|
468
|
-
id: String(id)
|
|
469
|
-
}))
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
// Handle includes
|
|
476
|
-
const included = [];
|
|
477
|
-
if (context.queryParams.include?.length > 0) {
|
|
478
|
-
const includedIds = new Set();
|
|
479
|
-
|
|
480
|
-
for (const item of data) {
|
|
481
|
-
for (const includePath of context.queryParams.include) {
|
|
482
|
-
const relName = includePath.split('.')[0];
|
|
483
|
-
const relationship = item.relationships[relName];
|
|
484
|
-
|
|
485
|
-
if (relationship?.data) {
|
|
486
|
-
const relData = Array.isArray(relationship.data)
|
|
487
|
-
? relationship.data
|
|
488
|
-
: [relationship.data];
|
|
489
|
-
|
|
490
|
-
for (const rel of relData) {
|
|
491
|
-
const key = `${rel.type}:${rel.id}`;
|
|
492
|
-
if (!includedIds.has(key)) {
|
|
493
|
-
includedIds.add(key);
|
|
494
|
-
const relCollection = getCollection(rel.type);
|
|
495
|
-
const relRecord = relCollection.get(rel.id);
|
|
496
|
-
if (relRecord) {
|
|
497
|
-
included.push({
|
|
498
|
-
type: rel.type,
|
|
499
|
-
id: rel.id,
|
|
500
|
-
attributes: { ...relRecord.attributes }
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Build pagination links
|
|
511
|
-
const baseUrl = `/${scopeName}`;
|
|
512
|
-
const queryString = new URLSearchParams();
|
|
513
|
-
if (page.size) queryString.set('page[size]', pageSize);
|
|
514
|
-
|
|
515
|
-
const links = {
|
|
516
|
-
first: `${baseUrl}?${queryString}&page[number]=1`,
|
|
517
|
-
last: `${baseUrl}?${queryString}&page[number]=${pageCount}`,
|
|
518
|
-
};
|
|
519
|
-
|
|
520
|
-
if (pageNumber > 1) {
|
|
521
|
-
links.prev = `${baseUrl}?${queryString}&page[number]=${pageNumber - 1}`;
|
|
522
|
-
}
|
|
523
|
-
if (pageNumber < pageCount) {
|
|
524
|
-
links.next = `${baseUrl}?${queryString}&page[number]=${pageNumber + 1}`;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return {
|
|
528
|
-
data,
|
|
529
|
-
...(included.length > 0 && { included }),
|
|
530
|
-
meta: {
|
|
531
|
-
page: pageNumber,
|
|
532
|
-
pageSize,
|
|
533
|
-
pageCount,
|
|
534
|
-
total
|
|
535
|
-
},
|
|
536
|
-
links
|
|
537
|
-
};
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
// 5. CREATE RESOURCE
|
|
541
|
-
helpers.dataPost = async ({ scopeName, context }) => {
|
|
542
|
-
const collection = getCollection(scopeName);
|
|
543
|
-
const id = generateId();
|
|
544
|
-
|
|
545
|
-
const record = {
|
|
546
|
-
id,
|
|
547
|
-
attributes: { ...context.inputRecord.data.attributes },
|
|
548
|
-
relationships: {}
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
// Extract belongsTo foreign keys from relationships
|
|
552
|
-
if (context.inputRecord.data.relationships) {
|
|
553
|
-
const scope = scopes[scopeName];
|
|
554
|
-
const schemaRelationships = scope.vars.schemaInfo.schemaRelationships;
|
|
555
|
-
|
|
556
|
-
for (const [relName, relData] of Object.entries(context.inputRecord.data.relationships)) {
|
|
557
|
-
const relDef = schemaRelationships[relName];
|
|
558
|
-
if (relDef?.type === 'belongsTo' && relData.data) {
|
|
559
|
-
record.attributes[relDef.foreignKey] = relData.data.id;
|
|
560
|
-
} else if (relDef?.type === 'hasMany' && relData.data) {
|
|
561
|
-
record.relationships[relName] = relData.data.map(item => item.id);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
collection.set(id, record);
|
|
567
|
-
return id;
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
// 6. REPLACE RESOURCE
|
|
571
|
-
helpers.dataPut = async ({ scopeName, context }) => {
|
|
572
|
-
const collection = getCollection(scopeName);
|
|
573
|
-
const id = String(context.id);
|
|
574
|
-
|
|
575
|
-
if (!context.isCreate && !collection.has(id)) {
|
|
576
|
-
const { RestApiResourceError } = await import('../../lib/rest-api-errors.js');
|
|
577
|
-
throw new RestApiResourceError('Resource not found', {
|
|
578
|
-
subtype: 'not_found',
|
|
579
|
-
resourceType: scopeName,
|
|
580
|
-
resourceId: context.id
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const record = {
|
|
585
|
-
id,
|
|
586
|
-
attributes: { ...context.inputRecord.data.attributes },
|
|
587
|
-
relationships: {}
|
|
588
|
-
};
|
|
589
|
-
|
|
590
|
-
// Extract belongsTo foreign keys
|
|
591
|
-
if (context.inputRecord.data.relationships) {
|
|
592
|
-
const scope = scopes[scopeName];
|
|
593
|
-
const schemaRelationships = scope.vars.schemaInfo.schemaRelationships;
|
|
594
|
-
|
|
595
|
-
for (const [relName, relData] of Object.entries(context.inputRecord.data.relationships)) {
|
|
596
|
-
const relDef = schemaRelationships[relName];
|
|
597
|
-
if (relDef?.type === 'belongsTo' && relData.data) {
|
|
598
|
-
record.attributes[relDef.foreignKey] = relData.data.id;
|
|
599
|
-
} else if (relDef?.type === 'hasMany' && relData.data) {
|
|
600
|
-
record.relationships[relName] = relData.data.map(item => item.id);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
collection.set(id, record);
|
|
606
|
-
};
|
|
607
|
-
|
|
608
|
-
// 7. UPDATE RESOURCE
|
|
609
|
-
helpers.dataPatch = async ({ scopeName, context }) => {
|
|
610
|
-
const collection = getCollection(scopeName);
|
|
611
|
-
const id = String(context.id);
|
|
612
|
-
const existing = collection.get(id);
|
|
613
|
-
|
|
614
|
-
if (!existing) {
|
|
615
|
-
const { RestApiResourceError } = await import('../../lib/rest-api-errors.js');
|
|
616
|
-
throw new RestApiResourceError('Resource not found', {
|
|
617
|
-
subtype: 'not_found',
|
|
618
|
-
resourceType: scopeName,
|
|
619
|
-
resourceId: context.id
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Merge attributes
|
|
624
|
-
if (context.inputRecord.data.attributes) {
|
|
625
|
-
Object.assign(existing.attributes, context.inputRecord.data.attributes);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Update relationships
|
|
629
|
-
if (context.inputRecord.data.relationships) {
|
|
630
|
-
const scope = scopes[scopeName];
|
|
631
|
-
const schemaRelationships = scope.vars.schemaInfo.schemaRelationships;
|
|
632
|
-
|
|
633
|
-
for (const [relName, relData] of Object.entries(context.inputRecord.data.relationships)) {
|
|
634
|
-
const relDef = schemaRelationships[relName];
|
|
635
|
-
if (relDef?.type === 'belongsTo') {
|
|
636
|
-
if (relData.data === null) {
|
|
637
|
-
delete existing.attributes[relDef.foreignKey];
|
|
638
|
-
} else if (relData.data) {
|
|
639
|
-
existing.attributes[relDef.foreignKey] = relData.data.id;
|
|
640
|
-
}
|
|
641
|
-
} else if (relDef?.type === 'hasMany' && relData.data) {
|
|
642
|
-
existing.relationships[relName] = relData.data.map(item => item.id);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
// 8. DELETE RESOURCE
|
|
649
|
-
helpers.dataDelete = async ({ scopeName, context }) => {
|
|
650
|
-
const collection = getCollection(scopeName);
|
|
651
|
-
const id = String(context.id);
|
|
652
|
-
|
|
653
|
-
if (!collection.has(id)) {
|
|
654
|
-
const { RestApiResourceError } = await import('../../lib/rest-api-errors.js');
|
|
655
|
-
throw new RestApiResourceError('Resource not found', {
|
|
656
|
-
subtype: 'not_found',
|
|
657
|
-
resourceType: scopeName,
|
|
658
|
-
resourceId: context.id
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
collection.delete(id);
|
|
663
|
-
return { success: true };
|
|
664
|
-
};
|
|
665
|
-
|
|
666
|
-
log.info('InMemoryStoragePlugin installed - data stored in memory');
|
|
667
|
-
}
|
|
668
|
-
};
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
## Complete Example: Remote API Storage Plugin
|
|
672
|
-
|
|
673
|
-
Here's a storage plugin that proxies requests to a remote API:
|
|
674
|
-
|
|
675
|
-
```javascript
|
|
676
|
-
export const RemoteApiStoragePlugin = {
|
|
677
|
-
name: 'remote-api-storage',
|
|
678
|
-
dependencies: ['rest-api'],
|
|
679
|
-
|
|
680
|
-
install({ helpers, vars, pluginOptions, log }) {
|
|
681
|
-
const baseUrl = pluginOptions.baseUrl || 'https://api.example.com';
|
|
682
|
-
const headers = {
|
|
683
|
-
'Content-Type': 'application/vnd.api+json',
|
|
684
|
-
'Accept': 'application/vnd.api+json',
|
|
685
|
-
// Add authentication if needed
|
|
686
|
-
...(pluginOptions.token && { 'Authorization': `Bearer ${pluginOptions.token}` }),
|
|
687
|
-
...(pluginOptions.headers || {})
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
// Helper to make fetch requests
|
|
691
|
-
const fetchApi = async (path, options = {}) => {
|
|
692
|
-
const url = `${baseUrl}${path}`;
|
|
693
|
-
const response = await fetch(url, {
|
|
694
|
-
...options,
|
|
695
|
-
headers: {
|
|
696
|
-
...headers,
|
|
697
|
-
...options.headers
|
|
698
|
-
}
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
if (!response.ok) {
|
|
702
|
-
const error = await response.json().catch(() => ({ errors: [{ title: 'Request failed' }] }));
|
|
703
|
-
const { RestApiResourceError } = await import('../../lib/rest-api-errors.js');
|
|
704
|
-
throw new RestApiResourceError(
|
|
705
|
-
error.errors?.[0]?.title || 'Remote API error',
|
|
706
|
-
{
|
|
707
|
-
subtype: response.status === 404 ? 'not_found' : 'remote_error',
|
|
708
|
-
statusCode: response.status,
|
|
709
|
-
errors: error.errors
|
|
710
|
-
}
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
return response.json();
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
// 1. CHECK EXISTS
|
|
718
|
-
helpers.dataExists = async ({ scopeName, context }) => {
|
|
719
|
-
try {
|
|
720
|
-
await fetchApi(`/${scopeName}/${context.id}`, { method: 'HEAD' });
|
|
721
|
-
return true;
|
|
722
|
-
} catch (error) {
|
|
723
|
-
if (error.statusCode === 404) return false;
|
|
724
|
-
throw error;
|
|
725
|
-
}
|
|
726
|
-
};
|
|
727
|
-
|
|
728
|
-
// 2. GET SINGLE RESOURCE
|
|
729
|
-
helpers.dataGet = async ({ scopeName, context }) => {
|
|
730
|
-
const queryParams = new URLSearchParams();
|
|
731
|
-
|
|
732
|
-
// Add include parameter
|
|
733
|
-
if (context.queryParams.include?.length > 0) {
|
|
734
|
-
queryParams.set('include', context.queryParams.include.join(','));
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Add sparse fieldsets
|
|
738
|
-
if (context.queryParams.fields) {
|
|
739
|
-
for (const [type, fields] of Object.entries(context.queryParams.fields)) {
|
|
740
|
-
queryParams.set(`fields[${type}]`, fields);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const query = queryParams.toString();
|
|
745
|
-
const path = `/${scopeName}/${context.id}${query ? `?${query}` : ''}`;
|
|
746
|
-
|
|
747
|
-
return await fetchApi(path);
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
// 3. GET MINIMAL
|
|
751
|
-
helpers.dataGetMinimal = async ({ scopeName, context }) => {
|
|
752
|
-
const response = await fetchApi(`/${scopeName}/${context.id}`);
|
|
753
|
-
return response.data;
|
|
754
|
-
};
|
|
755
|
-
|
|
756
|
-
// 4. QUERY RESOURCES
|
|
757
|
-
helpers.dataQuery = async ({ scopeName, context }) => {
|
|
758
|
-
const queryParams = new URLSearchParams();
|
|
759
|
-
|
|
760
|
-
// Add filters
|
|
761
|
-
if (context.queryParams.filters) {
|
|
762
|
-
for (const [field, value] of Object.entries(context.queryParams.filters)) {
|
|
763
|
-
queryParams.set(`filter[${field}]`, value);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// Add sorting
|
|
768
|
-
if (context.queryParams.sort?.length > 0) {
|
|
769
|
-
queryParams.set('sort', context.queryParams.sort.join(','));
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Add pagination
|
|
773
|
-
if (context.queryParams.page) {
|
|
774
|
-
for (const [key, value] of Object.entries(context.queryParams.page)) {
|
|
775
|
-
queryParams.set(`page[${key}]`, value);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// Add includes
|
|
780
|
-
if (context.queryParams.include?.length > 0) {
|
|
781
|
-
queryParams.set('include', context.queryParams.include.join(','));
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Add sparse fieldsets
|
|
785
|
-
if (context.queryParams.fields) {
|
|
786
|
-
for (const [type, fields] of Object.entries(context.queryParams.fields)) {
|
|
787
|
-
queryParams.set(`fields[${type}]`, fields);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const query = queryParams.toString();
|
|
792
|
-
const path = `/${scopeName}${query ? `?${query}` : ''}`;
|
|
793
|
-
|
|
794
|
-
const response = await fetchApi(path);
|
|
795
|
-
|
|
796
|
-
// Ensure proper structure
|
|
797
|
-
return {
|
|
798
|
-
data: response.data || [],
|
|
799
|
-
included: response.included,
|
|
800
|
-
meta: response.meta,
|
|
801
|
-
links: response.links
|
|
802
|
-
};
|
|
803
|
-
};
|
|
804
|
-
|
|
805
|
-
// 5. CREATE RESOURCE
|
|
806
|
-
helpers.dataPost = async ({ scopeName, context }) => {
|
|
807
|
-
const response = await fetchApi(`/${scopeName}`, {
|
|
808
|
-
method: 'POST',
|
|
809
|
-
body: JSON.stringify(context.inputRecord)
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
return response.data.id;
|
|
813
|
-
};
|
|
814
|
-
|
|
815
|
-
// 6. REPLACE RESOURCE
|
|
816
|
-
helpers.dataPut = async ({ scopeName, context }) => {
|
|
817
|
-
await fetchApi(`/${scopeName}/${context.id}`, {
|
|
818
|
-
method: 'PUT',
|
|
819
|
-
body: JSON.stringify(context.inputRecord)
|
|
820
|
-
});
|
|
821
|
-
};
|
|
822
|
-
|
|
823
|
-
// 7. UPDATE RESOURCE
|
|
824
|
-
helpers.dataPatch = async ({ scopeName, context }) => {
|
|
825
|
-
await fetchApi(`/${scopeName}/${context.id}`, {
|
|
826
|
-
method: 'PATCH',
|
|
827
|
-
body: JSON.stringify(context.inputRecord)
|
|
828
|
-
});
|
|
829
|
-
};
|
|
830
|
-
|
|
831
|
-
// 8. DELETE RESOURCE
|
|
832
|
-
helpers.dataDelete = async ({ scopeName, context }) => {
|
|
833
|
-
await fetchApi(`/${scopeName}/${context.id}`, {
|
|
834
|
-
method: 'DELETE'
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
return { success: true };
|
|
838
|
-
};
|
|
839
|
-
|
|
840
|
-
log.info(`RemoteApiStoragePlugin installed - proxying to ${baseUrl}`);
|
|
841
|
-
}
|
|
842
|
-
};
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
## Advanced Topics
|
|
846
|
-
|
|
847
|
-
### Transaction Support
|
|
848
|
-
|
|
849
|
-
If your storage supports transactions, the `context.db` parameter will automatically contain the transaction when one is active:
|
|
850
|
-
|
|
851
|
-
```javascript
|
|
852
|
-
helpers.dataPost = async ({ scopeName, context }) => {
|
|
853
|
-
// context.db is automatically the transaction if one is active,
|
|
854
|
-
// or the base connection if not in a transaction
|
|
855
|
-
const db = context.db || defaultConnection;
|
|
856
|
-
|
|
857
|
-
// For storage that supports transactions:
|
|
858
|
-
if (context.transaction) {
|
|
859
|
-
// We're in a transaction - ensure all operations use it
|
|
860
|
-
await db.insert(scopeName, record);
|
|
861
|
-
} else {
|
|
862
|
-
// No transaction - use regular connection
|
|
863
|
-
await db.insert(scopeName, record);
|
|
864
|
-
}
|
|
865
|
-
};
|
|
866
|
-
```
|
|
867
|
-
|
|
868
|
-
### Sparse Fieldsets
|
|
869
|
-
|
|
870
|
-
When `context.queryParams.fields` is provided, only return the requested fields:
|
|
871
|
-
|
|
872
|
-
```javascript
|
|
873
|
-
// Example: fields[articles]=title,summary
|
|
874
|
-
// Can be a string or array depending on how it was parsed
|
|
875
|
-
const requestedFields = context.queryParams.fields[scopeName];
|
|
876
|
-
if (requestedFields) {
|
|
877
|
-
const fields = Array.isArray(requestedFields)
|
|
878
|
-
? requestedFields
|
|
879
|
-
: requestedFields.split(',');
|
|
880
|
-
|
|
881
|
-
// Filter attributes to only include requested fields
|
|
882
|
-
data.attributes = Object.fromEntries(
|
|
883
|
-
Object.entries(data.attributes).filter(([key]) => fields.includes(key))
|
|
884
|
-
);
|
|
885
|
-
|
|
886
|
-
// Handle nested field requests like fields[articles]=title,author.name
|
|
887
|
-
// The REST API plugin will handle nested field filtering for included resources
|
|
888
|
-
}
|
|
889
|
-
```
|
|
890
|
-
|
|
891
|
-
### Computed Fields and Dependencies
|
|
892
|
-
|
|
893
|
-
The REST API plugin handles computed fields, but you may need to ensure dependency fields are included:
|
|
894
|
-
|
|
895
|
-
```javascript
|
|
896
|
-
// context.computedDependencies tells you which fields are needed for computations
|
|
897
|
-
// Always include these fields even if not explicitly requested
|
|
898
|
-
```
|
|
899
|
-
|
|
900
|
-
### Search and Filter Implementation
|
|
901
|
-
|
|
902
|
-
For complex filtering, you'll need to parse the search schema:
|
|
903
|
-
|
|
904
|
-
```javascript
|
|
905
|
-
// context.schemaInfo.searchSchema defines what can be filtered
|
|
906
|
-
// context.queryParams.filters contains the actual filter values
|
|
907
|
-
```
|
|
908
|
-
|
|
909
|
-
### Error Handling
|
|
910
|
-
|
|
911
|
-
Always use the proper error classes:
|
|
912
|
-
|
|
913
|
-
```javascript
|
|
914
|
-
import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js';
|
|
915
|
-
|
|
916
|
-
// For not found errors
|
|
917
|
-
throw new RestApiResourceError('Resource not found', {
|
|
918
|
-
subtype: 'not_found',
|
|
919
|
-
resourceType: scopeName,
|
|
920
|
-
resourceId: id
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
// For validation errors
|
|
924
|
-
throw new RestApiValidationError('Invalid filter value', {
|
|
925
|
-
fields: ['filters.status'],
|
|
926
|
-
violations: [{
|
|
927
|
-
field: 'filters.status',
|
|
928
|
-
rule: 'invalid_value',
|
|
929
|
-
message: 'Status must be one of: draft, published'
|
|
930
|
-
}]
|
|
931
|
-
});
|
|
932
|
-
```
|
|
933
|
-
|
|
934
|
-
## Testing Your Storage Plugin
|
|
935
|
-
|
|
936
|
-
### Using the Test Suite
|
|
937
|
-
|
|
938
|
-
The JSON REST API test suite can be adapted for your storage plugin:
|
|
939
|
-
|
|
940
|
-
```javascript
|
|
941
|
-
import { createBasicApi } from './tests/fixtures/api-configs.js';
|
|
942
|
-
import { YourStoragePlugin } from './your-storage-plugin.js';
|
|
943
|
-
|
|
944
|
-
describe('Your Storage Plugin', () => {
|
|
945
|
-
let api;
|
|
946
|
-
|
|
947
|
-
before(async () => {
|
|
948
|
-
// Create API with your storage instead of Knex
|
|
949
|
-
api = await createApi({
|
|
950
|
-
plugins: [
|
|
951
|
-
[RestApiPlugin, { /* options */ }],
|
|
952
|
-
[YourStoragePlugin, { /* options */ }],
|
|
953
|
-
// ... other plugins
|
|
954
|
-
]
|
|
955
|
-
});
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
it('should create and retrieve a resource', async () => {
|
|
959
|
-
const result = await api.resources.articles.post({
|
|
960
|
-
inputRecord: {
|
|
961
|
-
data: {
|
|
962
|
-
type: 'articles',
|
|
963
|
-
attributes: {
|
|
964
|
-
title: 'Test Article'
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
});
|
|
969
|
-
|
|
970
|
-
const article = await api.resources.articles.get({ id: result.id });
|
|
971
|
-
expect(article.data.attributes.title).to.equal('Test Article');
|
|
972
|
-
});
|
|
973
|
-
});
|
|
974
|
-
```
|
|
975
|
-
|
|
976
|
-
### Storage-Specific Tests
|
|
977
|
-
|
|
978
|
-
Test edge cases specific to your storage:
|
|
979
|
-
|
|
980
|
-
```javascript
|
|
981
|
-
describe('Edge Cases', () => {
|
|
982
|
-
it('should handle concurrent writes', async () => {
|
|
983
|
-
// Test your storage's concurrency handling
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
it('should handle large datasets', async () => {
|
|
987
|
-
// Test pagination with many records
|
|
988
|
-
});
|
|
989
|
-
|
|
990
|
-
it('should handle network failures gracefully', async () => {
|
|
991
|
-
// For remote storage, test connection issues
|
|
992
|
-
});
|
|
993
|
-
});
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
## Common Pitfalls & Best Practices
|
|
997
|
-
|
|
998
|
-
### 1. ID Type Conversion
|
|
999
|
-
|
|
1000
|
-
JSON:API requires IDs to be strings, but your storage might use numbers:
|
|
1001
|
-
|
|
1002
|
-
```javascript
|
|
1003
|
-
// Always convert IDs to strings in responses
|
|
1004
|
-
data.id = String(record.id);
|
|
1005
|
-
|
|
1006
|
-
// Accept both strings and numbers in inputs
|
|
1007
|
-
const id = String(context.id);
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
|
-
### 2. Relationship Format
|
|
1011
|
-
|
|
1012
|
-
Relationships must follow the JSON:API format exactly:
|
|
1013
|
-
|
|
1014
|
-
```javascript
|
|
1015
|
-
// Correct - single relationship
|
|
1016
|
-
relationships: {
|
|
1017
|
-
author: {
|
|
1018
|
-
data: { type: 'users', id: '123' }
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
// Correct - to-many relationship
|
|
1023
|
-
relationships: {
|
|
1024
|
-
tags: {
|
|
1025
|
-
data: [
|
|
1026
|
-
{ type: 'tags', id: '1' },
|
|
1027
|
-
{ type: 'tags', id: '2' }
|
|
1028
|
-
]
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
// Correct - empty relationship
|
|
1033
|
-
relationships: {
|
|
1034
|
-
author: {
|
|
1035
|
-
data: null
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
```
|
|
1039
|
-
|
|
1040
|
-
### 3. Error Response Format
|
|
1041
|
-
|
|
1042
|
-
Errors should include proper subtypes:
|
|
1043
|
-
|
|
1044
|
-
```javascript
|
|
1045
|
-
// Use these standard subtypes
|
|
1046
|
-
'not_found' - Resource doesn't exist
|
|
1047
|
-
'validation_error' - Input validation failed
|
|
1048
|
-
'permission_denied' - Insufficient permissions
|
|
1049
|
-
'conflict' - Resource conflict (e.g., duplicate)
|
|
1050
|
-
```
|
|
1051
|
-
|
|
1052
|
-
### 4. Pagination Meta
|
|
1053
|
-
|
|
1054
|
-
Always include pagination metadata for queries:
|
|
1055
|
-
|
|
1056
|
-
```javascript
|
|
1057
|
-
meta: {
|
|
1058
|
-
page: 1, // Current page
|
|
1059
|
-
pageSize: 20, // Items per page
|
|
1060
|
-
pageCount: 5, // Total pages
|
|
1061
|
-
total: 100 // Total items
|
|
1062
|
-
}
|
|
1063
|
-
```
|
|
1064
|
-
|
|
1065
|
-
### 5. Include Deduplication
|
|
1066
|
-
|
|
1067
|
-
When returning included resources, avoid duplicates:
|
|
1068
|
-
|
|
1069
|
-
```javascript
|
|
1070
|
-
const includedMap = new Map();
|
|
1071
|
-
// Use type:id as key to ensure uniqueness
|
|
1072
|
-
includedMap.set(`${type}:${id}`, resource);
|
|
1073
|
-
const included = Array.from(includedMap.values());
|
|
1074
|
-
```
|
|
1075
|
-
|
|
1076
|
-
### 6. Performance Considerations
|
|
1077
|
-
|
|
1078
|
-
- Cache frequently accessed data
|
|
1079
|
-
- Implement efficient filtering at the storage level
|
|
1080
|
-
- Use bulk operations where possible
|
|
1081
|
-
- Consider implementing cursor-based pagination for large datasets
|
|
1082
|
-
|
|
1083
|
-
### 7. Schema Information
|
|
1084
|
-
|
|
1085
|
-
Use the schema information provided in context:
|
|
1086
|
-
|
|
1087
|
-
```javascript
|
|
1088
|
-
// Available in context.schemaInfo:
|
|
1089
|
-
- tableName: Storage identifier for the resource
|
|
1090
|
-
- idProperty: Primary key field name (might not be 'id')
|
|
1091
|
-
- schema: Full schema definition
|
|
1092
|
-
- schemaRelationships: Relationship definitions
|
|
1093
|
-
- searchSchema: Filterable fields and their rules
|
|
1094
|
-
```
|
|
1095
|
-
|
|
1096
|
-
## Conclusion
|
|
1097
|
-
|
|
1098
|
-
Creating a custom storage plugin gives you complete control over how data is stored and retrieved while leveraging all the features of the JSON REST API library. The key is to properly implement the 8 required helpers and ensure all responses follow the JSON:API specification.
|
|
1099
|
-
|
|
1100
|
-
Remember:
|
|
1101
|
-
- Start with the in-memory example and adapt it to your needs
|
|
1102
|
-
- Always return proper JSON:API formatted responses
|
|
1103
|
-
- Use the provided error classes for consistency
|
|
1104
|
-
- Test thoroughly with the existing test suite
|
|
1105
|
-
- Refer to the Knex plugin source code for complex implementations
|
|
1106
|
-
|
|
1107
|
-
Happy coding!
|