json-rest-api 1.0.6 → 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 +99 -33
- 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
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
# 2.7 Field Transformations
|
|
2
|
+
|
|
3
|
+
The JSON REST API library provides a comprehensive system for transforming data as it flows through your application. This chapter covers all the ways you can transform, compute, and control field visibility in your API.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Field transformations allow you to:
|
|
8
|
+
- Accept temporary data that isn't stored (virtual fields)
|
|
9
|
+
- Transform data before storing it (setters)
|
|
10
|
+
- Transform data when retrieving it (getters)
|
|
11
|
+
- Calculate values from other fields (computed fields)
|
|
12
|
+
- Control which fields are visible in responses (hidden fields)
|
|
13
|
+
|
|
14
|
+
## The Data Transformation Pipeline
|
|
15
|
+
|
|
16
|
+
Understanding when each transformation occurs is crucial for building robust APIs:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
INPUT FLOW (POST/PUT/PATCH):
|
|
20
|
+
┌─────────────┐ ┌──────────────────┐ ┌─────────┐ ┌──────────┐
|
|
21
|
+
│ User Input │ --> │ Validate Schema │ --> │ Setters │ --> │ Database │
|
|
22
|
+
│ (JSON) │ │ (including virt) │ │ │ │ (no virt)│
|
|
23
|
+
└─────────────┘ └──────────────────┘ └─────────┘ └──────────┘
|
|
24
|
+
↓
|
|
25
|
+
Virtual fields validated
|
|
26
|
+
and preserved for response
|
|
27
|
+
|
|
28
|
+
OUTPUT FLOW (GET):
|
|
29
|
+
┌──────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────┐
|
|
30
|
+
│ Database │ --> │ Getters │ --> │ Computed │ --> │ Merge Virtual & │ --> │ Response │
|
|
31
|
+
│ │ │ │ │ Fields │ │ Apply Hidden │ │ (JSON) │
|
|
32
|
+
└──────────┘ └─────────┘ └──────────────┘ └─────────────────┘ └──────────┘
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Virtual Fields
|
|
36
|
+
|
|
37
|
+
Virtual fields are fields that pass through the API but are never stored in the database. They're perfect for temporary data needed during request processing.
|
|
38
|
+
|
|
39
|
+
### Defining Virtual Fields
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
await api.addResource('users', {
|
|
43
|
+
schema: {
|
|
44
|
+
username: { type: 'string', required: true },
|
|
45
|
+
email: { type: 'string', required: true },
|
|
46
|
+
password: { type: 'string', required: true, hidden: true },
|
|
47
|
+
|
|
48
|
+
// Virtual fields - not stored in database
|
|
49
|
+
passwordConfirmation: { type: 'string', virtual: true },
|
|
50
|
+
termsAccepted: { type: 'boolean', virtual: true },
|
|
51
|
+
captchaToken: { type: 'string', virtual: true }
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Common Use Cases
|
|
57
|
+
|
|
58
|
+
1. **Password Confirmation**
|
|
59
|
+
```javascript
|
|
60
|
+
// Client sends:
|
|
61
|
+
{
|
|
62
|
+
"username": "john",
|
|
63
|
+
"password": "secret123",
|
|
64
|
+
"passwordConfirmation": "secret123" // Virtual field
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Use in a hook to validate:
|
|
68
|
+
api.on('beforeData:create:users', ({ inputRecord }) => {
|
|
69
|
+
const { password, passwordConfirmation } = inputRecord.data.attributes;
|
|
70
|
+
if (password !== passwordConfirmation) {
|
|
71
|
+
throw new Error('Passwords do not match');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
2. **Terms Acceptance**
|
|
77
|
+
```javascript
|
|
78
|
+
api.on('beforeData:create:users', ({ inputRecord }) => {
|
|
79
|
+
if (!inputRecord.data.attributes.termsAccepted) {
|
|
80
|
+
throw new Error('You must accept the terms of service');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
3. **UI State or Metadata**
|
|
86
|
+
```javascript
|
|
87
|
+
// Client can send UI state that's returned but not stored
|
|
88
|
+
{
|
|
89
|
+
"title": "My Article",
|
|
90
|
+
"content": "...",
|
|
91
|
+
"editorState": { ... }, // Virtual field with editor metadata
|
|
92
|
+
"isDraft": true // Virtual field for UI state
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Key Characteristics
|
|
97
|
+
|
|
98
|
+
- **Input**: Accepted in POST/PUT/PATCH requests
|
|
99
|
+
- **Validation**: Validated according to schema rules
|
|
100
|
+
- **Storage**: Never stored in the database
|
|
101
|
+
- **Output**: Returned in responses if provided
|
|
102
|
+
- **Hooks**: Available to all hooks during request processing
|
|
103
|
+
|
|
104
|
+
## Setters and Getters
|
|
105
|
+
|
|
106
|
+
Setters and getters transform data at the database boundary. Setters run before saving, getters run after loading.
|
|
107
|
+
|
|
108
|
+
### Setters - Transform Before Storage
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
await api.addResource('users', {
|
|
112
|
+
schema: {
|
|
113
|
+
email: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
required: true,
|
|
116
|
+
setter: (value) => value.toLowerCase().trim()
|
|
117
|
+
},
|
|
118
|
+
phone: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
setter: (value) => {
|
|
121
|
+
// Remove all non-digits
|
|
122
|
+
return value ? value.replace(/\D/g, '') : null;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
metadata: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
setter: (value) => JSON.stringify(value || {})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Getters - Transform After Retrieval
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
await api.addResource('users', {
|
|
137
|
+
schema: {
|
|
138
|
+
email: {
|
|
139
|
+
type: 'string',
|
|
140
|
+
getter: (value) => value?.toLowerCase()
|
|
141
|
+
},
|
|
142
|
+
phone: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
getter: (value) => {
|
|
145
|
+
// Format as (XXX) XXX-XXXX
|
|
146
|
+
if (!value || value.length !== 10) return value;
|
|
147
|
+
return `(${value.slice(0,3)}) ${value.slice(3,6)}-${value.slice(6)}`;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
metadata: {
|
|
151
|
+
type: 'string',
|
|
152
|
+
getter: (value) => {
|
|
153
|
+
try {
|
|
154
|
+
return value ? JSON.parse(value) : {};
|
|
155
|
+
} catch {
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Using Virtual Fields with Setters
|
|
165
|
+
|
|
166
|
+
A common pattern is using virtual fields to provide data that setters will process:
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
await api.addResource('products', {
|
|
170
|
+
schema: {
|
|
171
|
+
price: {
|
|
172
|
+
type: 'number',
|
|
173
|
+
setter: function(value, { attributes }) {
|
|
174
|
+
// Use virtual priceInCents if provided
|
|
175
|
+
if (attributes.priceInCents !== undefined) {
|
|
176
|
+
return attributes.priceInCents / 100;
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
priceInCents: { type: 'number', virtual: true }
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Client can send either format:
|
|
186
|
+
// { "price": 19.99 }
|
|
187
|
+
// OR
|
|
188
|
+
// { "priceInCents": 1999 }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Async Setters and Getters
|
|
192
|
+
|
|
193
|
+
Both setters and getters can be async:
|
|
194
|
+
|
|
195
|
+
```javascript
|
|
196
|
+
await api.addResource('secure_data', {
|
|
197
|
+
schema: {
|
|
198
|
+
secret: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
setter: async (value) => {
|
|
201
|
+
// Encrypt before storing
|
|
202
|
+
const encrypted = await encrypt(value);
|
|
203
|
+
return encrypted;
|
|
204
|
+
},
|
|
205
|
+
getter: async (value) => {
|
|
206
|
+
// Decrypt after retrieving
|
|
207
|
+
const decrypted = await decrypt(value);
|
|
208
|
+
return decrypted;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Setter and Getter Context
|
|
216
|
+
|
|
217
|
+
Both functions receive a context object as the second parameter:
|
|
218
|
+
|
|
219
|
+
```javascript
|
|
220
|
+
setter: (value, context) => {
|
|
221
|
+
// context contains:
|
|
222
|
+
// - attributes: all field values
|
|
223
|
+
// - record: the full record (on updates)
|
|
224
|
+
// - scopeName: resource name
|
|
225
|
+
// - method: 'post', 'put', or 'patch'
|
|
226
|
+
|
|
227
|
+
if (context.method === 'post') {
|
|
228
|
+
// Special handling for creation
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Computed Fields
|
|
235
|
+
|
|
236
|
+
Computed fields are output-only fields calculated from other fields. They're never stored and always calculated fresh when requested.
|
|
237
|
+
|
|
238
|
+
### Basic Computed Fields
|
|
239
|
+
|
|
240
|
+
```javascript
|
|
241
|
+
await api.addResource('products', {
|
|
242
|
+
schema: {
|
|
243
|
+
name: { type: 'string', required: true },
|
|
244
|
+
price: { type: 'number', required: true },
|
|
245
|
+
cost: { type: 'number', required: true, normallyHidden: true },
|
|
246
|
+
|
|
247
|
+
// Computed fields
|
|
248
|
+
profitMargin: {
|
|
249
|
+
type: 'number',
|
|
250
|
+
computed: true,
|
|
251
|
+
dependencies: ['price', 'cost'],
|
|
252
|
+
compute: ({ attributes }) => {
|
|
253
|
+
if (!attributes.price || !attributes.cost) return null;
|
|
254
|
+
return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
displayName: {
|
|
259
|
+
type: 'string',
|
|
260
|
+
computed: true,
|
|
261
|
+
dependencies: ['name', 'price'],
|
|
262
|
+
compute: ({ attributes }) => {
|
|
263
|
+
return `${attributes.name} - $${attributes.price}`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Dependencies and Hidden Fields
|
|
271
|
+
|
|
272
|
+
Computed fields can depend on hidden fields:
|
|
273
|
+
|
|
274
|
+
```javascript
|
|
275
|
+
await api.addResource('users', {
|
|
276
|
+
schema: {
|
|
277
|
+
firstName: { type: 'string' },
|
|
278
|
+
lastName: { type: 'string' },
|
|
279
|
+
dateOfBirth: { type: 'date', normallyHidden: true },
|
|
280
|
+
|
|
281
|
+
fullName: {
|
|
282
|
+
type: 'string',
|
|
283
|
+
computed: true,
|
|
284
|
+
dependencies: ['firstName', 'lastName'],
|
|
285
|
+
compute: ({ attributes }) => {
|
|
286
|
+
return [attributes.firstName, attributes.lastName]
|
|
287
|
+
.filter(Boolean)
|
|
288
|
+
.join(' ');
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
age: {
|
|
293
|
+
type: 'number',
|
|
294
|
+
computed: true,
|
|
295
|
+
dependencies: ['dateOfBirth'], // Depends on hidden field
|
|
296
|
+
compute: ({ attributes }) => {
|
|
297
|
+
if (!attributes.dateOfBirth) return null;
|
|
298
|
+
const birth = new Date(attributes.dateOfBirth);
|
|
299
|
+
const today = new Date();
|
|
300
|
+
let age = today.getFullYear() - birth.getFullYear();
|
|
301
|
+
const monthDiff = today.getMonth() - birth.getMonth();
|
|
302
|
+
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
|
303
|
+
age--;
|
|
304
|
+
}
|
|
305
|
+
return age;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Async Computed Fields
|
|
313
|
+
|
|
314
|
+
```javascript
|
|
315
|
+
await api.addResource('products', {
|
|
316
|
+
schema: {
|
|
317
|
+
id: { type: 'id' },
|
|
318
|
+
name: { type: 'string' },
|
|
319
|
+
|
|
320
|
+
inventoryStatus: {
|
|
321
|
+
type: 'string',
|
|
322
|
+
computed: true,
|
|
323
|
+
dependencies: ['id'],
|
|
324
|
+
compute: async ({ attributes, context }) => {
|
|
325
|
+
// Could check external inventory system
|
|
326
|
+
const count = await context.knex('inventory')
|
|
327
|
+
.where('product_id', attributes.id)
|
|
328
|
+
.sum('quantity as total')
|
|
329
|
+
.first();
|
|
330
|
+
|
|
331
|
+
if (count.total > 100) return 'In Stock';
|
|
332
|
+
if (count.total > 0) return 'Low Stock';
|
|
333
|
+
return 'Out of Stock';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Compute Function Context
|
|
341
|
+
|
|
342
|
+
The compute function receives a rich context:
|
|
343
|
+
|
|
344
|
+
```javascript
|
|
345
|
+
compute: (context) => {
|
|
346
|
+
// context contains:
|
|
347
|
+
// - attributes: all record attributes (including dependencies)
|
|
348
|
+
// - record: full record with id
|
|
349
|
+
// - context: request context with knex, transaction, etc.
|
|
350
|
+
// - helpers: API helpers
|
|
351
|
+
// - api: full API instance
|
|
352
|
+
// - scopeName: current resource name
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Important Notes on Computed Fields
|
|
357
|
+
|
|
358
|
+
1. **Output Only**: If a computed field is sent in input, it's ignored with a warning
|
|
359
|
+
2. **Always Fresh**: Calculated on every request (no caching)
|
|
360
|
+
3. **Dependencies**: The system automatically fetches dependency fields from the database
|
|
361
|
+
4. **Sparse Fieldsets**: Work seamlessly with JSON:API sparse fieldsets
|
|
362
|
+
5. **Performance**: Keep computations fast as they run on every request
|
|
363
|
+
|
|
364
|
+
## Hidden Fields
|
|
365
|
+
|
|
366
|
+
Control which fields are visible in API responses:
|
|
367
|
+
|
|
368
|
+
### Hidden Fields - Never Visible
|
|
369
|
+
|
|
370
|
+
```javascript
|
|
371
|
+
await api.addResource('users', {
|
|
372
|
+
schema: {
|
|
373
|
+
email: { type: 'string', required: true },
|
|
374
|
+
passwordHash: { type: 'string', hidden: true },
|
|
375
|
+
salt: { type: 'string', hidden: true },
|
|
376
|
+
internalNotes: { type: 'string', hidden: true }
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// These fields are NEVER returned in responses, even if explicitly requested
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Normally Hidden Fields - Available on Request
|
|
384
|
+
|
|
385
|
+
```javascript
|
|
386
|
+
await api.addResource('products', {
|
|
387
|
+
schema: {
|
|
388
|
+
name: { type: 'string', required: true },
|
|
389
|
+
price: { type: 'number', required: true },
|
|
390
|
+
cost: { type: 'number', normallyHidden: true },
|
|
391
|
+
supplierCode: { type: 'string', normallyHidden: true }
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Hidden by default
|
|
396
|
+
GET /products/1
|
|
397
|
+
// Returns: { id: '1', name: 'Widget', price: 99.99 }
|
|
398
|
+
|
|
399
|
+
// Explicitly request hidden fields
|
|
400
|
+
GET /products/1?fields[products]=name,price,cost
|
|
401
|
+
// Returns: { id: '1', name: 'Widget', price: 99.99, cost: 45.00 }
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Advanced Transformations with Hooks
|
|
405
|
+
|
|
406
|
+
For complex transformations that depend on context (user permissions, time of day, etc.), use the `enrichAttributes` hook:
|
|
407
|
+
|
|
408
|
+
```javascript
|
|
409
|
+
// Add permission-based field visibility
|
|
410
|
+
api.on('enrichAttributes', ({ attributes, context }) => {
|
|
411
|
+
if (context.user?.role !== 'admin') {
|
|
412
|
+
delete attributes.profitMargin;
|
|
413
|
+
delete attributes.cost;
|
|
414
|
+
}
|
|
415
|
+
return attributes;
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Add dynamic computed fields
|
|
419
|
+
api.on('enrichAttributes', ({ attributes, context }) => {
|
|
420
|
+
if (context.scopeName === 'products') {
|
|
421
|
+
attributes.isOnSale = attributes.price < attributes.regularPrice;
|
|
422
|
+
}
|
|
423
|
+
return attributes;
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
For more details on hooks, see the [Hooks Documentation](./GUIDE_3_Hooks.md).
|
|
428
|
+
|
|
429
|
+
## Complete Example: E-commerce Product
|
|
430
|
+
|
|
431
|
+
Here's a complete example showing all transformation types working together:
|
|
432
|
+
|
|
433
|
+
```javascript
|
|
434
|
+
await api.addResource('products', {
|
|
435
|
+
schema: {
|
|
436
|
+
// Regular fields
|
|
437
|
+
sku: { type: 'string', required: true },
|
|
438
|
+
name: { type: 'string', required: true },
|
|
439
|
+
description: { type: 'string' },
|
|
440
|
+
|
|
441
|
+
// Price with setter for cents conversion
|
|
442
|
+
price: {
|
|
443
|
+
type: 'number',
|
|
444
|
+
required: true,
|
|
445
|
+
setter: function(value, { attributes }) {
|
|
446
|
+
// Accept price in cents via virtual field
|
|
447
|
+
if (attributes.priceInCents !== undefined) {
|
|
448
|
+
return attributes.priceInCents / 100;
|
|
449
|
+
}
|
|
450
|
+
return value;
|
|
451
|
+
},
|
|
452
|
+
getter: (value) => Number(value.toFixed(2))
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// Hidden cost field
|
|
456
|
+
cost: {
|
|
457
|
+
type: 'number',
|
|
458
|
+
required: true,
|
|
459
|
+
normallyHidden: true
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
// Never visible
|
|
463
|
+
supplierApiKey: {
|
|
464
|
+
type: 'string',
|
|
465
|
+
hidden: true
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// Virtual fields for input
|
|
469
|
+
priceInCents: { type: 'number', virtual: true },
|
|
470
|
+
importFromSupplier: { type: 'boolean', virtual: true },
|
|
471
|
+
|
|
472
|
+
// Computed fields for output
|
|
473
|
+
profitMargin: {
|
|
474
|
+
type: 'number',
|
|
475
|
+
computed: true,
|
|
476
|
+
dependencies: ['price', 'cost'],
|
|
477
|
+
compute: ({ attributes }) => {
|
|
478
|
+
if (!attributes.price || !attributes.cost) return null;
|
|
479
|
+
return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
displayPrice: {
|
|
484
|
+
type: 'string',
|
|
485
|
+
computed: true,
|
|
486
|
+
dependencies: ['price'],
|
|
487
|
+
compute: ({ attributes }) => {
|
|
488
|
+
return `$${attributes.price.toFixed(2)}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Usage example:
|
|
495
|
+
// POST /products
|
|
496
|
+
{
|
|
497
|
+
"data": {
|
|
498
|
+
"type": "products",
|
|
499
|
+
"attributes": {
|
|
500
|
+
"sku": "WIDGET-001",
|
|
501
|
+
"name": "Super Widget",
|
|
502
|
+
"description": "The best widget",
|
|
503
|
+
"priceInCents": 9999, // Virtual field (converted to 99.99)
|
|
504
|
+
"cost": 45.00,
|
|
505
|
+
"importFromSupplier": true, // Virtual field (triggers hook)
|
|
506
|
+
"supplierApiKey": "secret" // Hidden field (stored but never returned)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Hook to handle virtual field
|
|
512
|
+
api.on('beforeData:create:products', async ({ inputRecord, context }) => {
|
|
513
|
+
if (inputRecord.data.attributes.importFromSupplier) {
|
|
514
|
+
// Use the hidden supplierApiKey to fetch data
|
|
515
|
+
const data = await fetchFromSupplier(inputRecord.data.attributes.supplierApiKey);
|
|
516
|
+
inputRecord.data.attributes.description = data.description;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Response:
|
|
521
|
+
{
|
|
522
|
+
"data": {
|
|
523
|
+
"type": "products",
|
|
524
|
+
"id": "1",
|
|
525
|
+
"attributes": {
|
|
526
|
+
"sku": "WIDGET-001",
|
|
527
|
+
"name": "Super Widget",
|
|
528
|
+
"description": "The best widget",
|
|
529
|
+
"price": 99.99, // Setter converted from cents
|
|
530
|
+
"displayPrice": "$99.99", // Computed field
|
|
531
|
+
"profitMargin": "54.95", // Computed field (if user has permission)
|
|
532
|
+
"importFromSupplier": true // Virtual field preserved
|
|
533
|
+
// Note: cost, supplierApiKey not included
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Best Practices
|
|
540
|
+
|
|
541
|
+
### When to Use Each Transformation Type
|
|
542
|
+
|
|
543
|
+
| Need | Use | Example |
|
|
544
|
+
|------|-----|---------|
|
|
545
|
+
| Temporary request data | Virtual field | Password confirmation |
|
|
546
|
+
| Clean input data | Setter | Lowercase emails, trim whitespace |
|
|
547
|
+
| Format output data | Getter | Format phone numbers, parse JSON |
|
|
548
|
+
| Calculate from other fields | Computed field | Full names, totals, percentages |
|
|
549
|
+
| Security-sensitive data | Hidden field | Password hashes, API keys |
|
|
550
|
+
| Sensitive business data | Normally hidden field | Costs, internal notes |
|
|
551
|
+
| Context-aware transforms | enrichAttributes hook | Permission-based visibility |
|
|
552
|
+
|
|
553
|
+
### Performance Considerations
|
|
554
|
+
|
|
555
|
+
1. **Setters/Getters**: Keep them fast and synchronous when possible
|
|
556
|
+
2. **Computed Fields**:
|
|
557
|
+
- Calculated on every request (no caching)
|
|
558
|
+
- Dependencies are always fetched from DB
|
|
559
|
+
- Consider storing frequently accessed computed values
|
|
560
|
+
3. **Virtual Fields**: No performance impact (not stored/retrieved)
|
|
561
|
+
4. **Hidden Fields**: Filtered after retrieval (minimal impact)
|
|
562
|
+
|
|
563
|
+
### Common Pitfalls to Avoid
|
|
564
|
+
|
|
565
|
+
1. **Don't use computed fields for heavy calculations** - Consider background jobs instead
|
|
566
|
+
2. **Don't put validation logic in setters** - Use schema validation or hooks
|
|
567
|
+
3. **Remember computed fields are output-only** - They're ignored in input
|
|
568
|
+
4. **Test edge cases** - Null values, missing dependencies, etc.
|
|
569
|
+
5. **Document virtual fields** - They're part of your API contract
|
|
570
|
+
|
|
571
|
+
## Migration Tips
|
|
572
|
+
|
|
573
|
+
If you're migrating from an older version:
|
|
574
|
+
- Computed fields now use `computed: true` in the schema (not a separate object)
|
|
575
|
+
- Virtual fields use `virtual: true` in the schema
|
|
576
|
+
- All field transformations are defined in one place: the schema
|
|
577
|
+
|
|
578
|
+
## Summary
|
|
579
|
+
|
|
580
|
+
The JSON REST API library provides a complete transformation pipeline:
|
|
581
|
+
- **Virtual fields** for temporary data that flows through but isn't stored
|
|
582
|
+
- **Setters** for cleaning and transforming input before storage
|
|
583
|
+
- **Getters** for formatting and transforming output after retrieval
|
|
584
|
+
- **Computed fields** for deriving values from other fields
|
|
585
|
+
- **Hidden fields** for controlling visibility
|
|
586
|
+
- **Hooks** for advanced context-aware transformations
|
|
587
|
+
|
|
588
|
+
By combining these tools, you can build APIs that accept user-friendly input, store data efficiently, and return perfectly formatted responses.
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
# Detailed Guide: Computed Fields
|
|
593
|
+
|
|
594
|
+
Computed fields are virtual fields that don't exist in your database but are calculated on-the-fly from other fields. They're computed after the database load, every time they're requested, and can depend on other fields (including hidden ones). Computed fields work seamlessly with sparse fieldsets and are calculated for both main resources and included resources.
|
|
595
|
+
|
|
596
|
+
Let's create a complete example with products and reviews:
|
|
597
|
+
|
|
598
|
+
```javascript
|
|
599
|
+
// Define products resource with computed fields
|
|
600
|
+
await api.addResource('products', {
|
|
601
|
+
schema: {
|
|
602
|
+
name: { type: 'string', required: true, max: 255 },
|
|
603
|
+
price: { type: 'number', required: true, min: 0 },
|
|
604
|
+
cost: { type: 'number', required: true, min: 0, normallyHidden: true },
|
|
605
|
+
profit_margin: {
|
|
606
|
+
type: 'number',
|
|
607
|
+
computed: true, // Mark as computed field
|
|
608
|
+
dependencies: ['price', 'cost'],
|
|
609
|
+
compute: ({ attributes }) => {
|
|
610
|
+
if (!attributes.price || attributes.price === 0) return 0;
|
|
611
|
+
return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
// Computed fields can also be async
|
|
615
|
+
availability_status: {
|
|
616
|
+
type: 'string',
|
|
617
|
+
computed: true, // Mark as computed field
|
|
618
|
+
dependencies: ['name'],
|
|
619
|
+
compute: async ({ attributes }) => {
|
|
620
|
+
// Simulate async operation (e.g., checking external inventory)
|
|
621
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
622
|
+
return `${attributes.name} - In Stock`;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
relationships: {
|
|
627
|
+
reviews: { hasMany: 'reviews', foreignKey: 'product_id' }
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
await api.resources.products.createKnexTable();
|
|
631
|
+
|
|
632
|
+
// Define reviews resource with computed fields
|
|
633
|
+
await api.addResource('reviews', {
|
|
634
|
+
schema: {
|
|
635
|
+
product_id: { type: 'id', belongsTo: 'products', as: 'product', required: true },
|
|
636
|
+
reviewer_name: { type: 'string', required: true },
|
|
637
|
+
rating: { type: 'number', required: true, min: 1, max: 5 },
|
|
638
|
+
comment: { type: 'string', max: 1000 },
|
|
639
|
+
helpful_votes: { type: 'number', default: 0 },
|
|
640
|
+
total_votes: { type: 'number', default: 0 },
|
|
641
|
+
helpfulness_score: {
|
|
642
|
+
type: 'number',
|
|
643
|
+
computed: true, // Mark as computed field
|
|
644
|
+
dependencies: ['helpful_votes', 'total_votes'],
|
|
645
|
+
compute: ({ attributes }) => {
|
|
646
|
+
if (attributes.total_votes === 0) return null;
|
|
647
|
+
return ((attributes.helpful_votes / attributes.total_votes) * 100).toFixed(0);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
await api.resources.reviews.createKnexTable();
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
The key features to note:
|
|
656
|
+
- `cost` is marked as `normallyHidden` - it won't be returned unless explicitly requested
|
|
657
|
+
- `profit_margin` depends on both `price` and `cost`
|
|
658
|
+
- `helpfulness_score` is computed for each review
|
|
659
|
+
|
|
660
|
+
## Basic Usage
|
|
661
|
+
|
|
662
|
+
Let's create some data and see how computed fields work:
|
|
663
|
+
|
|
664
|
+
```javascript
|
|
665
|
+
// Create a product
|
|
666
|
+
const product = await api.resources.products.post({
|
|
667
|
+
name: 'Premium Headphones',
|
|
668
|
+
price: 199.99,
|
|
669
|
+
cost: 89.50
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// Fetch the product - computed fields are automatically calculated
|
|
673
|
+
const fetchedProduct = await api.resources.products.get({ id: product.id });
|
|
674
|
+
console.log(fetchedProduct);
|
|
675
|
+
// {
|
|
676
|
+
// id: '1',
|
|
677
|
+
// name: 'Premium Headphones',
|
|
678
|
+
// price: 199.99,
|
|
679
|
+
// profit_margin: '55.23', // Computed: (199.99 - 89.50) / 199.99 * 100
|
|
680
|
+
// availability_status: 'Premium Headphones - In Stock' // Async computed field
|
|
681
|
+
// }
|
|
682
|
+
// Note: 'cost' is not included (normallyHidden)
|
|
683
|
+
|
|
684
|
+
// Add some reviews
|
|
685
|
+
const review1 = await api.resources.reviews.post({
|
|
686
|
+
product_id: product.id,
|
|
687
|
+
reviewer_name: 'Alice',
|
|
688
|
+
rating: 5,
|
|
689
|
+
comment: 'Excellent sound quality!',
|
|
690
|
+
helpful_votes: 45,
|
|
691
|
+
total_votes: 50
|
|
692
|
+
});
|
|
693
|
+
console.log(review1);
|
|
694
|
+
|
|
695
|
+
const review2 = await api.resources.reviews.post({
|
|
696
|
+
product_id: product.id,
|
|
697
|
+
reviewer_name: 'Bob',
|
|
698
|
+
rating: 4,
|
|
699
|
+
comment: 'Good, but a bit pricey',
|
|
700
|
+
helpful_votes: 10,
|
|
701
|
+
total_votes: 25
|
|
702
|
+
});
|
|
703
|
+
console.log(review2);
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
## Sparse Fieldsets and Dependencies
|
|
709
|
+
|
|
710
|
+
When you request a computed field via sparse fieldsets, the system automatically fetches its dependencies:
|
|
711
|
+
|
|
712
|
+
```javascript
|
|
713
|
+
// Request only name and profit_margin
|
|
714
|
+
const sparseProduct = await api.resources.products.get({
|
|
715
|
+
id: product.id,
|
|
716
|
+
queryParams: {
|
|
717
|
+
fields: { products: 'name,profit_margin' }
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
console.log('Product with sparse fields:', sparseProduct);
|
|
721
|
+
|
|
722
|
+
const productWithCost = await api.resources.products.get({
|
|
723
|
+
id: product.id,
|
|
724
|
+
queryParams: {
|
|
725
|
+
fields: { products: 'name,cost,profit_margin' }
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
console.log('Product with sparse fields includig cost:', productWithCost);
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
**Expected output**:
|
|
732
|
+
|
|
733
|
+
```text
|
|
734
|
+
Product with sparse fields: {
|
|
735
|
+
id: '1',
|
|
736
|
+
name: 'Premium Headphones',
|
|
737
|
+
profit_margin: '55.25',
|
|
738
|
+
availability_status: 'Premium Headphones - In Stock',
|
|
739
|
+
reviews_ids: [ '1', '2' ]
|
|
740
|
+
}
|
|
741
|
+
Product with sparse fields includig cost: {
|
|
742
|
+
id: '1',
|
|
743
|
+
name: 'Premium Headphones',
|
|
744
|
+
cost: 89.5,
|
|
745
|
+
profit_margin: '55.25',
|
|
746
|
+
reviews_ids: [ '1', '2' ]
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
## Computed Fields in Included Resources
|
|
751
|
+
|
|
752
|
+
Computed fields work seamlessly with included resources:
|
|
753
|
+
|
|
754
|
+
```javascript
|
|
755
|
+
// Fetch product with reviews
|
|
756
|
+
const productWithReviews = await api.resources.products.get({
|
|
757
|
+
id: product.id,
|
|
758
|
+
queryParams: {
|
|
759
|
+
include: ['reviews']
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
console.log('Product With Reviews:', productWithReviews);
|
|
763
|
+
|
|
764
|
+
// Use sparse fieldsets on included resources
|
|
765
|
+
const productWithSparseReviews = await api.resources.products.get({
|
|
766
|
+
id: product.id,
|
|
767
|
+
queryParams: {
|
|
768
|
+
include: ['reviews'],
|
|
769
|
+
fields: {
|
|
770
|
+
products: 'name,price',
|
|
771
|
+
reviews: 'reviewer_name,rating,helpfulness_score' // Only these fields
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
console.log('Product With Sparse Reviews:', productWithSparseReviews);
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
**Expected Output**:
|
|
779
|
+
|
|
780
|
+
```text
|
|
781
|
+
Product With Reviews: {
|
|
782
|
+
id: '1',
|
|
783
|
+
name: 'Premium Headphones',
|
|
784
|
+
price: 199.99,
|
|
785
|
+
profit_margin: '55.25',
|
|
786
|
+
availability_status: 'Premium Headphones - In Stock',
|
|
787
|
+
reviews_ids: [ '1', '2' ],
|
|
788
|
+
reviews: [
|
|
789
|
+
{
|
|
790
|
+
id: '1',
|
|
791
|
+
reviewer_name: 'Alice',
|
|
792
|
+
rating: 5,
|
|
793
|
+
comment: 'Excellent sound quality!',
|
|
794
|
+
helpful_votes: 45,
|
|
795
|
+
total_votes: 50,
|
|
796
|
+
helpfulness_score: '90'
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
id: '2',
|
|
800
|
+
reviewer_name: 'Bob',
|
|
801
|
+
rating: 4,
|
|
802
|
+
comment: 'Good, but a bit pricey',
|
|
803
|
+
helpful_votes: 10,
|
|
804
|
+
total_votes: 25,
|
|
805
|
+
helpfulness_score: '40'
|
|
806
|
+
}
|
|
807
|
+
]
|
|
808
|
+
}
|
|
809
|
+
Product With Sparse Reviews: {
|
|
810
|
+
id: '1',
|
|
811
|
+
name: 'Premium Headphones',
|
|
812
|
+
price: 199.99,
|
|
813
|
+
reviews_ids: [ '1', '2' ],
|
|
814
|
+
reviews: [
|
|
815
|
+
{
|
|
816
|
+
id: '1',
|
|
817
|
+
reviewer_name: 'Alice',
|
|
818
|
+
rating: 5,
|
|
819
|
+
helpfulness_score: '90'
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
id: '2',
|
|
823
|
+
reviewer_name: 'Bob',
|
|
824
|
+
rating: 4,
|
|
825
|
+
helpfulness_score: '40'
|
|
826
|
+
}
|
|
827
|
+
]
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
## Error Handling
|
|
832
|
+
|
|
833
|
+
Computed fields handle errors gracefully:
|
|
834
|
+
|
|
835
|
+
```javascript
|
|
836
|
+
|
|
837
|
+
// Create a review with no votes
|
|
838
|
+
const review3 = await api.resources.reviews.post({
|
|
839
|
+
product_id: product.id,
|
|
840
|
+
reviewer_name: 'Charlie',
|
|
841
|
+
rating: 3,
|
|
842
|
+
comment: 'Average product',
|
|
843
|
+
helpful_votes: 0,
|
|
844
|
+
total_votes: 0 // This will cause division by zero
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const fetchedReviewWithError = await api.resources.reviews.get({ id: review3.id });
|
|
848
|
+
console.log('Fetched review (with error in helpfulness score):', fetchedReviewWithError
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
**Expected Output**:
|
|
852
|
+
|
|
853
|
+
```text
|
|
854
|
+
Fetched review (with error in helpfulness score): {
|
|
855
|
+
id: '3',
|
|
856
|
+
reviewer_name: 'Charlie',
|
|
857
|
+
rating: 3,
|
|
858
|
+
comment: 'Average product',
|
|
859
|
+
helpful_votes: 0,
|
|
860
|
+
total_votes: 0,
|
|
861
|
+
helpfulness_score: null,
|
|
862
|
+
product_id: '1'
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
## Key Points
|
|
867
|
+
|
|
868
|
+
1. **Always Computed** - Computed fields are calculated fresh on every request, there's no caching.
|
|
869
|
+
|
|
870
|
+
2. **Dependencies Are Fetched** - When you request a computed field, all its dependencies are automatically fetched from the database, even if they won't appear in the response.
|
|
871
|
+
|
|
872
|
+
3. **Works with Sparse Fieldsets** - You can request computed fields just like regular fields using sparse fieldsets.
|
|
873
|
+
|
|
874
|
+
4. **Hidden Dependencies** - Fields marked as `normallyHidden` can be used as dependencies and will be fetched for computation, but won't appear in the response unless explicitly requested.
|
|
875
|
+
|
|
876
|
+
5. **Included Resources** - Computed fields are calculated for all resources, whether they're the main resource or included via relationships.
|
|
877
|
+
|
|
878
|
+
6. **Error Handling** - If a computation fails, the field is set to `null` and an error is logged, but the request continues.
|
|
879
|
+
|
|
880
|
+
## Async Computed Fields
|
|
881
|
+
|
|
882
|
+
Computed fields can be asynchronous - simply return a Promise or use async/await. The compute function will be awaited during field resolution:
|
|
883
|
+
|
|
884
|
+
```javascript
|
|
885
|
+
schema: {
|
|
886
|
+
name: { type: 'string', required: true },
|
|
887
|
+
availability_status: {
|
|
888
|
+
type: 'string',
|
|
889
|
+
computed: true, // Mark as computed field
|
|
890
|
+
dependencies: ['name'],
|
|
891
|
+
compute: async ({ attributes }) => {
|
|
892
|
+
// Perform async operation
|
|
893
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
894
|
+
return `${attributes.name} - In Stock`;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
## Best Practices
|
|
901
|
+
|
|
902
|
+
1. **Keep Computations Simple** - Computed fields should be quick calculations. While async is supported, avoid heavy operations like database queries or external API calls.
|
|
903
|
+
|
|
904
|
+
2. **Declare All Dependencies** - Always list all fields your computation needs in the `dependencies` array.
|
|
905
|
+
|
|
906
|
+
3. **Handle Edge Cases** - Check for null values and division by zero in your compute functions.
|
|
907
|
+
|
|
908
|
+
4. **Consider Performance** - Remember that dependencies are always fetched. If you have expensive computations or many dependencies, consider storing the computed value as a regular field instead.
|
|
909
|
+
|
|
910
|
+
---
|
|
911
|
+
|
|
912
|
+
# Detailed Guide: Getters and Setters
|
|
913
|
+
|
|
914
|
+
## Introduction
|
|
915
|
+
|
|
916
|
+
Field getters and setters allow you to transform data as it moves between your API and database:
|
|
917
|
+
|
|
918
|
+
- **Getters**: Transform data when reading from the database (e.g., formatting phone numbers, trimming strings)
|
|
919
|
+
- **Setters**: Transform data before writing to the database (e.g., normalizing emails, hashing passwords)
|
|
920
|
+
|
|
921
|
+
This is different from computed fields, which are virtual fields calculated on-the-fly. Getters and setters work with actual database columns.
|
|
922
|
+
|
|
923
|
+
## Initial Setup: A Blog System
|
|
924
|
+
|
|
925
|
+
Let's start with a simple blog system without any getters or setters:
|
|
926
|
+
|
|
927
|
+
```javascript
|
|
928
|
+
import { Api } from 'hooked-api';
|
|
929
|
+
import RestApiPlugin from 'json-rest-api/plugins/core/rest-api-plugin.js';
|
|
930
|
+
import RestApiKnexPlugin from 'json-rest-api/plugins/core/rest-api-knex-plugin.js';
|
|
931
|
+
import knex from 'knex';
|
|
932
|
+
|
|
933
|
+
// Create database connection
|
|
934
|
+
const db = knex({
|
|
935
|
+
client: 'better-sqlite3',
|
|
936
|
+
connection: { filename: './blog.db' }
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// Create API instance
|
|
940
|
+
const api = new Api({ name: 'blog-api', version: '1.0.0' });
|
|
941
|
+
|
|
942
|
+
// Install plugins
|
|
943
|
+
await api.use(RestApiPlugin);
|
|
944
|
+
await api.use(RestApiKnexPlugin, { knex: db });
|
|
945
|
+
|
|
946
|
+
// Define authors resource (no getters/setters yet)
|
|
947
|
+
await api.addResource('authors', {
|
|
948
|
+
schema: {
|
|
949
|
+
id: { type: 'id' },
|
|
950
|
+
email: { type: 'string', required: true },
|
|
951
|
+
name: { type: 'string', required: true },
|
|
952
|
+
bio: { type: 'string', nullable: true }
|
|
953
|
+
},
|
|
954
|
+
relationships: {
|
|
955
|
+
posts: { hasMany: 'posts', foreignKey: 'author_id' }
|
|
956
|
+
},
|
|
957
|
+
tableName: 'authors'
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// Define posts resource (no getters/setters yet)
|
|
961
|
+
await api.addResource('posts', {
|
|
962
|
+
schema: {
|
|
963
|
+
id: { type: 'id' },
|
|
964
|
+
title: { type: 'string', required: true },
|
|
965
|
+
content: { type: 'string', required: true },
|
|
966
|
+
author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
|
|
967
|
+
published_at: { type: 'dateTime', default: 'now()' }
|
|
968
|
+
},
|
|
969
|
+
tableName: 'posts'
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// Create tables
|
|
973
|
+
await api.resources.authors.createKnexTable();
|
|
974
|
+
await api.resources.posts.createKnexTable();
|
|
975
|
+
|
|
976
|
+
// Create test data
|
|
977
|
+
const author = await api.resources.authors.post({
|
|
978
|
+
email: ' Jane.Doe@BLOG.COM ',
|
|
979
|
+
name: ' Jane Doe ',
|
|
980
|
+
bio: ' Software developer and writer '
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const post1 = await api.resources.posts.post({
|
|
984
|
+
title: ' Getting Started with APIs ',
|
|
985
|
+
content: ' This is my first post about APIs... ',
|
|
986
|
+
author_id: author.id
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const post2 = await api.resources.posts.post({
|
|
990
|
+
title: ' advanced api patterns ',
|
|
991
|
+
content: ' Let\'s explore some advanced patterns... ',
|
|
992
|
+
author_id: author.id
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// Fetch author with posts
|
|
996
|
+
const authorWithPosts = await api.resources.authors.get({
|
|
997
|
+
id: author.id,
|
|
998
|
+
queryParams: { include: ['posts'] }
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
console.log('Author with posts (no getters):', authorWithPosts);
|
|
1002
|
+
// Notice the messy data:
|
|
1003
|
+
// - Email has spaces and mixed case
|
|
1004
|
+
// - Name and bio have extra spaces
|
|
1005
|
+
// - Post titles are inconsistently cased
|
|
1006
|
+
// - Content has leading/trailing spaces
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
## Adding Getters: Transform Data on Read
|
|
1010
|
+
|
|
1011
|
+
Now let's add getters to clean up the data automatically:
|
|
1012
|
+
|
|
1013
|
+
```javascript
|
|
1014
|
+
// Enhanced authors resource with getters
|
|
1015
|
+
await api.addResource('authors', {
|
|
1016
|
+
schema: {
|
|
1017
|
+
id: { type: 'id' },
|
|
1018
|
+
email: {
|
|
1019
|
+
type: 'string',
|
|
1020
|
+
required: true,
|
|
1021
|
+
getter: (value) => value?.toLowerCase().trim()
|
|
1022
|
+
},
|
|
1023
|
+
name: {
|
|
1024
|
+
type: 'string',
|
|
1025
|
+
required: true,
|
|
1026
|
+
getter: (value) => value?.trim()
|
|
1027
|
+
},
|
|
1028
|
+
bio: {
|
|
1029
|
+
type: 'string',
|
|
1030
|
+
nullable: true,
|
|
1031
|
+
getter: (value) => value?.trim()
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
relationships: {
|
|
1035
|
+
posts: { hasMany: 'posts', foreignKey: 'author_id' }
|
|
1036
|
+
},
|
|
1037
|
+
tableName: 'authors'
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Enhanced posts resource with getters
|
|
1041
|
+
await api.addResource('posts', {
|
|
1042
|
+
schema: {
|
|
1043
|
+
id: { type: 'id' },
|
|
1044
|
+
title: {
|
|
1045
|
+
type: 'string',
|
|
1046
|
+
required: true,
|
|
1047
|
+
getter: (value) => {
|
|
1048
|
+
// Capitalize first letter of each word
|
|
1049
|
+
return value?.trim()
|
|
1050
|
+
.split(' ')
|
|
1051
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
1052
|
+
.join(' ');
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
content: {
|
|
1056
|
+
type: 'string',
|
|
1057
|
+
required: true,
|
|
1058
|
+
getter: (value) => value?.trim()
|
|
1059
|
+
},
|
|
1060
|
+
author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
|
|
1061
|
+
published_at: { type: 'dateTime', default: 'now()' }
|
|
1062
|
+
},
|
|
1063
|
+
tableName: 'posts'
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Now fetch the same author with posts
|
|
1067
|
+
const cleanAuthorWithPosts = await api.resources.authors.get({
|
|
1068
|
+
id: author.id,
|
|
1069
|
+
queryParams: { include: ['posts'] }
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
console.log('Author:', cleanAuthorWithPosts);
|
|
1073
|
+
// {
|
|
1074
|
+
// id: '1',
|
|
1075
|
+
// email: 'jane.doe@blog.com', // Normalized
|
|
1076
|
+
// name: 'Jane Doe', // Trimmed
|
|
1077
|
+
// bio: 'Software developer and writer', // Trimmed
|
|
1078
|
+
// posts: [
|
|
1079
|
+
// {
|
|
1080
|
+
// id: '1',
|
|
1081
|
+
// title: 'Getting Started With Apis', // Title case
|
|
1082
|
+
// content: 'This is my first post about APIs...', // Trimmed
|
|
1083
|
+
// author_id: 1,
|
|
1084
|
+
// published_at: '2024-01-15T10:30:00.000Z'
|
|
1085
|
+
// },
|
|
1086
|
+
// {
|
|
1087
|
+
// id: '2',
|
|
1088
|
+
// title: 'Advanced Api Patterns', // Title case
|
|
1089
|
+
// content: 'Let\'s explore some advanced patterns...', // Trimmed
|
|
1090
|
+
// author_id: 1,
|
|
1091
|
+
// published_at: '2024-01-15T10:31:00.000Z'
|
|
1092
|
+
// }
|
|
1093
|
+
// ]
|
|
1094
|
+
// }
|
|
1095
|
+
|
|
1096
|
+
// Getters also work in queries
|
|
1097
|
+
const allPosts = await api.resources.posts.query({
|
|
1098
|
+
queryParams: {
|
|
1099
|
+
include: ['author'],
|
|
1100
|
+
filters: { author_id: author.id }
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
console.log('All posts with author:', allPosts);
|
|
1105
|
+
// Both posts and included authors have getters applied
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
## Adding Setters: Transform Data on Write
|
|
1109
|
+
|
|
1110
|
+
Setters ensure data is normalized before it's stored:
|
|
1111
|
+
|
|
1112
|
+
```javascript
|
|
1113
|
+
// Add setters to authors
|
|
1114
|
+
await api.addResource('authors', {
|
|
1115
|
+
schema: {
|
|
1116
|
+
id: { type: 'id' },
|
|
1117
|
+
email: {
|
|
1118
|
+
type: 'string',
|
|
1119
|
+
required: true,
|
|
1120
|
+
setter: (value) => value?.toLowerCase().trim(),
|
|
1121
|
+
getter: (value) => value
|
|
1122
|
+
},
|
|
1123
|
+
name: {
|
|
1124
|
+
type: 'string',
|
|
1125
|
+
required: true,
|
|
1126
|
+
setter: (value) => value?.trim(),
|
|
1127
|
+
getter: (value) => value
|
|
1128
|
+
},
|
|
1129
|
+
bio: {
|
|
1130
|
+
type: 'string',
|
|
1131
|
+
nullable: true,
|
|
1132
|
+
setter: (value) => value?.trim(),
|
|
1133
|
+
getter: (value) => value
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
relationships: {
|
|
1137
|
+
posts: { hasMany: 'posts', foreignKey: 'author_id' }
|
|
1138
|
+
},
|
|
1139
|
+
tableName: 'authors'
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Now when we create an author, data is cleaned before storage
|
|
1143
|
+
const newAuthor = await api.resources.authors.post({
|
|
1144
|
+
email: ' JOHN.SMITH@BLOG.COM ',
|
|
1145
|
+
name: ' John Smith ',
|
|
1146
|
+
bio: ' Tech enthusiast '
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
console.log('New author:', newAuthor);
|
|
1150
|
+
// Data is already clean:
|
|
1151
|
+
// {
|
|
1152
|
+
// id: '2',
|
|
1153
|
+
// email: 'john.smith@blog.com',
|
|
1154
|
+
// name: 'John Smith',
|
|
1155
|
+
// bio: 'Tech enthusiast'
|
|
1156
|
+
// }
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
## Async Setters for Secure Data
|
|
1160
|
+
|
|
1161
|
+
Use async setters for operations like password hashing:
|
|
1162
|
+
|
|
1163
|
+
```javascript
|
|
1164
|
+
await api.addResource('users', {
|
|
1165
|
+
schema: {
|
|
1166
|
+
id: { type: 'id' },
|
|
1167
|
+
email: {
|
|
1168
|
+
type: 'string',
|
|
1169
|
+
required: true,
|
|
1170
|
+
setter: (value) => value?.toLowerCase().trim()
|
|
1171
|
+
},
|
|
1172
|
+
password: {
|
|
1173
|
+
type: 'string',
|
|
1174
|
+
required: true,
|
|
1175
|
+
min: 8,
|
|
1176
|
+
setter: async (value) => {
|
|
1177
|
+
// Simulate password hashing
|
|
1178
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1179
|
+
return `hashed:${value}`;
|
|
1180
|
+
},
|
|
1181
|
+
getter: () => '[PROTECTED]' // Never expose hashed passwords
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
tableName: 'users'
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
const user = await api.resources.users.post({
|
|
1188
|
+
email: ' USER@EXAMPLE.COM ',
|
|
1189
|
+
password: 'mySecretPassword123'
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
console.log('Created user:', user);
|
|
1193
|
+
// {
|
|
1194
|
+
// id: '1',
|
|
1195
|
+
// email: 'user@example.com',
|
|
1196
|
+
// password: '[PROTECTED]'
|
|
1197
|
+
// }
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
## Setter Dependencies
|
|
1201
|
+
|
|
1202
|
+
When setters depend on other fields, use `runSetterAfter`:
|
|
1203
|
+
|
|
1204
|
+
```javascript
|
|
1205
|
+
await api.addResource('products', {
|
|
1206
|
+
schema: {
|
|
1207
|
+
id: { type: 'id' },
|
|
1208
|
+
name: { type: 'string', required: true },
|
|
1209
|
+
base_price: {
|
|
1210
|
+
type: 'number',
|
|
1211
|
+
setter: (value) => Math.round(value * 100) // Convert to cents
|
|
1212
|
+
},
|
|
1213
|
+
tax_rate: {
|
|
1214
|
+
type: 'number',
|
|
1215
|
+
setter: (value) => value || 0
|
|
1216
|
+
},
|
|
1217
|
+
total_price: {
|
|
1218
|
+
type: 'number',
|
|
1219
|
+
setter: (value, { attributes }) => {
|
|
1220
|
+
// Calculate from base_price (already in cents) and tax_rate
|
|
1221
|
+
const total = attributes.base_price * (1 + attributes.tax_rate);
|
|
1222
|
+
return Math.round(total);
|
|
1223
|
+
},
|
|
1224
|
+
runSetterAfter: ['base_price', 'tax_rate']
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
tableName: 'products'
|
|
1228
|
+
});
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
## Summary
|
|
1232
|
+
|
|
1233
|
+
Getters and setters provide automatic data transformation:
|
|
1234
|
+
|
|
1235
|
+
- **Getters** transform data when reading (including in relationships and queries)
|
|
1236
|
+
- **Setters** transform data before storing
|
|
1237
|
+
- Both support async operations and dependencies
|
|
1238
|
+
- They work with actual database columns
|
|
1239
|
+
|
|
1240
|
+
Common uses:
|
|
1241
|
+
- Email normalization
|
|
1242
|
+
- String trimming
|
|
1243
|
+
- Title case formatting
|
|
1244
|
+
- Password hashing
|
|
1245
|
+
- Price calculations
|
|
1246
|
+
- Data consistency across related records
|