json-rest-api 1.0.7 → 1.0.8
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/.claude/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +90 -32
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
package/docs/README.md
CHANGED
|
@@ -1,2277 +1,95 @@
|
|
|
1
|
-
# JSON REST API
|
|
1
|
+
# JSON REST API
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A powerful REST API plugin for [hooked-api](https://github.com/mobily-enterprises/hooked-api) that provides JSON:API-compliant endpoints with minimal configuration. This library makes it easy to create fully-featured REST APIs with support for relationships, filtering, sorting, pagination, and file uploads.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Here's a complete working example to get you started:
|
|
8
|
-
|
|
9
|
-
```javascript
|
|
10
|
-
import { Api } from 'hooked-api';
|
|
11
|
-
import { RestApiPlugin, HttpPlugin, FileHandlingPlugin } from 'json-rest-api';
|
|
12
|
-
|
|
13
|
-
// Create API
|
|
14
|
-
const api = new Api({ name: 'my-api' });
|
|
15
|
-
|
|
16
|
-
// Create a reusable in-memory storage plugin
|
|
17
|
-
// (In production, you'd use RestApiKnexPlugin or another database plugin)
|
|
18
|
-
const inMemoryStoragePlugin = {
|
|
19
|
-
name: 'in-memory-storage',
|
|
20
|
-
install({ helpers }) {
|
|
21
|
-
const storage = new Map();
|
|
22
|
-
|
|
23
|
-
// Seed with some initial data
|
|
24
|
-
storage.set('books', [
|
|
25
|
-
{ id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 },
|
|
26
|
-
{ id: '2', title: '1984', author: 'George Orwell', year: 1949 },
|
|
27
|
-
{ id: '3', title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
helpers.dataQuery = async ({ scopeName }) => {
|
|
31
|
-
const records = storage.get(scopeName) || [];
|
|
32
|
-
return {
|
|
33
|
-
data: records.map(record => ({
|
|
34
|
-
type: scopeName,
|
|
35
|
-
id: record.id,
|
|
36
|
-
attributes: record
|
|
37
|
-
}))
|
|
38
|
-
};
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
helpers.dataGet = async ({ scopeName, id }) => {
|
|
42
|
-
const records = storage.get(scopeName) || [];
|
|
43
|
-
const record = records.find(r => r.id === id);
|
|
44
|
-
|
|
45
|
-
if (!record) {
|
|
46
|
-
return { data: null };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
data: {
|
|
51
|
-
type: scopeName,
|
|
52
|
-
id: record.id,
|
|
53
|
-
attributes: record
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
helpers.dataPost = async ({ scopeName, inputRecord }) => {
|
|
59
|
-
const records = storage.get(scopeName) || [];
|
|
60
|
-
const newRecord = {
|
|
61
|
-
id: Date.now().toString(),
|
|
62
|
-
...inputRecord.data.attributes
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
records.push(newRecord);
|
|
66
|
-
storage.set(scopeName, records);
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
data: {
|
|
70
|
-
type: scopeName,
|
|
71
|
-
id: newRecord.id,
|
|
72
|
-
attributes: newRecord
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
helpers.dataPut = async ({ scopeName, id, inputRecord }) => {
|
|
78
|
-
const records = storage.get(scopeName) || [];
|
|
79
|
-
const index = records.findIndex(r => r.id === id);
|
|
80
|
-
|
|
81
|
-
if (index === -1) {
|
|
82
|
-
throw new Error('Record not found');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
records[index] = {
|
|
86
|
-
id,
|
|
87
|
-
...inputRecord.data.attributes
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
data: {
|
|
92
|
-
type: scopeName,
|
|
93
|
-
id: id,
|
|
94
|
-
attributes: records[index]
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
helpers.dataPatch = async ({ scopeName, id, inputRecord }) => {
|
|
100
|
-
const records = storage.get(scopeName) || [];
|
|
101
|
-
const index = records.findIndex(r => r.id === id);
|
|
102
|
-
|
|
103
|
-
if (index === -1) {
|
|
104
|
-
throw new Error('Record not found');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
records[index] = {
|
|
108
|
-
...records[index],
|
|
109
|
-
...inputRecord.data.attributes
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
data: {
|
|
114
|
-
type: scopeName,
|
|
115
|
-
id: id,
|
|
116
|
-
attributes: records[index]
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
helpers.dataDelete = async ({ scopeName, id }) => {
|
|
122
|
-
const records = storage.get(scopeName) || [];
|
|
123
|
-
const filtered = records.filter(r => r.id !== id);
|
|
124
|
-
storage.set(scopeName, filtered);
|
|
125
|
-
return { success: true };
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
helpers.dataExists = async ({ scopeName, id }) => {
|
|
129
|
-
const records = storage.get(scopeName) || [];
|
|
130
|
-
return records.some(r => r.id === id);
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
// Install plugins (order matters!)
|
|
136
|
-
await api.use(RestApiPlugin);
|
|
137
|
-
await api.use(inMemoryStoragePlugin);
|
|
138
|
-
await api.use(HttpPlugin);
|
|
139
|
-
|
|
140
|
-
// Define a resource
|
|
141
|
-
api.addResource('books', {
|
|
142
|
-
schema: {
|
|
143
|
-
title: { type: 'string', required: true },
|
|
144
|
-
author: { type: 'string', required: true },
|
|
145
|
-
year: { type: 'number' }
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// Start the server
|
|
150
|
-
api.http.startServer(3000);
|
|
151
|
-
console.log('API running at http://localhost:3000/api');
|
|
152
|
-
|
|
153
|
-
// Your API is now ready!
|
|
154
|
-
// GET http://localhost:3000/api/books
|
|
155
|
-
// POST http://localhost:3000/api/books
|
|
156
|
-
// etc.
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
## Table of Contents
|
|
160
|
-
|
|
161
|
-
1. [Getting Started](#getting-started)
|
|
162
|
-
2. [Basic REST API Plugin](#basic-rest-api-plugin)
|
|
163
|
-
3. [Response Control and ID Handling](#response-control-and-id-handling)
|
|
164
|
-
- [Return Full Record Configuration](#return-full-record-configuration)
|
|
165
|
-
- [Strict ID Handling](#strict-id-handling)
|
|
166
|
-
4. [Connector Plugins](#connector-plugins)
|
|
167
|
-
- [Express Plugin](#express-plugin)
|
|
168
|
-
- [HTTP Plugin](#http-plugin)
|
|
169
|
-
5. [File Storage](#file-storage)
|
|
170
|
-
- [File Uploads with Express](#file-uploads-with-express)
|
|
171
|
-
- [Storage Adapters](#storage-adapters)
|
|
172
|
-
|
|
173
|
-
## Getting Started
|
|
174
|
-
|
|
175
|
-
First, install the required packages:
|
|
176
|
-
|
|
177
|
-
```bash
|
|
178
|
-
npm install hooked-api json-rest-api
|
|
179
|
-
# If you get peer dependency conflicts, use:
|
|
180
|
-
# npm install hooked-api json-rest-api --legacy-peer-deps
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
Create a new file called `api.js`:
|
|
184
|
-
|
|
185
|
-
```javascript
|
|
186
|
-
import { Api } from 'hooked-api';
|
|
187
|
-
|
|
188
|
-
// Create a new API instance
|
|
189
|
-
const api = new Api({
|
|
190
|
-
name: 'my-library-api',
|
|
191
|
-
version: '1.0.0'
|
|
192
|
-
});
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
This is a pure Hooked API with no plugins. So, it doesn't do anything.
|
|
196
|
-
|
|
197
|
-
## Basic REST API Plugin
|
|
198
|
-
|
|
199
|
-
The `RestApiPlugin` is the foundation of jsonrestapi. It adds REST methods (query, get, post, put, patch, delete) to your API scopes.
|
|
200
|
-
|
|
201
|
-
### Adding the REST API Plugin
|
|
202
|
-
|
|
203
|
-
```javascript
|
|
204
|
-
import { Api } from 'hooked-api';
|
|
205
|
-
import { RestApiPlugin } from 'json-rest-api';
|
|
206
|
-
|
|
207
|
-
const api = new Api({
|
|
208
|
-
name: 'my-library-api',
|
|
209
|
-
version: '1.0.0'
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Use the REST API plugin
|
|
213
|
-
await api.use(RestApiPlugin);
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### Creating a Books Resource
|
|
217
|
-
|
|
218
|
-
Now let's create a simple "books" resource with fake data:
|
|
219
|
-
|
|
220
|
-
```javascript
|
|
221
|
-
// Define a books resource
|
|
222
|
-
api.addResource('books', {
|
|
223
|
-
schema: {
|
|
224
|
-
title: { type: 'string', required: true },
|
|
225
|
-
author: { type: 'string', required: true },
|
|
226
|
-
year: { type: 'number' },
|
|
227
|
-
isbn: { type: 'string' }
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
// To actually use the REST API methods, you need a storage plugin
|
|
232
|
-
// Use the same inMemoryStoragePlugin from the Quick Start example above
|
|
233
|
-
await api.use(inMemoryStoragePlugin);
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### Using the API Programmatically
|
|
237
|
-
|
|
238
|
-
With the REST API plugin and a storage plugin, you can now use the API programmatically:
|
|
239
|
-
|
|
240
|
-
```javascript
|
|
241
|
-
// Query all books
|
|
242
|
-
const allBooks = await api.resources.books.query({});
|
|
243
|
-
console.log(allBooks);
|
|
244
|
-
// Output: { data: [{ type: 'books', id: '1', attributes: {...} }, ...] }
|
|
245
|
-
|
|
246
|
-
// Get a single book
|
|
247
|
-
const book = await api.resources.books.get({ id: '1' });
|
|
248
|
-
console.log(book);
|
|
249
|
-
// Output: { data: { type: 'books', id: '1', attributes: {...} } }
|
|
250
|
-
|
|
251
|
-
// Create a new book
|
|
252
|
-
const newBook = await api.resources.books.post({
|
|
253
|
-
inputRecord: {
|
|
254
|
-
data: {
|
|
255
|
-
type: 'books',
|
|
256
|
-
attributes: {
|
|
257
|
-
title: 'New Book',
|
|
258
|
-
author: 'New Author',
|
|
259
|
-
year: 2024
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
console.log(newBook);
|
|
265
|
-
|
|
266
|
-
// Update a book (replace all fields)
|
|
267
|
-
const updatedBook = await api.resources.books.put({
|
|
268
|
-
inputRecord: {
|
|
269
|
-
data: {
|
|
270
|
-
type: 'books',
|
|
271
|
-
id: '1',
|
|
272
|
-
attributes: {
|
|
273
|
-
title: 'Updated Title',
|
|
274
|
-
author: 'Updated Author',
|
|
275
|
-
year: 2024
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// Partially update a book
|
|
282
|
-
const patchedBook = await api.resources.books.patch({
|
|
283
|
-
inputRecord: {
|
|
284
|
-
data: {
|
|
285
|
-
type: 'books',
|
|
286
|
-
id: '1',
|
|
287
|
-
attributes: {
|
|
288
|
-
title: 'New Title Only'
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
// Delete a book
|
|
295
|
-
await api.resources.books.delete({ id: '1' });
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
## Response Control and ID Handling
|
|
299
|
-
|
|
300
|
-
The REST API plugin provides advanced configuration options for controlling response payloads and ID validation behavior.
|
|
301
|
-
|
|
302
|
-
### Return Full Record Configuration
|
|
303
|
-
|
|
304
|
-
By default, POST, PUT, and PATCH operations return the complete record including all fields, defaults, and relationships. You can configure this behavior to return minimal responses containing only the data that was sent in the request.
|
|
305
|
-
|
|
306
|
-
#### Configuration Levels
|
|
307
|
-
|
|
308
|
-
The `returnFullRecord` option can be configured at three levels (from lowest to highest priority):
|
|
309
|
-
|
|
310
|
-
1. **API-level** (default for all resources)
|
|
311
|
-
2. **Resource-level** (override for specific resources)
|
|
312
|
-
3. **Method parameter** (override for individual calls)
|
|
313
|
-
|
|
314
|
-
#### Basic Configuration
|
|
315
|
-
|
|
316
|
-
```javascript
|
|
317
|
-
// API-level configuration
|
|
318
|
-
await api.use(RestApiPlugin, {
|
|
319
|
-
returnFullRecord: {
|
|
320
|
-
post: false, // Don't return full record for POST
|
|
321
|
-
put: false, // Don't return full record for PUT
|
|
322
|
-
patch: false, // Don't return full record for PATCH
|
|
323
|
-
allowRemoteOverride: true // Allow clients to override via query param
|
|
324
|
-
}
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
// Resource-level override
|
|
328
|
-
api.addResource('articles', {
|
|
329
|
-
schema: {
|
|
330
|
-
title: { type: 'string', required: true },
|
|
331
|
-
body: { type: 'string' },
|
|
332
|
-
status: { type: 'string', default: 'draft' }
|
|
333
|
-
},
|
|
334
|
-
returnFullRecord: {
|
|
335
|
-
post: true, // Always return full record for articles POST
|
|
336
|
-
allowRemoteOverride: false // Don't allow client override for articles
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
// Method-level override
|
|
341
|
-
const result = await api.resources.articles.post({
|
|
342
|
-
inputRecord: {
|
|
343
|
-
data: {
|
|
344
|
-
type: 'articles',
|
|
345
|
-
attributes: { title: 'New Article' }
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
returnFullRecord: false // Override for this specific call
|
|
349
|
-
});
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
#### Behavior Differences
|
|
353
|
-
|
|
354
|
-
**Full Record (default, `returnFullRecord: true`):**
|
|
355
|
-
- Returns the complete record after creation/update
|
|
356
|
-
- Includes all fields with their current database values
|
|
357
|
-
- Includes default values applied by schema or database
|
|
358
|
-
- Can include relationships if requested via `include` parameter
|
|
359
|
-
- Equivalent to performing a GET request after the operation
|
|
360
|
-
|
|
361
|
-
**Minimal Record (`returnFullRecord: false`):**
|
|
362
|
-
- Returns only the fields that were provided in the request
|
|
363
|
-
- Includes the generated/updated ID
|
|
364
|
-
- Does not include default values or unchanged fields
|
|
365
|
-
- More efficient for large records or when defaults aren't needed
|
|
366
|
-
- Useful for bandwidth-constrained environments
|
|
367
|
-
|
|
368
|
-
#### Example Responses
|
|
369
|
-
|
|
370
|
-
```javascript
|
|
371
|
-
// Input
|
|
372
|
-
const input = {
|
|
373
|
-
data: {
|
|
374
|
-
type: 'articles',
|
|
375
|
-
attributes: {
|
|
376
|
-
title: 'My Article'
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
// With returnFullRecord: true (default)
|
|
382
|
-
{
|
|
383
|
-
"data": {
|
|
384
|
-
"type": "articles",
|
|
385
|
-
"id": "123",
|
|
386
|
-
"attributes": {
|
|
387
|
-
"title": "My Article",
|
|
388
|
-
"status": "draft", // Default value included
|
|
389
|
-
"createdAt": "2024-01-01T00:00:00Z", // Database-generated
|
|
390
|
-
"updatedAt": "2024-01-01T00:00:00Z" // Database-generated
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// With returnFullRecord: false
|
|
396
|
-
{
|
|
397
|
-
"data": {
|
|
398
|
-
"type": "articles",
|
|
399
|
-
"id": "123",
|
|
400
|
-
"attributes": {
|
|
401
|
-
"title": "My Article" // Only what was provided
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
#### Remote Override via Query Parameters
|
|
408
|
-
|
|
409
|
-
When `allowRemoteOverride` is enabled, clients can control the response format using query parameters:
|
|
410
|
-
|
|
411
|
-
```bash
|
|
412
|
-
# Request minimal response
|
|
413
|
-
POST /api/articles?returnFullRecord=false
|
|
414
|
-
|
|
415
|
-
# Request full response (when API default is false)
|
|
416
|
-
POST /api/articles?returnFullRecord=true
|
|
417
|
-
```
|
|
418
|
-
|
|
419
|
-
#### Network vs Programmatic Usage
|
|
420
|
-
|
|
421
|
-
### Complete Example
|
|
422
|
-
|
|
423
|
-
Here's a complete example demonstrating both features:
|
|
424
|
-
|
|
425
|
-
```javascript
|
|
426
|
-
import { Api } from 'hooked-api';
|
|
427
|
-
import { RestApiPlugin, ExpressPlugin, RestApiKnexPlugin } from 'json-rest-api';
|
|
428
|
-
import express from 'express';
|
|
429
|
-
|
|
430
|
-
const api = new Api({ name: 'my-api' });
|
|
431
|
-
|
|
432
|
-
// Configure REST API with custom settings
|
|
433
|
-
await api.use(RestApiPlugin, {
|
|
434
|
-
returnFullRecord: {
|
|
435
|
-
post: false,
|
|
436
|
-
put: false,
|
|
437
|
-
patch: true, // Only PATCH returns full record by default
|
|
438
|
-
allowRemoteOverride: true
|
|
439
|
-
}
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
// Add Express connector
|
|
443
|
-
await api.use(ExpressPlugin, { basePath: '/api' });
|
|
444
|
-
|
|
445
|
-
// Add your database plugin
|
|
446
|
-
await api.use(RestApiKnexPlugin, { /* your config */ });
|
|
447
|
-
|
|
448
|
-
// Define resources with overrides
|
|
449
|
-
api.addResource('users', {
|
|
450
|
-
schema: {
|
|
451
|
-
name: { type: 'string', required: true },
|
|
452
|
-
email: { type: 'string', required: true },
|
|
453
|
-
role: { type: 'string', default: 'user' },
|
|
454
|
-
createdAt: { type: 'string', default: () => new Date().toISOString() }
|
|
455
|
-
},
|
|
456
|
-
// Users always return full record for audit purposes
|
|
457
|
-
returnFullRecord: {
|
|
458
|
-
post: true,
|
|
459
|
-
put: true,
|
|
460
|
-
patch: true,
|
|
461
|
-
allowRemoteOverride: false // Don't allow clients to change this
|
|
462
|
-
}
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
api.addResource('posts', {
|
|
466
|
-
schema: {
|
|
467
|
-
title: { type: 'string', required: true },
|
|
468
|
-
content: { type: 'string' },
|
|
469
|
-
status: { type: 'string', default: 'draft' }
|
|
470
|
-
}
|
|
471
|
-
// Uses API defaults (minimal response)
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
// Start server
|
|
475
|
-
const app = express();
|
|
476
|
-
app.use(api.expressRouter);
|
|
477
|
-
app.listen(3000);
|
|
478
|
-
|
|
479
|
-
// Client examples:
|
|
480
|
-
|
|
481
|
-
// 1. Create post with minimal response (API default)
|
|
482
|
-
// POST /api/posts
|
|
483
|
-
// Returns: { "data": { "type": "posts", "id": "1", "attributes": { "title": "Hello" } } }
|
|
484
|
-
|
|
485
|
-
// 2. Create post with full response (query override)
|
|
486
|
-
// POST /api/posts?returnFullRecord=true
|
|
487
|
-
// Returns: { "data": { "type": "posts", "id": "1", "attributes": { "title": "Hello", "status": "draft" } } }
|
|
488
|
-
|
|
489
|
-
// 3. Create user (always returns full record)
|
|
490
|
-
// POST /api/users?returnFullRecord=false // Ignored due to allowRemoteOverride: false
|
|
491
|
-
// Returns: { "data": { "type": "users", "id": "2", "attributes": { "name": "John", "email": "john@example.com", "role": "user", "createdAt": "2024-01-01T00:00:00Z" } } }
|
|
492
|
-
|
|
493
|
-
// 4. Update with relaxed ID handling (programmatic)
|
|
494
|
-
await api.resources.posts.patch({
|
|
495
|
-
id: '1',
|
|
496
|
-
inputRecord: {
|
|
497
|
-
data: {
|
|
498
|
-
type: 'posts',
|
|
499
|
-
id: 1,
|
|
500
|
-
attributes: { status: 'published' }
|
|
501
|
-
}
|
|
502
|
-
},
|
|
503
|
-
returnFullRecord: false // Override to get minimal response
|
|
504
|
-
});
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
### Use Cases
|
|
508
|
-
|
|
509
|
-
**When to use minimal responses (`returnFullRecord: false`):**
|
|
510
|
-
- High-volume APIs where bandwidth is a concern
|
|
511
|
-
- When clients already have the full record and only need confirmation
|
|
512
|
-
- Mobile applications with limited data plans
|
|
513
|
-
- When default values are computed client-side
|
|
514
|
-
|
|
515
|
-
The connector plugins allow you to expose the API in several ways. The core plugins expose the API to Express or to pure Node.
|
|
516
|
-
|
|
517
|
-
## Connector Plugins
|
|
518
|
-
|
|
519
|
-
While the REST API plugin provides the core functionality, you need a connector plugin to expose your API over HTTP. You can choose between Express or the pure HTTP plugin.
|
|
520
|
-
|
|
521
|
-
> **Important**: Use either the Express plugin OR the HTTP plugin, not both!
|
|
522
|
-
|
|
523
|
-
### Features (Common to Both Plugins)
|
|
524
|
-
|
|
525
|
-
Both connector plugins provide these features:
|
|
526
|
-
|
|
527
|
-
- Automatic route creation for all resources (GET, POST, PUT, PATCH, DELETE)
|
|
528
|
-
- Full JSON:API compliant request/response handling
|
|
529
|
-
- Query parameter parsing (include, fields, filter, sort, page)
|
|
530
|
-
- Request body parsing with configurable size limits
|
|
531
|
-
- Strict content type validation with `strictContentType` option (default: true)
|
|
532
|
-
- Enforces `application/vnd.api+json` or `application/json` for POST/PUT/PATCH
|
|
533
|
-
- Returns 415 Unsupported Media Type for invalid content types
|
|
534
|
-
- Error responses in JSON:API format with proper HTTP status codes (400, 403, 404, 405, 409, 415, 422, 500)
|
|
535
|
-
- File upload support when used with FileHandlingPlugin
|
|
536
|
-
- Access to raw request/response objects for advanced use cases
|
|
537
|
-
- Dynamic resource support (resources added after server startup work automatically)
|
|
538
|
-
|
|
539
|
-
Example error response (same for both plugins):
|
|
540
|
-
```json
|
|
541
|
-
{
|
|
542
|
-
"errors": [{
|
|
543
|
-
"status": "422",
|
|
544
|
-
"title": "Validation Error",
|
|
545
|
-
"detail": "Title is required",
|
|
546
|
-
"source": { "pointer": "/data/attributes/title" }
|
|
547
|
-
}]
|
|
548
|
-
}
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
### Express Plugin
|
|
552
|
-
|
|
553
|
-
The Express plugin integrates your API with Express.js, the popular Node.js web framework. In addition to the common features above, it provides:
|
|
554
|
-
|
|
555
|
-
- Middleware injection support (before/after routes, per-resource)
|
|
556
|
-
- Works with any Express middleware ecosystem
|
|
557
|
-
- Familiar Express router patterns
|
|
558
|
-
|
|
559
|
-
First, install Express:
|
|
560
|
-
|
|
561
|
-
```bash
|
|
562
|
-
npm install express
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
Then update your code:
|
|
566
|
-
|
|
567
|
-
```javascript
|
|
568
|
-
import { Api } from 'hooked-api';
|
|
569
|
-
import { RestApiPlugin, ExpressPlugin } from 'json-rest-api';
|
|
570
|
-
import express from 'express';
|
|
571
|
-
|
|
572
|
-
const api = new Api({
|
|
573
|
-
name: 'my-library-api',
|
|
574
|
-
version: '1.0.0'
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
// Add plugins
|
|
578
|
-
await api.use(RestApiPlugin);
|
|
579
|
-
await api.use(ExpressPlugin, {
|
|
580
|
-
basePath: '/api', // Optional, defaults to '/api'
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
// Add your books resource (you'll need a storage plugin for it to work)
|
|
584
|
-
// ...
|
|
585
|
-
|
|
586
|
-
// Create Express app
|
|
587
|
-
const app = express();
|
|
588
|
-
|
|
589
|
-
// Mount the API routes - both approaches work:
|
|
590
|
-
|
|
591
|
-
// Approach 1: Direct router usage (standard Express pattern)
|
|
592
|
-
app.use(api.http.express.router); // Mount at root
|
|
593
|
-
// OR
|
|
594
|
-
app.use('/v1', api.http.express.router); // Mount at specific path
|
|
595
|
-
|
|
596
|
-
// Approach 2: Convenience method (adds logging)
|
|
597
|
-
api.http.express.mount(app); // Mount at root
|
|
598
|
-
// OR
|
|
599
|
-
api.http.express.mount(app, '/v1'); // Mount at specific path
|
|
600
|
-
|
|
601
|
-
// Note: The mount() method is just syntactic sugar that calls app.use() and logs the mount path
|
|
602
|
-
|
|
603
|
-
// Start the server
|
|
604
|
-
app.listen(3000, () => {
|
|
605
|
-
console.log('API running at http://localhost:3000/api');
|
|
606
|
-
});
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
Now your API is ready to receive HTTP requests!
|
|
610
|
-
|
|
611
|
-
### HTTP Plugin
|
|
612
|
-
|
|
613
|
-
If you prefer not to use Express, the HTTP plugin provides a lightweight alternative using only Node.js built-in modules. In addition to the common features above, it provides:
|
|
614
|
-
|
|
615
|
-
- Zero framework dependencies (pure Node.js)
|
|
616
|
-
- Built-in HTTP server with configurable port
|
|
617
|
-
- Multiple deployment options (standalone server, HTTPS with custom handler, integration with existing servers)
|
|
618
|
-
- Character encoding support (respects Content-Type charset)
|
|
619
|
-
|
|
620
|
-
#### Configuration Options
|
|
621
|
-
|
|
622
|
-
```javascript
|
|
623
|
-
import { Api } from 'hooked-api';
|
|
624
|
-
import { RestApiPlugin, HttpPlugin } from 'json-rest-api';
|
|
625
|
-
|
|
626
|
-
const api = new Api({
|
|
627
|
-
name: 'my-library-api',
|
|
628
|
-
version: '1.0.0'
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
// Add plugins
|
|
632
|
-
await api.use(RestApiPlugin);
|
|
633
|
-
await api.use(HttpPlugin, {
|
|
634
|
-
port: 3000, // Server port (default: 3000)
|
|
635
|
-
basePath: '/api', // API base path (default: '/api')
|
|
636
|
-
strictContentType: true, // Enforce JSON content types (default: true)
|
|
637
|
-
requestSizeLimit: '10mb' // Max request body size (default: '1mb')
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
// Add your books resource (you'll need a storage plugin for it to work)
|
|
641
|
-
// ...
|
|
642
|
-
|
|
643
|
-
// Start the HTTP server - multiple options:
|
|
644
|
-
|
|
645
|
-
// Option 1: Start on configured port (3000 by default)
|
|
646
|
-
api.http.startServer();
|
|
647
|
-
|
|
648
|
-
// Option 2: Start on a different port
|
|
649
|
-
api.http.startServer(4000);
|
|
650
|
-
|
|
651
|
-
// Option 3: Access the raw server for custom configuration
|
|
652
|
-
const server = api.http.server;
|
|
653
|
-
server.timeout = 60000; // 60 second timeout
|
|
654
|
-
server.listen(3000);
|
|
655
|
-
|
|
656
|
-
// Option 4: Use with HTTPS
|
|
657
|
-
import { createServer } from 'https';
|
|
658
|
-
const httpsServer = createServer(sslOptions, api.http.handler);
|
|
659
|
-
httpsServer.listen(443);
|
|
660
|
-
|
|
661
|
-
// Option 5: Integrate into existing server
|
|
662
|
-
myExistingServer.on('request', (req, res) => {
|
|
663
|
-
if (req.url.startsWith('/api')) {
|
|
664
|
-
api.http.handler(req, res);
|
|
665
|
-
} else {
|
|
666
|
-
// Handle other routes
|
|
667
|
-
}
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
console.log('API running at http://localhost:3000/api');
|
|
671
|
-
```
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
#### Character Encoding
|
|
675
|
-
|
|
676
|
-
The plugin respects the charset parameter in Content-Type headers:
|
|
677
|
-
|
|
678
|
-
```bash
|
|
679
|
-
curl -X POST http://localhost:3000/api/books \
|
|
680
|
-
-H "Content-Type: application/json; charset=utf-16" \
|
|
681
|
-
-d '...'
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
The HTTP plugin provides the same REST endpoints as Express but with zero external framework dependencies.
|
|
685
|
-
|
|
686
|
-
### Using Your REST API
|
|
687
|
-
|
|
688
|
-
Once you've set up either the Express or HTTP plugin, you can interact with your API using any HTTP client. Here are examples using curl that show the **full JSON:API compliant responses** you'll receive:
|
|
689
|
-
|
|
690
|
-
```bash
|
|
691
|
-
# Get all books
|
|
692
|
-
curl http://localhost:3000/api/books
|
|
693
|
-
|
|
694
|
-
# Returns:
|
|
695
|
-
{
|
|
696
|
-
"data": [
|
|
697
|
-
{
|
|
698
|
-
"type": "books",
|
|
699
|
-
"id": "1",
|
|
700
|
-
"attributes": {
|
|
701
|
-
"title": "The Great Gatsby",
|
|
702
|
-
"author": "F. Scott Fitzgerald",
|
|
703
|
-
"year": 1925
|
|
704
|
-
}
|
|
705
|
-
},
|
|
706
|
-
{
|
|
707
|
-
"type": "books",
|
|
708
|
-
"id": "2",
|
|
709
|
-
"attributes": {
|
|
710
|
-
"title": "1984",
|
|
711
|
-
"author": "George Orwell",
|
|
712
|
-
"year": 1949
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
]
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
# Get a single book
|
|
719
|
-
curl http://localhost:3000/api/books/1
|
|
720
|
-
|
|
721
|
-
# Returns:
|
|
722
|
-
{
|
|
723
|
-
"data": {
|
|
724
|
-
"type": "books",
|
|
725
|
-
"id": "1",
|
|
726
|
-
"attributes": {
|
|
727
|
-
"title": "The Great Gatsby",
|
|
728
|
-
"author": "F. Scott Fitzgerald",
|
|
729
|
-
"year": 1925
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
# Create a new book
|
|
735
|
-
curl -X POST http://localhost:3000/api/books \
|
|
736
|
-
-H "Content-Type: application/json" \
|
|
737
|
-
-d '{
|
|
738
|
-
"data": {
|
|
739
|
-
"type": "books",
|
|
740
|
-
"attributes": {
|
|
741
|
-
"title": "New Book",
|
|
742
|
-
"author": "New Author",
|
|
743
|
-
"year": 2024
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}'
|
|
747
|
-
|
|
748
|
-
# Returns (201 Created):
|
|
749
|
-
{
|
|
750
|
-
"data": {
|
|
751
|
-
"type": "books",
|
|
752
|
-
"id": "1234567890",
|
|
753
|
-
"attributes": {
|
|
754
|
-
"title": "New Book",
|
|
755
|
-
"author": "New Author",
|
|
756
|
-
"year": 2024
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
# Update a book
|
|
762
|
-
curl -X PATCH http://localhost:3000/api/books/1 \
|
|
763
|
-
-H "Content-Type: application/json" \
|
|
764
|
-
-d '{
|
|
765
|
-
"data": {
|
|
766
|
-
"type": "books",
|
|
767
|
-
"id": "1",
|
|
768
|
-
"attributes": {
|
|
769
|
-
"title": "Updated Title"
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
}'
|
|
773
|
-
|
|
774
|
-
# Returns:
|
|
775
|
-
{
|
|
776
|
-
"data": {
|
|
777
|
-
"type": "books",
|
|
778
|
-
"id": "1",
|
|
779
|
-
"attributes": {
|
|
780
|
-
"title": "Updated Title",
|
|
781
|
-
"author": "F. Scott Fitzgerald",
|
|
782
|
-
"year": 1925
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
# Delete a book
|
|
788
|
-
curl -X DELETE http://localhost:3000/api/books/1
|
|
789
|
-
|
|
790
|
-
# Returns: 204 No Content (empty response)
|
|
791
|
-
```
|
|
792
|
-
|
|
793
|
-
### File Upload Support in Connector Plugins
|
|
794
|
-
|
|
795
|
-
Both the Express and HTTP plugins provide support for the FileHandlingPlugin by:
|
|
796
|
-
|
|
797
|
-
1. **Providing raw request/response access**: The plugins add `_httpReq` and `_httpRes` parameters that the FileHandlingPlugin uses to detect and parse multipart uploads.
|
|
798
|
-
|
|
799
|
-
2. **Registering file detectors**: When file uploads are enabled, the connector plugins register appropriate file detectors with the REST API.
|
|
800
|
-
|
|
801
|
-
#### Configuration
|
|
802
|
-
|
|
803
|
-
##### Express Plugin
|
|
804
|
-
```javascript
|
|
805
|
-
import { Api } from 'hooked-api';
|
|
806
|
-
import { RestApiPlugin, ExpressPlugin } from 'json-rest-api';
|
|
807
|
-
|
|
808
|
-
const api = new Api({ name: 'my-api' });
|
|
809
|
-
await api.use(RestApiPlugin);
|
|
810
|
-
|
|
811
|
-
// Configure Express plugin with file upload options
|
|
812
|
-
await api.use(ExpressPlugin, {
|
|
813
|
-
enableFileUploads: true, // Enable file upload support (default: true)
|
|
814
|
-
fileParser: 'busboy', // Parser: 'busboy' or 'formidable'
|
|
815
|
-
fileParserOptions: { // Options passed to the file parser
|
|
816
|
-
limits: {
|
|
817
|
-
fileSize: 10 * 1024 * 1024, // 10MB file size limit
|
|
818
|
-
files: 5 // Max 5 files per request
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
});
|
|
822
|
-
```
|
|
823
|
-
|
|
824
|
-
##### HTTP Plugin
|
|
825
|
-
```javascript
|
|
826
|
-
import { Api } from 'hooked-api';
|
|
827
|
-
import { RestApiPlugin, HttpPlugin } from 'json-rest-api';
|
|
828
|
-
|
|
829
|
-
const api = new Api({ name: 'my-api' });
|
|
830
|
-
await api.use(RestApiPlugin);
|
|
831
|
-
|
|
832
|
-
// Configure HTTP plugin with file upload options
|
|
833
|
-
await api.use(HttpPlugin, {
|
|
834
|
-
enableFileUploads: true, // Enable file upload support (default: true)
|
|
835
|
-
fileParser: 'busboy', // Parser: 'busboy' or 'formidable'
|
|
836
|
-
fileParserOptions: { // Options passed to the file parser
|
|
837
|
-
limits: {
|
|
838
|
-
fileSize: 10 * 1024 * 1024, // 10MB file size limit
|
|
839
|
-
files: 5 // Max 5 files per request
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
});
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
#### File Parsers
|
|
846
|
-
|
|
847
|
-
Both plugins support the same file parsers:
|
|
848
|
-
|
|
849
|
-
- **busboy**: Streaming parser, memory efficient, good for large files
|
|
850
|
-
- **formidable**: Saves to temp files, includes progress tracking
|
|
851
|
-
|
|
852
|
-
**Installation**: File parsers must be installed separately:
|
|
853
|
-
```bash
|
|
854
|
-
# For busboy (recommended)
|
|
855
|
-
npm install busboy
|
|
856
|
-
|
|
857
|
-
# For formidable
|
|
858
|
-
npm install formidable
|
|
859
|
-
```
|
|
860
|
-
|
|
861
|
-
**Note**: File parsing requires the FileHandlingPlugin to be loaded. The connector plugins only provide the detection and parsing capabilities; the actual file handling logic is implemented by the FileHandlingPlugin.
|
|
862
|
-
|
|
863
|
-
## File Storage
|
|
864
|
-
|
|
865
|
-
The file handling system allows you to accept file uploads in your API. It consists of three parts:
|
|
866
|
-
|
|
867
|
-
1. The `FileHandlingPlugin` - orchestrates file processing
|
|
868
|
-
2. File detectors - parse multipart uploads (built into connector plugins)
|
|
869
|
-
3. Storage adapters - save files to disk or cloud storage
|
|
870
|
-
|
|
871
|
-
### Setting Up File Uploads
|
|
872
|
-
|
|
873
|
-
First, let's add file support to our books API. We'll allow users to upload book covers:
|
|
874
|
-
|
|
875
|
-
```javascript
|
|
876
|
-
import { Api } from 'hooked-api';
|
|
877
|
-
import { RestApiPlugin, FileHandlingPlugin, ExpressPlugin } from 'json-rest-api';
|
|
878
|
-
import { LocalStorage } from 'jsonrestapi/plugins/storage/local-storage.js';
|
|
879
|
-
import express from 'express';
|
|
880
|
-
|
|
881
|
-
const api = new Api({
|
|
882
|
-
name: 'my-library-api',
|
|
883
|
-
version: '1.0.0'
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
// Create a storage adapter
|
|
887
|
-
const coverStorage = new LocalStorage({
|
|
888
|
-
directory: './uploads/covers',
|
|
889
|
-
baseUrl: 'http://localhost:3000/uploads/covers'
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
// Add plugins (ORDER MATTERS!)
|
|
893
|
-
await api.use(RestApiPlugin);
|
|
894
|
-
await api.use(FileHandlingPlugin); // Must come after RestApiPlugin
|
|
895
|
-
await api.use(ExpressPlugin); // Must come after FileHandlingPlugin
|
|
896
|
-
|
|
897
|
-
// Update the books schema to include a cover image
|
|
898
|
-
api.addResource('books', {
|
|
899
|
-
schema: {
|
|
900
|
-
title: { type: 'string', required: true },
|
|
901
|
-
author: { type: 'string', required: true },
|
|
902
|
-
year: { type: 'number' },
|
|
903
|
-
isbn: { type: 'string' },
|
|
904
|
-
cover: {
|
|
905
|
-
type: 'file',
|
|
906
|
-
storage: coverStorage,
|
|
907
|
-
accepts: ['image/jpeg', 'image/png', 'image/gif'],
|
|
908
|
-
maxSize: '5mb'
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
// You'll need a storage plugin to handle the data
|
|
914
|
-
// Use the same inMemoryStoragePlugin from the Quick Start example
|
|
915
|
-
await api.use(inMemoryStoragePlugin);
|
|
916
|
-
|
|
917
|
-
// Create Express app
|
|
918
|
-
const app = express();
|
|
919
|
-
|
|
920
|
-
// Serve uploaded files
|
|
921
|
-
app.use('/uploads', express.static('./uploads'));
|
|
922
|
-
|
|
923
|
-
// Mount the API (both approaches work)
|
|
924
|
-
app.use(api.express.router); // Direct Express approach
|
|
925
|
-
// OR
|
|
926
|
-
api.express.mount(app); // Convenience method
|
|
927
|
-
|
|
928
|
-
app.listen(3000, () => {
|
|
929
|
-
console.log('API with file uploads running at http://localhost:3000/api');
|
|
930
|
-
});
|
|
931
|
-
```
|
|
932
|
-
|
|
933
|
-
### File Uploads with Express
|
|
934
|
-
|
|
935
|
-
To upload a file, you need to send a multipart/form-data request. Here's an example HTML form:
|
|
936
|
-
|
|
937
|
-
```html
|
|
938
|
-
<!DOCTYPE html>
|
|
939
|
-
<html>
|
|
940
|
-
<body>
|
|
941
|
-
<h2>Add a Book</h2>
|
|
942
|
-
<form action="http://localhost:3000/api/books" method="POST" enctype="multipart/form-data">
|
|
943
|
-
<label>Title: <input name="title" required></label><br>
|
|
944
|
-
<label>Author: <input name="author" required></label><br>
|
|
945
|
-
<label>Year: <input name="year" type="number"></label><br>
|
|
946
|
-
<label>Cover: <input name="cover" type="file" accept="image/*"></label><br>
|
|
947
|
-
<button type="submit">Add Book</button>
|
|
948
|
-
</form>
|
|
949
|
-
</body>
|
|
950
|
-
</html>
|
|
951
|
-
```
|
|
952
|
-
|
|
953
|
-
Or using curl:
|
|
954
|
-
|
|
955
|
-
```bash
|
|
956
|
-
curl -X POST http://localhost:3000/api/books \
|
|
957
|
-
-F "title=The Hobbit" \
|
|
958
|
-
-F "author=J.R.R. Tolkien" \
|
|
959
|
-
-F "year=1937" \
|
|
960
|
-
-F "cover=@/path/to/cover.jpg"
|
|
961
|
-
```
|
|
962
|
-
|
|
963
|
-
The file handling plugin will:
|
|
964
|
-
1. Detect the multipart upload
|
|
965
|
-
2. Parse the files using busboy (you'll need to install it: `npm install busboy`)
|
|
966
|
-
3. Validate file type and size according to your schema
|
|
967
|
-
4. Upload the file using the storage adapter
|
|
968
|
-
5. Replace the file field with the uploaded file's URL
|
|
969
|
-
6. Convert everything to JSON:API format
|
|
970
|
-
|
|
971
|
-
### Storage Adapters
|
|
972
|
-
|
|
973
|
-
#### LocalStorage
|
|
974
|
-
|
|
975
|
-
The LocalStorage adapter saves files to your local filesystem:
|
|
976
|
-
|
|
977
|
-
```javascript
|
|
978
|
-
import { LocalStorage } from 'jsonrestapi/plugins/storage/local-storage.js';
|
|
979
|
-
|
|
980
|
-
// Basic usage
|
|
981
|
-
const storage = new LocalStorage({
|
|
982
|
-
directory: './uploads',
|
|
983
|
-
baseUrl: 'http://localhost:3000/uploads'
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
// With security options
|
|
987
|
-
const secureStorage = new LocalStorage({
|
|
988
|
-
directory: './uploads',
|
|
989
|
-
baseUrl: 'http://localhost:3000/uploads',
|
|
990
|
-
nameStrategy: 'hash', // Generate random filenames
|
|
991
|
-
allowedExtensions: ['.jpg', '.jpeg', '.png', '.pdf'],
|
|
992
|
-
maxFilenameLength: 255
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
// Different naming strategies
|
|
996
|
-
const customStorage = new LocalStorage({
|
|
997
|
-
directory: './uploads',
|
|
998
|
-
baseUrl: 'http://localhost:3000/uploads',
|
|
999
|
-
nameStrategy: 'custom',
|
|
1000
|
-
nameGenerator: async (file) => {
|
|
1001
|
-
// Generate your own filename
|
|
1002
|
-
const timestamp = Date.now();
|
|
1003
|
-
const random = Math.random().toString(36).substring(7);
|
|
1004
|
-
return `book_cover_${timestamp}_${random}`;
|
|
1005
|
-
}
|
|
1006
|
-
});
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
#### S3Storage (Mock Implementation)
|
|
1010
|
-
|
|
1011
|
-
The package includes a mock S3 storage adapter for development:
|
|
1012
|
-
|
|
1013
|
-
```javascript
|
|
1014
|
-
import { S3Storage } from 'jsonrestapi/plugins/storage/s3-storage.js';
|
|
1015
|
-
|
|
1016
|
-
const s3Storage = new S3Storage({
|
|
1017
|
-
bucket: 'my-book-covers',
|
|
1018
|
-
region: 'us-east-1',
|
|
1019
|
-
prefix: 'covers/',
|
|
1020
|
-
mockMode: true // Currently only mock mode is implemented
|
|
1021
|
-
});
|
|
1022
|
-
```
|
|
1023
|
-
|
|
1024
|
-
> **Looking for a complete example with multiple files?** Check out our [Complete File Upload Guide](docs/mini-guides/file-uploads-complete.md) that shows multiple file fields, HTML forms, and static file serving.
|
|
1025
|
-
|
|
1026
|
-
## Transactions
|
|
1027
|
-
|
|
1028
|
-
The JSON REST API supports database transactions for atomic operations across multiple resources. This is essential for maintaining data consistency when creating or updating related resources.
|
|
1029
|
-
|
|
1030
|
-
### Basic Transaction Usage
|
|
1031
|
-
|
|
1032
|
-
```javascript
|
|
1033
|
-
// Manual transaction management
|
|
1034
|
-
const trx = await api.knex.instance.transaction();
|
|
1035
|
-
try {
|
|
1036
|
-
// All operations share the same transaction
|
|
1037
|
-
const author = await api.resources.people.post({
|
|
1038
|
-
transaction: trx,
|
|
1039
|
-
inputRecord: {
|
|
1040
|
-
data: {
|
|
1041
|
-
type: 'people',
|
|
1042
|
-
attributes: { name: 'New Author' }
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
const book = await api.resources.books.post({
|
|
1048
|
-
transaction: trx,
|
|
1049
|
-
inputRecord: {
|
|
1050
|
-
data: {
|
|
1051
|
-
type: 'books',
|
|
1052
|
-
attributes: { title: 'New Book' },
|
|
1053
|
-
relationships: {
|
|
1054
|
-
author: { data: { type: 'people', id: author.data.id } }
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
// Commit if everything succeeds
|
|
1061
|
-
await trx.commit();
|
|
1062
|
-
return { author, book };
|
|
1063
|
-
|
|
1064
|
-
} catch (error) {
|
|
1065
|
-
// Rollback on any error
|
|
1066
|
-
await trx.rollback();
|
|
1067
|
-
throw error;
|
|
1068
|
-
}
|
|
1069
|
-
```
|
|
1070
|
-
|
|
1071
|
-
### Automatic Transaction Handling
|
|
1072
|
-
|
|
1073
|
-
POST operations automatically use transactions when creating resources with many-to-many relationships:
|
|
1074
|
-
|
|
1075
|
-
```javascript
|
|
1076
|
-
// This single POST will use a transaction internally
|
|
1077
|
-
const book = await api.resources.books.post({
|
|
1078
|
-
inputRecord: {
|
|
1079
|
-
data: {
|
|
1080
|
-
type: 'books',
|
|
1081
|
-
attributes: { title: 'Tagged Book' },
|
|
1082
|
-
relationships: {
|
|
1083
|
-
tags: {
|
|
1084
|
-
data: [
|
|
1085
|
-
{ type: 'tags', id: '1' },
|
|
1086
|
-
{ type: 'tags', id: '2' }
|
|
1087
|
-
]
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
});
|
|
1093
|
-
// The book and pivot table entries are created atomically
|
|
1094
|
-
```
|
|
1095
|
-
|
|
1096
|
-
### Nested Transactions
|
|
1097
|
-
|
|
1098
|
-
The system intelligently handles nested transactions by reusing existing ones:
|
|
1099
|
-
|
|
1100
|
-
```javascript
|
|
1101
|
-
const trx = await api.knex.instance.transaction();
|
|
1102
|
-
try {
|
|
1103
|
-
// This POST will use the existing transaction
|
|
1104
|
-
const book = await api.resources.books.post({
|
|
1105
|
-
transaction: trx,
|
|
1106
|
-
inputRecord: {
|
|
1107
|
-
data: {
|
|
1108
|
-
type: 'books',
|
|
1109
|
-
attributes: { title: 'Book with Tags' },
|
|
1110
|
-
relationships: {
|
|
1111
|
-
tags: { data: [{ type: 'tags', id: '1' }] }
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
});
|
|
1116
|
-
// Even though POST creates its own transaction for many-to-many,
|
|
1117
|
-
// it detects and uses the existing one
|
|
1118
|
-
|
|
1119
|
-
await trx.commit();
|
|
1120
|
-
} catch (error) {
|
|
1121
|
-
await trx.rollback();
|
|
1122
|
-
}
|
|
1123
|
-
```
|
|
1124
|
-
|
|
1125
|
-
### Transaction Support Across Methods
|
|
1126
|
-
|
|
1127
|
-
All REST API methods support transactions:
|
|
1128
|
-
|
|
1129
|
-
```javascript
|
|
1130
|
-
const trx = await api.knex.instance.transaction();
|
|
1131
|
-
try {
|
|
1132
|
-
// Query with transaction
|
|
1133
|
-
const books = await api.resources.books.query({
|
|
1134
|
-
transaction: trx,
|
|
1135
|
-
queryParams: { filters: { status: 'draft' } }
|
|
1136
|
-
});
|
|
1137
|
-
|
|
1138
|
-
// Update with transaction
|
|
1139
|
-
await api.resources.books.patch({
|
|
1140
|
-
transaction: trx,
|
|
1141
|
-
id: '123',
|
|
1142
|
-
inputRecord: {
|
|
1143
|
-
data: {
|
|
1144
|
-
type: 'books',
|
|
1145
|
-
id: '123',
|
|
1146
|
-
attributes: { status: 'published' }
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
});
|
|
1150
|
-
|
|
1151
|
-
// Delete with transaction
|
|
1152
|
-
await api.resources.drafts.delete({
|
|
1153
|
-
transaction: trx,
|
|
1154
|
-
id: '456'
|
|
1155
|
-
});
|
|
1156
|
-
|
|
1157
|
-
await trx.commit();
|
|
1158
|
-
} catch (error) {
|
|
1159
|
-
await trx.rollback();
|
|
1160
|
-
}
|
|
1161
|
-
```
|
|
1162
|
-
|
|
1163
|
-
## Updating Resources with Relationships
|
|
1164
|
-
|
|
1165
|
-
The REST API plugin now fully supports updating relationships in PUT and PATCH operations according to JSON:API specifications.
|
|
1166
|
-
|
|
1167
|
-
### PUT - Complete Replacement
|
|
1168
|
-
|
|
1169
|
-
PUT replaces the entire resource, including all relationships. Any relationships not provided in the request will be removed.
|
|
1170
|
-
|
|
1171
|
-
```javascript
|
|
1172
|
-
// Example: Update an article with new author and categories
|
|
1173
|
-
const updatedArticle = await api.resources.articles.put({
|
|
1174
|
-
id: '123',
|
|
1175
|
-
inputRecord: {
|
|
1176
|
-
data: {
|
|
1177
|
-
type: 'articles',
|
|
1178
|
-
id: '123',
|
|
1179
|
-
attributes: {
|
|
1180
|
-
title: 'Updated Title',
|
|
1181
|
-
body: 'New content'
|
|
1182
|
-
},
|
|
1183
|
-
relationships: {
|
|
1184
|
-
// 1:1 relationship - will update author_id in the database
|
|
1185
|
-
author: {
|
|
1186
|
-
data: { type: 'users', id: '42' }
|
|
1187
|
-
},
|
|
1188
|
-
// n:n relationship - will replace ALL category associations
|
|
1189
|
-
categories: {
|
|
1190
|
-
data: [
|
|
1191
|
-
{ type: 'categories', id: '1' },
|
|
1192
|
-
{ type: 'categories', id: '3' }
|
|
1193
|
-
]
|
|
1194
|
-
}
|
|
1195
|
-
// Note: any other relationships not listed here will be cleared!
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
});
|
|
1200
|
-
```
|
|
1201
|
-
|
|
1202
|
-
### PATCH - Partial Update
|
|
1203
|
-
|
|
1204
|
-
PATCH only updates the fields and relationships explicitly provided, leaving everything else unchanged.
|
|
1205
|
-
|
|
1206
|
-
```javascript
|
|
1207
|
-
// Example: Update only the author, keeping categories unchanged
|
|
1208
|
-
const patchedArticle = await api.resources.articles.patch({
|
|
1209
|
-
id: '123',
|
|
1210
|
-
inputRecord: {
|
|
1211
|
-
data: {
|
|
1212
|
-
type: 'articles',
|
|
1213
|
-
id: '123',
|
|
1214
|
-
relationships: {
|
|
1215
|
-
// Only update the author relationship
|
|
1216
|
-
author: {
|
|
1217
|
-
data: { type: 'users', id: '99' }
|
|
1218
|
-
}
|
|
1219
|
-
// categories relationship remains untouched
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
// Example: Clear a relationship by setting it to null
|
|
1226
|
-
const clearedRelationship = await api.resources.articles.patch({
|
|
1227
|
-
id: '123',
|
|
1228
|
-
inputRecord: {
|
|
1229
|
-
data: {
|
|
1230
|
-
type: 'articles',
|
|
1231
|
-
id: '123',
|
|
1232
|
-
relationships: {
|
|
1233
|
-
reviewer: {
|
|
1234
|
-
data: null // Removes the reviewer
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
// Example: Update many-to-many relationship
|
|
1242
|
-
const updatedCategories = await api.resources.articles.patch({
|
|
1243
|
-
id: '123',
|
|
1244
|
-
inputRecord: {
|
|
1245
|
-
data: {
|
|
1246
|
-
type: 'articles',
|
|
1247
|
-
id: '123',
|
|
1248
|
-
relationships: {
|
|
1249
|
-
// This will replace the categories for this article
|
|
1250
|
-
categories: {
|
|
1251
|
-
data: [
|
|
1252
|
-
{ type: 'categories', id: '5' },
|
|
1253
|
-
{ type: 'categories', id: '7' },
|
|
1254
|
-
{ type: 'categories', id: '9' }
|
|
1255
|
-
]
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
});
|
|
1261
|
-
```
|
|
1262
|
-
|
|
1263
|
-
### Transaction Support for Relationship Updates
|
|
1264
|
-
|
|
1265
|
-
All relationship updates are performed within the same transaction as the main record update, ensuring data consistency:
|
|
1266
|
-
|
|
1267
|
-
```javascript
|
|
1268
|
-
// This entire operation is atomic
|
|
1269
|
-
const result = await api.resources.articles.put({
|
|
1270
|
-
id: '123',
|
|
1271
|
-
inputRecord: {
|
|
1272
|
-
data: {
|
|
1273
|
-
type: 'articles',
|
|
1274
|
-
id: '123',
|
|
1275
|
-
attributes: {
|
|
1276
|
-
title: 'New Title',
|
|
1277
|
-
status: 'published'
|
|
1278
|
-
},
|
|
1279
|
-
relationships: {
|
|
1280
|
-
author: {
|
|
1281
|
-
data: { type: 'users', id: '10' }
|
|
1282
|
-
},
|
|
1283
|
-
categories: {
|
|
1284
|
-
data: [
|
|
1285
|
-
{ type: 'categories', id: '1' },
|
|
1286
|
-
{ type: 'categories', id: '2' }
|
|
1287
|
-
]
|
|
1288
|
-
},
|
|
1289
|
-
tags: {
|
|
1290
|
-
data: [
|
|
1291
|
-
{ type: 'tags', id: '100' },
|
|
1292
|
-
{ type: 'tags', id: '101' }
|
|
1293
|
-
]
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
});
|
|
1299
|
-
// If any part fails, everything is rolled back
|
|
1300
|
-
```
|
|
5
|
+
[Official Website](https://mobily-enterprises.github.io/json-rest-api/)
|
|
1301
6
|
|
|
1302
|
-
##
|
|
7
|
+
## Features
|
|
1303
8
|
|
|
1304
|
-
|
|
9
|
+
* **JSON:API Compliant** - Full support for the JSON:API specification
|
|
10
|
+
* **Relationship Support** - `belongsTo`, `hasMany`, and many-to-many relationships, including polymorphic
|
|
11
|
+
* **Advanced Querying** - Filtering, sorting, pagination, and field selection (sparse fieldsets)
|
|
12
|
+
* **File Uploads** - Built-in support for file handling with multiple storage adapters (local, S3)
|
|
13
|
+
* **Framework Agnostic** - Works with raw Node.js HTTP, Express, and other Node.js frameworks via flexible connectors
|
|
14
|
+
* **Validation** - Schema-based validation with detailed error messages and custom rules
|
|
15
|
+
* **Simplified Mode** - A developer-friendly option to work with plain JavaScript objects instead of verbose JSON:API structure
|
|
16
|
+
* **Extensible** - Built on `hooked-api`'s powerful plugin and hook system for deep customization
|
|
1305
17
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
First, install the MySQL driver for Knex:
|
|
1309
|
-
|
|
1310
|
-
```bash
|
|
1311
|
-
npm install mysql2
|
|
1312
|
-
```
|
|
1313
|
-
|
|
1314
|
-
### Basic MySQL Configuration
|
|
1315
|
-
|
|
1316
|
-
```javascript
|
|
1317
|
-
import { Api } from 'hooked-api';
|
|
1318
|
-
import { RestApiPlugin, RestApiKnexPlugin, HttpPlugin } from 'json-rest-api';
|
|
1319
|
-
import knex from 'knex';
|
|
1320
|
-
|
|
1321
|
-
// Create a Knex instance with MySQL configuration
|
|
1322
|
-
const db = knex({
|
|
1323
|
-
client: 'mysql2',
|
|
1324
|
-
connection: {
|
|
1325
|
-
host: 'localhost',
|
|
1326
|
-
port: 3306,
|
|
1327
|
-
user: 'your_database_user',
|
|
1328
|
-
password: 'your_database_password',
|
|
1329
|
-
database: 'your_database_name'
|
|
1330
|
-
},
|
|
1331
|
-
pool: {
|
|
1332
|
-
min: 2,
|
|
1333
|
-
max: 10
|
|
1334
|
-
}
|
|
1335
|
-
});
|
|
1336
|
-
|
|
1337
|
-
// Create the API
|
|
1338
|
-
const api = new Api({ name: 'my-api' });
|
|
1339
|
-
|
|
1340
|
-
// Install plugins
|
|
1341
|
-
await api.use(RestApiPlugin);
|
|
1342
|
-
await api.use(RestApiKnexPlugin, { knex: db });
|
|
1343
|
-
await api.use(HttpPlugin);
|
|
1344
|
-
|
|
1345
|
-
// The Knex instance is now available as api.knex for direct queries
|
|
1346
|
-
console.log('Database connected:', await api.knex.raw('SELECT 1+1 AS result'));
|
|
1347
|
-
|
|
1348
|
-
// Add a sample resource
|
|
1349
|
-
api.addResource('users', {
|
|
1350
|
-
schema: {
|
|
1351
|
-
id: { type: 'id' },
|
|
1352
|
-
name: { type: 'string', required: true },
|
|
1353
|
-
email: { type: 'string', required: true }
|
|
1354
|
-
}
|
|
1355
|
-
});
|
|
1356
|
-
|
|
1357
|
-
// Create the users table if it doesn't exist
|
|
1358
|
-
await api.knex.schema.createTableIfNotExists('users', (table) => {
|
|
1359
|
-
table.increments('id');
|
|
1360
|
-
table.string('name');
|
|
1361
|
-
table.string('email');
|
|
1362
|
-
table.timestamps(true, true);
|
|
1363
|
-
});
|
|
1364
|
-
|
|
1365
|
-
// Start the HTTP server
|
|
1366
|
-
api.http.startServer();
|
|
1367
|
-
console.log('API server running at http://localhost:3000/api');
|
|
1368
|
-
console.log('Try: curl http://localhost:3000/api/users');
|
|
1369
|
-
```
|
|
1370
|
-
|
|
1371
|
-
### Testing the API with curl
|
|
1372
|
-
|
|
1373
|
-
Once your server is running, you can test it with these curl commands:
|
|
18
|
+
## Installation
|
|
1374
19
|
|
|
1375
20
|
```bash
|
|
1376
|
-
|
|
1377
|
-
curl http://localhost:3000/api/users
|
|
1378
|
-
|
|
1379
|
-
# Create a new user
|
|
1380
|
-
curl -X POST http://localhost:3000/api/users \
|
|
1381
|
-
-H "Content-Type: application/json" \
|
|
1382
|
-
-d '{
|
|
1383
|
-
"data": {
|
|
1384
|
-
"type": "users",
|
|
1385
|
-
"attributes": {
|
|
1386
|
-
"name": "John Doe",
|
|
1387
|
-
"email": "john@example.com"
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
}'
|
|
1391
|
-
|
|
1392
|
-
# Get all users again (should show the created user)
|
|
1393
|
-
curl http://localhost:3000/api/users
|
|
1394
|
-
|
|
1395
|
-
# Get a specific user
|
|
1396
|
-
curl http://localhost:3000/api/users/1
|
|
1397
|
-
|
|
1398
|
-
# Update a user
|
|
1399
|
-
curl -X PATCH http://localhost:3000/api/users/1 \
|
|
1400
|
-
-H "Content-Type: application/json" \
|
|
1401
|
-
-d '{
|
|
1402
|
-
"data": {
|
|
1403
|
-
"type": "users",
|
|
1404
|
-
"id": "1",
|
|
1405
|
-
"attributes": {
|
|
1406
|
-
"name": "John Smith"
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
}'
|
|
1410
|
-
|
|
1411
|
-
# Delete a user
|
|
1412
|
-
curl -X DELETE http://localhost:3000/api/users/1
|
|
1413
|
-
```
|
|
1414
|
-
|
|
1415
|
-
### Using Environment Variables
|
|
1416
|
-
|
|
1417
|
-
For production deployments, use environment variables to store sensitive credentials:
|
|
1418
|
-
|
|
1419
|
-
```javascript
|
|
1420
|
-
// Load environment variables (using dotenv package)
|
|
1421
|
-
import dotenv from 'dotenv';
|
|
1422
|
-
dotenv.config();
|
|
1423
|
-
|
|
1424
|
-
const db = knex({
|
|
1425
|
-
client: 'mysql2',
|
|
1426
|
-
connection: {
|
|
1427
|
-
host: process.env.DB_HOST || 'localhost',
|
|
1428
|
-
port: process.env.DB_PORT || 3306,
|
|
1429
|
-
user: process.env.DB_USER,
|
|
1430
|
-
password: process.env.DB_PASSWORD,
|
|
1431
|
-
database: process.env.DB_NAME
|
|
1432
|
-
},
|
|
1433
|
-
pool: {
|
|
1434
|
-
min: 2,
|
|
1435
|
-
max: 10
|
|
1436
|
-
}
|
|
1437
|
-
});
|
|
1438
|
-
```
|
|
1439
|
-
|
|
1440
|
-
Create a `.env` file (remember to add it to `.gitignore`):
|
|
1441
|
-
|
|
1442
|
-
```
|
|
1443
|
-
DB_HOST=localhost
|
|
1444
|
-
DB_PORT=3306
|
|
1445
|
-
DB_USER=myapp_user
|
|
1446
|
-
DB_PASSWORD=secret_password
|
|
1447
|
-
DB_NAME=myapp_db
|
|
1448
|
-
```
|
|
1449
|
-
|
|
1450
|
-
### Connection URL Format
|
|
1451
|
-
|
|
1452
|
-
You can also use a connection URL:
|
|
1453
|
-
|
|
1454
|
-
```javascript
|
|
1455
|
-
const db = knex({
|
|
1456
|
-
client: 'mysql2',
|
|
1457
|
-
connection: process.env.DATABASE_URL || 'mysql://user:pass@localhost:3306/dbname'
|
|
1458
|
-
});
|
|
1459
|
-
```
|
|
1460
|
-
|
|
1461
|
-
### Advanced MySQL Configuration
|
|
1462
|
-
|
|
1463
|
-
For production environments, consider these additional options:
|
|
1464
|
-
|
|
1465
|
-
```javascript
|
|
1466
|
-
const db = knex({
|
|
1467
|
-
client: 'mysql2',
|
|
1468
|
-
connection: {
|
|
1469
|
-
host: process.env.DB_HOST,
|
|
1470
|
-
port: process.env.DB_PORT,
|
|
1471
|
-
user: process.env.DB_USER,
|
|
1472
|
-
password: process.env.DB_PASSWORD,
|
|
1473
|
-
database: process.env.DB_NAME,
|
|
1474
|
-
ssl: {
|
|
1475
|
-
// For MySQL servers with SSL/TLS
|
|
1476
|
-
rejectUnauthorized: true,
|
|
1477
|
-
ca: fs.readFileSync('path/to/ca.pem')
|
|
1478
|
-
},
|
|
1479
|
-
timezone: 'UTC',
|
|
1480
|
-
charset: 'utf8mb4',
|
|
1481
|
-
connectTimeout: 60000, // 60 seconds
|
|
1482
|
-
stringifyObjects: false,
|
|
1483
|
-
multipleStatements: true
|
|
1484
|
-
},
|
|
1485
|
-
pool: {
|
|
1486
|
-
min: 2,
|
|
1487
|
-
max: 10,
|
|
1488
|
-
createTimeoutMillis: 30000,
|
|
1489
|
-
acquireTimeoutMillis: 30000,
|
|
1490
|
-
idleTimeoutMillis: 30000,
|
|
1491
|
-
reapIntervalMillis: 1000,
|
|
1492
|
-
createRetryIntervalMillis: 100
|
|
1493
|
-
},
|
|
1494
|
-
acquireConnectionTimeout: 60000,
|
|
1495
|
-
debug: process.env.NODE_ENV === 'development'
|
|
1496
|
-
});
|
|
1497
|
-
```
|
|
1498
|
-
|
|
1499
|
-
### Creating Tables
|
|
1500
|
-
|
|
1501
|
-
Once connected, you can create tables using Knex migrations or directly:
|
|
1502
|
-
|
|
1503
|
-
```javascript
|
|
1504
|
-
// Create a books table
|
|
1505
|
-
await api.knex.schema.createTable('books', (table) => {
|
|
1506
|
-
table.increments('id').primary();
|
|
1507
|
-
table.string('title', 255).notNullable();
|
|
1508
|
-
table.string('author', 255).notNullable();
|
|
1509
|
-
table.integer('year').unsigned();
|
|
1510
|
-
table.string('isbn', 20).unique();
|
|
1511
|
-
table.text('description');
|
|
1512
|
-
table.decimal('price', 10, 2);
|
|
1513
|
-
table.boolean('in_stock').defaultTo(true);
|
|
1514
|
-
table.timestamps(true, true); // created_at, updated_at
|
|
1515
|
-
|
|
1516
|
-
// Indexes for better query performance
|
|
1517
|
-
table.index(['author']);
|
|
1518
|
-
table.index(['year']);
|
|
1519
|
-
});
|
|
21
|
+
npm install json-rest-api hooked-api json-rest-schema knex sqlite3
|
|
1520
22
|
```
|
|
1521
23
|
|
|
1522
|
-
|
|
24
|
+
> **Note:** `json-rest-schema` and a database driver like `knex` and `sqlite3` are peer dependencies, meaning you need to install them explicitly. This gives you control over your database choice.
|
|
1523
25
|
|
|
1524
|
-
|
|
26
|
+
## Quick Start
|
|
1525
27
|
|
|
1526
28
|
```javascript
|
|
1527
29
|
import { Api } from 'hooked-api';
|
|
1528
|
-
import { RestApiPlugin
|
|
30
|
+
import { RestApiPlugin } from 'json-rest-api/plugins/core/rest-api-plugin.js';
|
|
31
|
+
import { RestApiKnexPlugin } from 'json-rest-api/plugins/core/rest-api-knex-plugin.js';
|
|
32
|
+
import { ExpressPlugin } from 'json-rest-api/plugins/core/connectors/express-plugin.js';
|
|
33
|
+
import express from 'express';
|
|
1529
34
|
import knex from 'knex';
|
|
1530
|
-
import dotenv from 'dotenv';
|
|
1531
35
|
|
|
1532
|
-
//
|
|
1533
|
-
|
|
36
|
+
// Create API instance
|
|
37
|
+
const api = new Api({ name: 'my-api' });
|
|
1534
38
|
|
|
1535
|
-
//
|
|
39
|
+
// Set up database
|
|
1536
40
|
const db = knex({
|
|
1537
|
-
client: '
|
|
1538
|
-
connection: {
|
|
1539
|
-
|
|
1540
|
-
port: process.env.DB_PORT || 3306,
|
|
1541
|
-
user: process.env.DB_USER || 'root',
|
|
1542
|
-
password: process.env.DB_PASSWORD || '',
|
|
1543
|
-
database: process.env.DB_NAME || 'bookstore'
|
|
1544
|
-
},
|
|
1545
|
-
pool: { min: 2, max: 10 }
|
|
41
|
+
client: 'sqlite3',
|
|
42
|
+
connection: { filename: ':memory:' },
|
|
43
|
+
useNullAsDefault: true
|
|
1546
44
|
});
|
|
1547
45
|
|
|
1548
|
-
// Create API instance
|
|
1549
|
-
const api = new Api({ name: 'bookstore-api' });
|
|
1550
|
-
|
|
1551
46
|
// Install plugins
|
|
1552
47
|
await api.use(RestApiPlugin);
|
|
1553
48
|
await api.use(RestApiKnexPlugin, { knex: db });
|
|
1554
|
-
await api.use(
|
|
1555
|
-
|
|
1556
|
-
// Define the books resource with filtering
|
|
1557
|
-
api.addResource('books', {
|
|
1558
|
-
schema: {
|
|
1559
|
-
id: { type: 'id' },
|
|
1560
|
-
title: {
|
|
1561
|
-
type: 'string',
|
|
1562
|
-
required: true,
|
|
1563
|
-
search: { filterUsing: 'like' } // Enable LIKE filtering
|
|
1564
|
-
},
|
|
1565
|
-
author: {
|
|
1566
|
-
type: 'string',
|
|
1567
|
-
required: true,
|
|
1568
|
-
search: true // Enable exact match filtering
|
|
1569
|
-
},
|
|
1570
|
-
year: {
|
|
1571
|
-
type: 'number',
|
|
1572
|
-
search: {
|
|
1573
|
-
year_min: { filterUsing: '>=' },
|
|
1574
|
-
year_max: { filterUsing: '<=' }
|
|
1575
|
-
}
|
|
1576
|
-
},
|
|
1577
|
-
price: {
|
|
1578
|
-
type: 'decimal',
|
|
1579
|
-
search: true
|
|
1580
|
-
},
|
|
1581
|
-
in_stock: {
|
|
1582
|
-
type: 'boolean',
|
|
1583
|
-
search: true
|
|
1584
|
-
}
|
|
1585
|
-
},
|
|
1586
|
-
sortableFields: ['title', 'author', 'year', 'price'],
|
|
1587
|
-
tableName: 'books' // Explicitly set table name (optional)
|
|
1588
|
-
});
|
|
1589
|
-
|
|
1590
|
-
// Start the HTTP server
|
|
1591
|
-
api.http.startServer(3000);
|
|
1592
|
-
console.log('API running at http://localhost:3000/api');
|
|
1593
|
-
|
|
1594
|
-
// Test the database connection
|
|
1595
|
-
try {
|
|
1596
|
-
await db.raw('SELECT 1+1 AS result');
|
|
1597
|
-
console.log('Database connected successfully');
|
|
1598
|
-
} catch (error) {
|
|
1599
|
-
console.error('Database connection failed:', error);
|
|
1600
|
-
}
|
|
1601
|
-
```
|
|
1602
|
-
|
|
1603
|
-
### Troubleshooting MySQL Connections
|
|
1604
|
-
|
|
1605
|
-
Common issues and solutions:
|
|
1606
|
-
|
|
1607
|
-
1. **Authentication Error**: If you get "ER_NOT_SUPPORTED_AUTH_MODE", your MySQL 8.0 server might be using the newer authentication plugin. Fix:
|
|
1608
|
-
```sql
|
|
1609
|
-
ALTER USER 'your_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';
|
|
1610
|
-
FLUSH PRIVILEGES;
|
|
1611
|
-
```
|
|
1612
|
-
|
|
1613
|
-
2. **Connection Timeout**: Increase the `connectTimeout` in your configuration.
|
|
1614
|
-
|
|
1615
|
-
3. **SSL/TLS Issues**: For cloud MySQL instances (AWS RDS, Google Cloud SQL), you may need to configure SSL:
|
|
1616
|
-
```javascript
|
|
1617
|
-
connection: {
|
|
1618
|
-
// ... other config
|
|
1619
|
-
ssl: {
|
|
1620
|
-
rejectUnauthorized: true,
|
|
1621
|
-
ca: fs.readFileSync('path/to/server-ca.pem')
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
```
|
|
1625
|
-
|
|
1626
|
-
4. **Character Set Issues**: Use `utf8mb4` for full Unicode support including emojis:
|
|
1627
|
-
```javascript
|
|
1628
|
-
connection: {
|
|
1629
|
-
// ... other config
|
|
1630
|
-
charset: 'utf8mb4'
|
|
1631
|
-
}
|
|
1632
|
-
```
|
|
1633
|
-
|
|
1634
|
-
### Direct Database Access
|
|
1635
|
-
|
|
1636
|
-
The plugin exposes the Knex instance as `api.knex`, allowing you to run complex queries directly:
|
|
1637
|
-
|
|
1638
|
-
```javascript
|
|
1639
|
-
// Complex query with joins
|
|
1640
|
-
const results = await api.knex('books')
|
|
1641
|
-
.join('authors', 'books.author_id', 'authors.id')
|
|
1642
|
-
.select('books.*', 'authors.name as author_name')
|
|
1643
|
-
.where('books.year', '>', 2020)
|
|
1644
|
-
.orderBy('books.created_at', 'desc')
|
|
1645
|
-
.limit(10);
|
|
1646
|
-
|
|
1647
|
-
// Raw SQL queries
|
|
1648
|
-
const stats = await api.knex.raw(`
|
|
1649
|
-
SELECT
|
|
1650
|
-
COUNT(*) as total_books,
|
|
1651
|
-
AVG(price) as avg_price,
|
|
1652
|
-
MAX(price) as max_price
|
|
1653
|
-
FROM books
|
|
1654
|
-
WHERE in_stock = ?
|
|
1655
|
-
`, [true]);
|
|
1656
|
-
|
|
1657
|
-
// Transactions
|
|
1658
|
-
await api.knex.transaction(async (trx) => {
|
|
1659
|
-
const [authorId] = await trx('authors').insert({ name: 'New Author' });
|
|
1660
|
-
await trx('books').insert({
|
|
1661
|
-
title: 'New Book',
|
|
1662
|
-
author_id: authorId,
|
|
1663
|
-
year: 2024
|
|
1664
|
-
});
|
|
1665
|
-
});
|
|
1666
|
-
```
|
|
1667
|
-
|
|
1668
|
-
With this configuration, your REST API will automatically use MySQL for all database operations, with full support for filtering, sorting, and pagination as described in the previous sections.
|
|
1669
|
-
|
|
1670
|
-
### Advanced Filtering with Hooks
|
|
1671
|
-
|
|
1672
|
-
The REST API Knex Plugin supports extensible filtering through hooks. When adding custom filters, always use grouping to prevent accidental security bypasses:
|
|
1673
|
-
|
|
1674
|
-
```javascript
|
|
1675
|
-
// Add a tenant isolation filter
|
|
1676
|
-
api.addHook('knexQueryFiltering', 'tenantFilter', { order: -100 }, ({ query }) => {
|
|
1677
|
-
query.where(function() {
|
|
1678
|
-
this.where('tenant_id', getCurrentTenant());
|
|
1679
|
-
this.where('deleted_at', null);
|
|
1680
|
-
});
|
|
1681
|
-
});
|
|
1682
|
-
|
|
1683
|
-
// Add a complex search filter
|
|
1684
|
-
api.addHook('knexQueryFiltering', 'searchFilter', {}, ({ query, filters }) => {
|
|
1685
|
-
if (filters.search) {
|
|
1686
|
-
query.where(function() {
|
|
1687
|
-
const term = `%${filters.search}%`;
|
|
1688
|
-
this.where('title', 'like', term)
|
|
1689
|
-
.orWhere('description', 'like', term)
|
|
1690
|
-
.orWhere('tags', 'like', term);
|
|
1691
|
-
});
|
|
1692
|
-
}
|
|
1693
|
-
});
|
|
1694
|
-
|
|
1695
|
-
// Add region-based filtering
|
|
1696
|
-
api.addHook('knexQueryFiltering', 'regionFilter', {}, ({ query }) => {
|
|
1697
|
-
query.where(function() {
|
|
1698
|
-
const userRegion = getUserRegion();
|
|
1699
|
-
// Show items from user's region, global items, or items without a region
|
|
1700
|
-
this.where('region', userRegion)
|
|
1701
|
-
.orWhere('region', 'global')
|
|
1702
|
-
.orWhereNull('region');
|
|
1703
|
-
});
|
|
1704
|
-
});
|
|
1705
|
-
```
|
|
1706
|
-
|
|
1707
|
-
These filters will be combined with AND, ensuring that security filters cannot be bypassed:
|
|
1708
|
-
- `WHERE (tenant_id = 123 AND deleted_at IS NULL) AND (title LIKE '%search%' OR description LIKE '%search%' OR tags LIKE '%search%') AND (region = 'US' OR region = 'global' OR region IS NULL)`
|
|
1709
|
-
|
|
1710
|
-
**Important**: Always wrap your filter conditions in `query.where(function() { ... })` to ensure proper grouping. This prevents OR conditions from accidentally bypassing other filters.
|
|
1711
|
-
|
|
1712
|
-
## Many-to-Many Relationships
|
|
1713
|
-
|
|
1714
|
-
Many-to-many relationships require a pivot table. In the JSON REST API system, the pivot table is treated as a full resource with its own schema and endpoints.
|
|
1715
|
-
|
|
1716
|
-
### Understanding Relationships and Including Related Data
|
|
1717
|
-
|
|
1718
|
-
Before diving into many-to-many relationships, it's important to understand how relationships work in the JSON REST API system.
|
|
1719
|
-
|
|
1720
|
-
#### Relationship Types
|
|
1721
|
-
|
|
1722
|
-
The system supports three types of relationships:
|
|
1723
|
-
|
|
1724
|
-
1. **belongsTo** (Many-to-One) - A foreign key relationship where a resource belongs to another resource
|
|
1725
|
-
2. **hasMany** (One-to-Many) - The inverse of belongsTo, where a resource has many related resources
|
|
1726
|
-
3. **hasMany with through** (Many-to-Many) - A relationship through a pivot/junction table
|
|
1727
|
-
|
|
1728
|
-
#### Including Related Resources with the Include Parameter
|
|
1729
|
-
|
|
1730
|
-
You can fetch related resources in a single request using the `include` query parameter. This follows the JSON:API specification and helps prevent N+1 query problems.
|
|
1731
|
-
|
|
1732
|
-
```javascript
|
|
1733
|
-
// Example: Get a book with its author included
|
|
1734
|
-
GET /api/books/1?include=author
|
|
1735
|
-
|
|
1736
|
-
// Example: Get a book with nested includes
|
|
1737
|
-
GET /api/books/1?include=author.company,reviews.reviewer
|
|
1738
|
-
```
|
|
1739
|
-
|
|
1740
|
-
#### Defining Relationships
|
|
1741
|
-
|
|
1742
|
-
##### For belongsTo Relationships
|
|
1743
|
-
|
|
1744
|
-
```javascript
|
|
1745
|
-
api.addResource('books', {
|
|
1746
|
-
schema: {
|
|
1747
|
-
author_id: {
|
|
1748
|
-
belongsTo: 'authors',
|
|
1749
|
-
as: 'author'
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
});
|
|
1753
|
-
|
|
1754
|
-
// This allows:
|
|
1755
|
-
// GET /api/books?include=author
|
|
1756
|
-
```
|
|
1757
|
-
|
|
1758
|
-
##### For hasMany Relationships
|
|
1759
|
-
|
|
1760
|
-
```javascript
|
|
1761
|
-
api.addResource('authors', {
|
|
1762
|
-
schema: {
|
|
1763
|
-
id: { type: 'id' },
|
|
1764
|
-
name: { type: 'string' }
|
|
1765
|
-
},
|
|
1766
|
-
relationships: {
|
|
1767
|
-
books: {
|
|
1768
|
-
hasMany: 'books',
|
|
1769
|
-
foreignKey: 'author_id'
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
});
|
|
1773
|
-
|
|
1774
|
-
// This allows:
|
|
1775
|
-
// GET /api/authors/1?include=books
|
|
1776
|
-
```
|
|
1777
|
-
|
|
1778
|
-
##### For Many-to-Many Relationships
|
|
1779
|
-
|
|
1780
|
-
Many-to-many relationships use `hasMany` with `through` to specify the pivot table:
|
|
1781
|
-
|
|
1782
|
-
```javascript
|
|
1783
|
-
api.addResource('books', {
|
|
1784
|
-
schema: {
|
|
1785
|
-
id: { type: 'id' },
|
|
1786
|
-
title: { type: 'string' }
|
|
1787
|
-
},
|
|
1788
|
-
relationships: {
|
|
1789
|
-
tags: {
|
|
1790
|
-
hasMany: 'tags',
|
|
1791
|
-
through: 'book_tags', // Pivot table
|
|
1792
|
-
foreignKey: 'book_id', // Key for this resource
|
|
1793
|
-
otherKey: 'tag_id' // Key for related resource
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
});
|
|
1797
|
-
|
|
1798
|
-
// This allows:
|
|
1799
|
-
// GET /api/books/1?include=tags
|
|
1800
|
-
```
|
|
1801
|
-
|
|
1802
|
-
#### Important Notes on Including Relationships
|
|
1803
|
-
|
|
1804
|
-
1. **Performance Consideration**: Including many related resources can impact performance. Be mindful when including hasMany relationships.
|
|
1805
|
-
|
|
1806
|
-
2. **Nested Includes**: You can include nested relationships using dot notation:
|
|
1807
|
-
```
|
|
1808
|
-
GET /api/books/1?include=author.company,reviews.reviewer.department
|
|
1809
|
-
```
|
|
1810
|
-
|
|
1811
|
-
3. **Sparse Fieldsets**: Combine includes with sparse fieldsets to fetch only needed fields:
|
|
1812
|
-
```
|
|
1813
|
-
GET /api/books/1?include=author&fields[authors]=name,email
|
|
1814
|
-
```
|
|
1815
|
-
|
|
1816
|
-
4. **Cross-Table Search**: You can define searchSchema fields that reference related tables:
|
|
1817
|
-
```javascript
|
|
1818
|
-
api.addResource('books', {
|
|
1819
|
-
schema: { /* ... */ },
|
|
1820
|
-
searchSchema: {
|
|
1821
|
-
authorName: {
|
|
1822
|
-
type: 'string',
|
|
1823
|
-
actualField: 'authors.name', // Reference field in related table
|
|
1824
|
-
filterUsing: 'like'
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
});
|
|
1828
|
-
```
|
|
1829
|
-
|
|
1830
|
-
### Books with Multiple Authors Example
|
|
1831
|
-
|
|
1832
|
-
Let's update our books example to support multiple authors. Previously, books had a single `author` field. Now we'll create a many-to-many relationship between `books` and `people` (authors).
|
|
1833
|
-
|
|
1834
|
-
#### Step 1: Define the Pivot Resource
|
|
1835
|
-
|
|
1836
|
-
```javascript
|
|
1837
|
-
// Define the book_authors pivot table as a resource
|
|
1838
|
-
api.addResource('book_authors', {
|
|
1839
|
-
schema: {
|
|
1840
|
-
id: { type: 'id' },
|
|
1841
|
-
book_id: {
|
|
1842
|
-
type: 'number',
|
|
1843
|
-
belongsTo: 'books',
|
|
1844
|
-
as: 'book'
|
|
1845
|
-
},
|
|
1846
|
-
author_id: {
|
|
1847
|
-
type: 'number',
|
|
1848
|
-
belongsTo: 'people',
|
|
1849
|
-
as: 'author'
|
|
1850
|
-
},
|
|
1851
|
-
author_order: { type: 'number' }, // Display order for multiple authors
|
|
1852
|
-
contribution_type: { type: 'string' } // 'primary', 'co-author', 'contributor'
|
|
1853
|
-
},
|
|
1854
|
-
searchSchema: {
|
|
1855
|
-
// Search by book title
|
|
1856
|
-
bookTitle: {
|
|
1857
|
-
type: 'string',
|
|
1858
|
-
actualField: 'books.title',
|
|
1859
|
-
filterUsing: 'like'
|
|
1860
|
-
},
|
|
1861
|
-
// Search by author name
|
|
1862
|
-
authorName: {
|
|
1863
|
-
type: 'string',
|
|
1864
|
-
actualField: 'people.name',
|
|
1865
|
-
filterUsing: 'like'
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
});
|
|
1869
|
-
```
|
|
1870
|
-
|
|
1871
|
-
#### Step 2: Update Books and People Resources
|
|
1872
|
-
|
|
1873
|
-
```javascript
|
|
1874
|
-
// Update books resource (remove single author field)
|
|
1875
|
-
api.addResource('books', {
|
|
1876
|
-
schema: {
|
|
1877
|
-
id: { type: 'id' },
|
|
1878
|
-
title: { type: 'string', required: true, indexed: true },
|
|
1879
|
-
year: { type: 'number' },
|
|
1880
|
-
isbn: { type: 'string' }
|
|
1881
|
-
// Note: removed 'author' field - now using many-to-many
|
|
1882
|
-
},
|
|
1883
|
-
relationships: {
|
|
1884
|
-
authors: {
|
|
1885
|
-
hasMany: 'book_authors',
|
|
1886
|
-
foreignKey: 'book_id',
|
|
1887
|
-
as: 'authors'
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
});
|
|
1891
|
-
|
|
1892
|
-
// Define people resource
|
|
1893
|
-
api.addResource('people', {
|
|
1894
|
-
schema: {
|
|
1895
|
-
id: { type: 'id' },
|
|
1896
|
-
name: { type: 'string', required: true, indexed: true },
|
|
1897
|
-
email: { type: 'string' }
|
|
1898
|
-
},
|
|
1899
|
-
relationships: {
|
|
1900
|
-
books: {
|
|
1901
|
-
hasMany: 'book_authors',
|
|
1902
|
-
foreignKey: 'author_id',
|
|
1903
|
-
as: 'books'
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
});
|
|
1907
|
-
```
|
|
1908
|
-
|
|
1909
|
-
### Using Many-to-Many Relationships
|
|
1910
|
-
|
|
1911
|
-
#### NEW: Creating Books with Authors in One Request
|
|
1912
|
-
|
|
1913
|
-
With the enhanced POST implementation, you can now create a book and establish many-to-many relationships in a single atomic request:
|
|
1914
|
-
|
|
1915
|
-
```javascript
|
|
1916
|
-
// First, define the pivot table resource (with searchable fields for updates)
|
|
1917
|
-
api.addResource('book_tags', {
|
|
1918
|
-
schema: {
|
|
1919
|
-
id: { type: 'id' },
|
|
1920
|
-
book_id: { type: 'number', required: true, search: true },
|
|
1921
|
-
tag_id: { type: 'number', required: true, search: true }
|
|
1922
|
-
}
|
|
1923
|
-
// Note: You can also define these in a separate searchSchema instead:
|
|
1924
|
-
// searchSchema: {
|
|
1925
|
-
// book_id: { type: 'number' },
|
|
1926
|
-
// tag_id: { type: 'number' }
|
|
1927
|
-
// }
|
|
1928
|
-
});
|
|
1929
|
-
|
|
1930
|
-
// Then define the tags resource
|
|
1931
|
-
api.addResource('tags', {
|
|
1932
|
-
schema: {
|
|
1933
|
-
id: { type: 'id' },
|
|
1934
|
-
name: { type: 'string', required: true }
|
|
1935
|
-
}
|
|
1936
|
-
});
|
|
1937
|
-
|
|
1938
|
-
// Finally, define the books resource with the many-to-many relationship
|
|
1939
|
-
api.addResource('books', {
|
|
1940
|
-
schema: {
|
|
1941
|
-
id: { type: 'id' },
|
|
1942
|
-
title: { type: 'string', required: true },
|
|
1943
|
-
year: { type: 'number' }
|
|
1944
|
-
},
|
|
1945
|
-
relationships: {
|
|
1946
|
-
tags: {
|
|
1947
|
-
hasMany: 'tags',
|
|
1948
|
-
through: 'book_tags', // Pivot table resource
|
|
1949
|
-
foreignKey: 'book_id', // Key for this resource
|
|
1950
|
-
otherKey: 'tag_id' // Key for related resource
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
// Create book with tags in one request
|
|
1956
|
-
const response = await api.resources.books.post({
|
|
1957
|
-
inputRecord: {
|
|
1958
|
-
"data": {
|
|
1959
|
-
"type": "books",
|
|
1960
|
-
"attributes": {
|
|
1961
|
-
"title": "Good Omens",
|
|
1962
|
-
"year": 1990
|
|
1963
|
-
},
|
|
1964
|
-
"relationships": {
|
|
1965
|
-
"tags": {
|
|
1966
|
-
"data": [
|
|
1967
|
-
{ "type": "tags", "id": "1" }, // Must be existing tags
|
|
1968
|
-
{ "type": "tags", "id": "2" }
|
|
1969
|
-
]
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1974
|
-
});
|
|
1975
|
-
```
|
|
1976
|
-
|
|
1977
|
-
This will atomically:
|
|
1978
|
-
1. Create the book
|
|
1979
|
-
2. Create entries in the `book_tags` pivot table
|
|
1980
|
-
3. Return the book with its relationships
|
|
1981
|
-
|
|
1982
|
-
#### Traditional Method: Creating Relationships Separately
|
|
1983
|
-
|
|
1984
|
-
Alternatively, you can still use the traditional approach:
|
|
1985
|
-
|
|
1986
|
-
```bash
|
|
1987
|
-
# First, create a book
|
|
1988
|
-
curl -X POST http://localhost:3000/api/books \
|
|
1989
|
-
-H "Content-Type: application/json" \
|
|
1990
|
-
-d '{
|
|
1991
|
-
"data": {
|
|
1992
|
-
"type": "books",
|
|
1993
|
-
"attributes": {
|
|
1994
|
-
"title": "Good Omens",
|
|
1995
|
-
"year": 1990
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
}'
|
|
1999
|
-
|
|
2000
|
-
# Then link authors to the book via pivot table
|
|
2001
|
-
curl -X POST http://localhost:3000/api/book_authors \
|
|
2002
|
-
-H "Content-Type: application/json" \
|
|
2003
|
-
-d '{
|
|
2004
|
-
"data": {
|
|
2005
|
-
"type": "book_authors",
|
|
2006
|
-
"attributes": {
|
|
2007
|
-
"author_order": 1,
|
|
2008
|
-
"contribution_type": "co-author"
|
|
2009
|
-
},
|
|
2010
|
-
"relationships": {
|
|
2011
|
-
"book": { "data": { "type": "books", "id": "1" } },
|
|
2012
|
-
"author": { "data": { "type": "people", "id": "1" } }
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
}'
|
|
2016
|
-
```
|
|
2017
|
-
|
|
2018
|
-
#### Querying Books with Authors
|
|
2019
|
-
|
|
2020
|
-
```bash
|
|
2021
|
-
# Get a book with all its authors
|
|
2022
|
-
curl "http://localhost:3000/api/books/1?include=authors.author"
|
|
2023
|
-
|
|
2024
|
-
# Search for books by author name
|
|
2025
|
-
curl "http://localhost:3000/api/book_authors?filter[authorName]=Fitzgerald&include=book"
|
|
2026
|
-
|
|
2027
|
-
# Get all books by a specific author
|
|
2028
|
-
curl "http://localhost:3000/api/people/1?include=books.book"
|
|
2029
|
-
```
|
|
2030
|
-
|
|
2031
|
-
#### Example Response
|
|
2032
|
-
|
|
2033
|
-
```json
|
|
2034
|
-
{
|
|
2035
|
-
"data": {
|
|
2036
|
-
"type": "books",
|
|
2037
|
-
"id": "1",
|
|
2038
|
-
"attributes": {
|
|
2039
|
-
"title": "Good Omens",
|
|
2040
|
-
"year": 1990
|
|
2041
|
-
},
|
|
2042
|
-
"relationships": {
|
|
2043
|
-
"authors": {
|
|
2044
|
-
"data": [
|
|
2045
|
-
{ "type": "book_authors", "id": "1" },
|
|
2046
|
-
{ "type": "book_authors", "id": "2" }
|
|
2047
|
-
]
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
},
|
|
2051
|
-
"included": [
|
|
2052
|
-
{
|
|
2053
|
-
"type": "book_authors",
|
|
2054
|
-
"id": "1",
|
|
2055
|
-
"attributes": {
|
|
2056
|
-
"author_order": 1,
|
|
2057
|
-
"contribution_type": "co-author"
|
|
2058
|
-
},
|
|
2059
|
-
"relationships": {
|
|
2060
|
-
"author": { "data": { "type": "people", "id": "10" } }
|
|
2061
|
-
}
|
|
2062
|
-
},
|
|
2063
|
-
{
|
|
2064
|
-
"type": "book_authors",
|
|
2065
|
-
"id": "2",
|
|
2066
|
-
"attributes": {
|
|
2067
|
-
"author_order": 2,
|
|
2068
|
-
"contribution_type": "co-author"
|
|
2069
|
-
},
|
|
2070
|
-
"relationships": {
|
|
2071
|
-
"author": { "data": { "type": "people", "id": "11" } }
|
|
2072
|
-
}
|
|
2073
|
-
},
|
|
2074
|
-
{
|
|
2075
|
-
"type": "people",
|
|
2076
|
-
"id": "10",
|
|
2077
|
-
"attributes": { "name": "Terry Pratchett" }
|
|
2078
|
-
},
|
|
2079
|
-
{
|
|
2080
|
-
"type": "people",
|
|
2081
|
-
"id": "11",
|
|
2082
|
-
"attributes": { "name": "Neil Gaiman" }
|
|
2083
|
-
}
|
|
2084
|
-
]
|
|
2085
|
-
}
|
|
2086
|
-
```
|
|
2087
|
-
|
|
2088
|
-
## Polymorphic Relationships
|
|
2089
|
-
|
|
2090
|
-
Polymorphic relationships allow a model to belong to multiple other models using a single association.
|
|
2091
|
-
|
|
2092
|
-
### Comments Example
|
|
49
|
+
await api.use(ExpressPlugin, { app: express() });
|
|
2093
50
|
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
body: { type: 'string', required: true },
|
|
2103
|
-
user_id: {
|
|
2104
|
-
type: 'number',
|
|
2105
|
-
belongsTo: 'people',
|
|
2106
|
-
as: 'author'
|
|
2107
|
-
},
|
|
2108
|
-
// Polymorphic relationship - defines both the relationship and the fields
|
|
2109
|
-
commentable: {
|
|
2110
|
-
belongsToPolymorphic: {
|
|
2111
|
-
types: ['books', 'articles'],
|
|
2112
|
-
typeField: 'commentable_type',
|
|
2113
|
-
idField: 'commentable_id'
|
|
2114
|
-
},
|
|
2115
|
-
as: 'commentable'
|
|
2116
|
-
}
|
|
2117
|
-
},
|
|
2118
|
-
searchSchema: {
|
|
2119
|
-
// Search by title of the commented item
|
|
2120
|
-
commentableTitle: {
|
|
2121
|
-
type: 'string',
|
|
2122
|
-
filterUsing: 'like',
|
|
2123
|
-
polymorphicField: 'commentable',
|
|
2124
|
-
targetFields: {
|
|
2125
|
-
books: 'title',
|
|
2126
|
-
articles: 'title'
|
|
51
|
+
// Define a resource
|
|
52
|
+
await api.addScope('articles', {
|
|
53
|
+
restApi: {
|
|
54
|
+
schema: {
|
|
55
|
+
attributes: {
|
|
56
|
+
title: { type: 'string', required: true },
|
|
57
|
+
content: { type: 'string' },
|
|
58
|
+
publishedAt: { type: 'string', format: 'date-time' }
|
|
2127
59
|
}
|
|
2128
60
|
}
|
|
2129
61
|
}
|
|
2130
62
|
});
|
|
2131
63
|
|
|
2132
|
-
//
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
},
|
|
2137
|
-
relationships: {
|
|
2138
|
-
// ... existing relationships ...
|
|
2139
|
-
comments: {
|
|
2140
|
-
hasMany: 'comments',
|
|
2141
|
-
via: 'commentable',
|
|
2142
|
-
as: 'comments'
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
|
-
|
|
2147
|
-
// Define articles resource
|
|
2148
|
-
api.addResource('articles', {
|
|
2149
|
-
schema: {
|
|
2150
|
-
id: { type: 'id' },
|
|
2151
|
-
title: { type: 'string', required: true, indexed: true },
|
|
2152
|
-
content: { type: 'text' }
|
|
2153
|
-
},
|
|
2154
|
-
relationships: {
|
|
2155
|
-
comments: {
|
|
2156
|
-
hasMany: 'comments',
|
|
2157
|
-
via: 'commentable',
|
|
2158
|
-
as: 'comments'
|
|
2159
|
-
}
|
|
2160
|
-
}
|
|
2161
|
-
});
|
|
2162
|
-
```
|
|
2163
|
-
|
|
2164
|
-
### Using Polymorphic Relationships
|
|
2165
|
-
|
|
2166
|
-
#### Creating Comments
|
|
2167
|
-
|
|
2168
|
-
```bash
|
|
2169
|
-
# Comment on a book
|
|
2170
|
-
curl -X POST http://localhost:3000/api/comments \
|
|
2171
|
-
-H "Content-Type: application/json" \
|
|
2172
|
-
-d '{
|
|
2173
|
-
"data": {
|
|
2174
|
-
"type": "comments",
|
|
2175
|
-
"attributes": {
|
|
2176
|
-
"body": "This book changed my perspective!"
|
|
2177
|
-
},
|
|
2178
|
-
"relationships": {
|
|
2179
|
-
"author": { "data": { "type": "people", "id": "1" } },
|
|
2180
|
-
"commentable": { "data": { "type": "books", "id": "1" } }
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
}'
|
|
64
|
+
// Mount Express routes
|
|
65
|
+
const app = express();
|
|
66
|
+
api.http.express.mount(app);
|
|
67
|
+
app.listen(3000);
|
|
2184
68
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
-d '{
|
|
2189
|
-
"data": {
|
|
2190
|
-
"type": "comments",
|
|
2191
|
-
"attributes": {
|
|
2192
|
-
"body": "Great article!"
|
|
2193
|
-
},
|
|
2194
|
-
"relationships": {
|
|
2195
|
-
"author": { "data": { "type": "people", "id": "1" } },
|
|
2196
|
-
"commentable": { "data": { "type": "articles", "id": "5" } }
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
}'
|
|
69
|
+
// Your API is ready! Try:
|
|
70
|
+
// GET http://localhost:3000/api/articles
|
|
71
|
+
// POST http://localhost:3000/api/articles
|
|
2200
72
|
```
|
|
2201
73
|
|
|
2202
|
-
|
|
74
|
+
## Documentation
|
|
2203
75
|
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
curl "http://localhost:3000/api/comments?filter[commentableTitle]=REST"
|
|
76
|
+
### Core Guides
|
|
77
|
+
- [Initial Setup Guide](README_1_Initial_Setup.md) - Complete walkthrough for getting started
|
|
78
|
+
- [Computed and Hidden Fields](README_2_Computed_and_hidden_fields.md) - Advanced field handling
|
|
79
|
+
- [Hook System Guide](README_X_Hooks.md) - Extend and customize API behavior
|
|
80
|
+
- [API Tutorial](docs/GUIDE.md) - Step-by-step tutorial
|
|
2210
81
|
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
82
|
+
### Feature Guides
|
|
83
|
+
- [Permissions & Authentication](README_X_Permissions.md) - JWT auth and declarative permissions
|
|
84
|
+
- [CORS Configuration](README_X_Cors.md) - Cross-origin resource sharing setup
|
|
85
|
+
- [Relationships](README_X_Relationship_Plugin.md) - Working with related data
|
|
86
|
+
- [Bulk Operations](README_X_Bulk_Operations.md) - Efficient batch processing
|
|
87
|
+
- [File Uploads](docs/fileUploads.md) - Handling file uploads
|
|
88
|
+
- [WebSocket Support](README_X_SocketIO.md) - Real-time updates with Socket.IO
|
|
2214
89
|
|
|
2215
|
-
|
|
90
|
+
## License
|
|
2216
91
|
|
|
2217
|
-
|
|
2218
|
-
{
|
|
2219
|
-
"data": [
|
|
2220
|
-
{
|
|
2221
|
-
"type": "comments",
|
|
2222
|
-
"id": "1",
|
|
2223
|
-
"attributes": {
|
|
2224
|
-
"body": "Great book!"
|
|
2225
|
-
},
|
|
2226
|
-
"relationships": {
|
|
2227
|
-
"commentable": { "data": { "type": "books", "id": "1" } }
|
|
2228
|
-
}
|
|
2229
|
-
},
|
|
2230
|
-
{
|
|
2231
|
-
"type": "comments",
|
|
2232
|
-
"id": "2",
|
|
2233
|
-
"attributes": {
|
|
2234
|
-
"body": "Interesting article!"
|
|
2235
|
-
},
|
|
2236
|
-
"relationships": {
|
|
2237
|
-
"commentable": { "data": { "type": "articles", "id": "5" } }
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
],
|
|
2241
|
-
"included": [
|
|
2242
|
-
{
|
|
2243
|
-
"type": "books",
|
|
2244
|
-
"id": "1",
|
|
2245
|
-
"attributes": { "title": "The Great Gatsby" }
|
|
2246
|
-
},
|
|
2247
|
-
{
|
|
2248
|
-
"type": "articles",
|
|
2249
|
-
"id": "5",
|
|
2250
|
-
"attributes": { "title": "Understanding REST APIs" }
|
|
2251
|
-
}
|
|
2252
|
-
]
|
|
2253
|
-
}
|
|
2254
|
-
```
|
|
92
|
+
MIT
|
|
2255
93
|
|
|
2256
|
-
### How Polymorphic Search Works
|
|
2257
94
|
|
|
2258
|
-
The system generates conditional JOINs for polymorphic searches:
|
|
2259
95
|
|
|
2260
|
-
```sql
|
|
2261
|
-
-- GET /comments?filter[commentableTitle]=gatsby
|
|
2262
|
-
SELECT comments.*
|
|
2263
|
-
FROM comments
|
|
2264
|
-
LEFT JOIN books AS comments_commentable_books
|
|
2265
|
-
ON comments.commentable_type = 'books'
|
|
2266
|
-
AND comments.commentable_id = comments_commentable_books.id
|
|
2267
|
-
LEFT JOIN articles AS comments_commentable_articles
|
|
2268
|
-
ON comments.commentable_type = 'articles'
|
|
2269
|
-
AND comments.commentable_id = comments_commentable_articles.id
|
|
2270
|
-
WHERE (
|
|
2271
|
-
(comments.commentable_type = 'books'
|
|
2272
|
-
AND comments_commentable_books.title LIKE '%gatsby%')
|
|
2273
|
-
OR
|
|
2274
|
-
(comments.commentable_type = 'articles'
|
|
2275
|
-
AND comments_commentable_articles.title LIKE '%gatsby%')
|
|
2276
|
-
)
|
|
2277
|
-
```
|