json-rest-api 1.0.23 → 1.0.24

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.
Files changed (60) hide show
  1. package/README.md +22 -1
  2. package/docs/API.md +145 -24
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +83 -4
  4. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  5. package/lib/enhanced-logger.js +1 -1
  6. package/package.json +6 -2
  7. package/plugins/core/bulk-operations-plugin.js +26 -34
  8. package/plugins/core/connectors/express-plugin.js +0 -6
  9. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  10. package/plugins/core/lib/anyapi/schema-utils.js +43 -4
  11. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -1
  12. package/plugins/core/lib/dbIntrospection.js +1 -1
  13. package/plugins/core/lib/dbTablesOperations.js +10 -3
  14. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  15. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +0 -1
  16. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  17. package/plugins/core/lib/querying/knex-relationship-includes.js +11 -18
  18. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  19. package/plugins/core/lib/querying-writing/knex-field-helpers.js +1 -2
  20. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  21. package/plugins/core/lib/querying-writing/schema-helpers.js +0 -2
  22. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  23. package/plugins/core/lib/storage/canonical-storage-mapping.js +22 -2
  24. package/plugins/core/lib/storage/storage-adapter.js +3 -2
  25. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  26. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -2
  27. package/plugins/core/rest-api-anyapi-knex-plugin.js +194 -102
  28. package/plugins/core/rest-api-knex-plugin.js +23 -16
  29. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +1 -0
  30. package/plugins/core/rest-api-plugin-methods/common.js +27 -9
  31. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +11 -2
  32. package/plugins/core/rest-api-plugin-methods/delete.js +6 -1
  33. package/plugins/core/rest-api-plugin-methods/get-related.js +7 -2
  34. package/plugins/core/rest-api-plugin-methods/get-relationship.js +7 -2
  35. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  36. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +10 -3
  37. package/plugins/core/rest-api-plugin-methods/patch.js +31 -3
  38. package/plugins/core/rest-api-plugin-methods/post-relationship.js +11 -2
  39. package/plugins/core/rest-api-plugin-methods/post.js +8 -0
  40. package/plugins/core/rest-api-plugin-methods/put.js +31 -3
  41. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  42. package/plugins/core/rest-api-plugin.js +4 -0
  43. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  44. package/plugins/core/socketio-plugin.js +0 -1
  45. package/plugins/storage/local-storage.js +1 -1
  46. package/plugins/storage/s3-storage.js +5 -2
  47. package/quickTest.js +1 -0
  48. package/scripts/run-docs.js +80 -84
  49. package/test-debug.js +1 -1
  50. package/tests/README.md +7 -0
  51. package/tests/anyapi-basic.test.js +3 -0
  52. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  53. package/tests/db-introspection.test.js +4 -1
  54. package/tests/db-table-operations.test.js +25 -20
  55. package/tests/fastify-plugin.test.js +21 -6
  56. package/tests/fixtures/api-configs.js +133 -0
  57. package/tests/id-normalization.test.js +444 -0
  58. package/tests/logical-id-write-validation.test.js +151 -0
  59. package/tests/pagination-cursor-multifield.test.js +11 -11
  60. package/tests/storage-mapping.test.js +55 -6
package/README.md CHANGED
@@ -72,4 +72,25 @@ app.listen(3000, () => {
72
72
  - [Quick Start](QUICKSTART.md) - Quick start
73
73
  - [Complete guide](GUIDE/) - Comprehensive guide
74
74
  - [API Reference](API.md) - API reference
75
- - [Why json-rest-api?](docs/COMPARISON.md) - Step-by-step tutorial
75
+ - [Why json-rest-api?](docs/COMPARISON.md) - Step-by-step tutorial
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ # Run the normalizeId contract suites under the default backend and AnyAPI,
81
+ # then rebuild the checked-in docs site
82
+ npm run verify
83
+
84
+ # Run lint separately
85
+ npm run lint
86
+
87
+ # Run the focused normalizeId suites directly
88
+ npm run test:id-contracts
89
+ npm run test:id-contracts:anyapi
90
+
91
+ # Rebuild the checked-in docs site
92
+ npm run docs
93
+
94
+ # Serve the docs locally with live rebuild
95
+ npm run docs:dev
96
+ ```
package/docs/API.md CHANGED
@@ -14,6 +14,8 @@ This reference provides comprehensive documentation for all methods, parameters,
14
14
 
15
15
  4. **All parameters must be passed within a single params object** as the first argument to each method.
16
16
 
17
+ 5. **Simplified shorthand is payload-only**: when you pass attributes directly (for example, `post({ title: 'Hello' })`), that plain object is treated as the resource payload. If you also need per-call options such as `transaction`, `queryParams`, `simplified`, or `returnFullRecord`, put the record under `inputRecord` and keep those options at the top level of `params`.
18
+
17
19
  ## Table of Contents
18
20
 
19
21
  1. [Core API Methods](#core-api-methods)
@@ -291,6 +293,8 @@ const result = await api.resources.[resourceType].get(params, context)
291
293
  | `simplified` | Boolean | No | Override simplified mode setting (default: true) |
292
294
  | `transaction` | Object | No | Database transaction object |
293
295
 
296
+ `id` is normalized with the effective `normalizeId` function before validation and lookup. If it normalizes to an empty value, the operation fails as `REST_API_RESOURCE` with subtype `not_found`.
297
+
294
298
  #### Return Value
295
299
 
296
300
  **JSON:API Mode (simplified: false):**
@@ -430,7 +434,9 @@ const result = await api.resources.[resourceType].post(params, context)
430
434
  | Parameter | Type | Required | Description |
431
435
  |-----------|------|----------|-------------|
432
436
  | `inputRecord` | Object | Yes | Resource data to create |
437
+ | `inputRecord.id` | String | No (simplified) | Optional explicit logical resource ID |
433
438
  | `inputRecord.data` | Object | Yes (JSON:API) | Resource data container |
439
+ | `inputRecord.data.id` | String | No (JSON:API) | Optional explicit logical resource ID |
434
440
  | `inputRecord.data.type` | String | Yes (JSON:API) | Resource type |
435
441
  | `inputRecord.data.attributes` | Object | Yes (JSON:API) | Resource attributes |
436
442
  | `inputRecord.data.relationships` | Object | No | Related resources |
@@ -439,6 +445,8 @@ const result = await api.resources.[resourceType].post(params, context)
439
445
  | `transaction` | Object | No | Database transaction object |
440
446
  | `returnFullRecord` | String | No | Override return setting ('no', 'minimal', 'full') |
441
447
 
448
+ When an explicit resource id is provided, `normalizeId` runs before persistence and before any follow-up record fetch used for the return payload. If the normalized id is empty, the request fails as `REST_API_VALIDATION` on `data.id`.
449
+
442
450
  #### Return Value Behavior
443
451
 
444
452
  The return value depends on `returnFullRecord` setting and whether it's an API or transport call:
@@ -515,6 +523,25 @@ const result = await api.resources.articles.post({
515
523
  });
516
524
  ```
517
525
 
526
+ **Create with Explicit ID and Normalization:**
527
+ ```javascript
528
+ // With normalizeId configured to trim and uppercase ids,
529
+ // this record is stored as ARTICLE-42
530
+ const result = await api.resources.articles.post({
531
+ inputRecord: {
532
+ data: {
533
+ type: 'articles',
534
+ id: ' article-42 ',
535
+ attributes: {
536
+ title: 'Canonical ID Example',
537
+ content: 'Stored under the normalized id'
538
+ }
539
+ }
540
+ },
541
+ simplified: false
542
+ });
543
+ ```
544
+
518
545
  **Create with Multiple Relationships:**
519
546
  ```javascript
520
547
  // Create article with author and tags (simplified)
@@ -591,8 +618,8 @@ const result = await api.resources.[resourceType].put(params, context)
591
618
  | Parameter | Type | Required | Description |
592
619
  |-----------|------|----------|-------------|
593
620
  | `inputRecord` | Object | Yes | Complete resource data |
594
- | `inputRecord.id` | String | Yes (simplified) | Resource ID |
595
- | `inputRecord.data.id` | String | Yes (JSON:API) | Must match the resource ID |
621
+ | `inputRecord.id` | String | Yes (simplified) | Logical resource ID |
622
+ | `inputRecord.data.id` | String | Yes (JSON:API) | Logical resource ID |
596
623
  | `inputRecord.data.type` | String | Yes (JSON:API) | Resource type |
597
624
  | `inputRecord.data.attributes` | Object | Yes (JSON:API) | All resource attributes |
598
625
  | `inputRecord.data.relationships` | Object | No | All relationships (missing ones are nulled) |
@@ -601,6 +628,10 @@ const result = await api.resources.[resourceType].put(params, context)
601
628
  | `transaction` | Object | No | Database transaction object |
602
629
  | `returnFullRecord` | String | No | Override return setting |
603
630
 
631
+ `inputRecord.id` and `inputRecord.data.id` always refer to the logical resource id. `idProperty` and storage mapping affect the backing column name, not the API field name, and the resource id is not part of `attributes`.
632
+
633
+ If both the URL id and body id are present, both are normalized before the equality check. If either id normalizes to an empty value, the operation fails before storage is touched.
634
+
604
635
  #### Return Value
605
636
 
606
637
  Updated resource based on `returnFullRecord` setting (defaults: API='full', transport='no').
@@ -718,8 +749,8 @@ const result = await api.resources.[resourceType].patch(params, context)
718
749
  | Parameter | Type | Required | Description |
719
750
  |-----------|------|----------|-------------|
720
751
  | `inputRecord` | Object | Yes | Partial resource data |
721
- | `inputRecord.id` | String | Yes (simplified) | Resource ID |
722
- | `inputRecord.data.id` | String | Yes (JSON:API) | Resource ID |
752
+ | `inputRecord.id` | String | Yes (simplified) | Logical resource ID |
753
+ | `inputRecord.data.id` | String | Yes (JSON:API) | Logical resource ID |
723
754
  | `inputRecord.data.type` | String | Yes (JSON:API) | Resource type |
724
755
  | `inputRecord.data.attributes` | Object | No | Attributes to update |
725
756
  | `inputRecord.data.relationships` | Object | No | Relationships to update |
@@ -728,6 +759,8 @@ const result = await api.resources.[resourceType].patch(params, context)
728
759
  | `transaction` | Object | No | Database transaction object |
729
760
  | `returnFullRecord` | String | No | Override return setting |
730
761
 
762
+ If both the URL id and body id are present, both are normalized before the equality check. If either id normalizes to an empty value, the operation fails before storage is touched.
763
+
731
764
  #### Return Value
732
765
 
733
766
  Updated resource based on `returnFullRecord` setting (defaults: API='full', transport='no').
@@ -849,6 +882,8 @@ const result = await api.resources.[resourceType].delete(params, context)
849
882
  | `id` | String\|Number | Yes | ID of resource to delete |
850
883
  | `transaction` | Object | No | Database transaction object |
851
884
 
885
+ `id` is normalized with the effective `normalizeId` function before validation and lookup. If it normalizes to an empty value, the operation fails as `REST_API_RESOURCE` with subtype `not_found`.
886
+
852
887
  #### Return Value
853
888
 
854
889
  Returns `undefined` (204 No Content)
@@ -935,6 +970,8 @@ const result = await api.resources.[resourceType].getRelated(params, context)
935
970
  | `queryParams` | Object | No | Standard query parameters for related resources |
936
971
  | `transaction` | Object | No | Database transaction object |
937
972
 
973
+ `id` is normalized with the effective `normalizeId` function before validation and lookup. If it normalizes to an empty value, the operation fails as `REST_API_RESOURCE` with subtype `not_found`.
974
+
938
975
  #### Return Value
939
976
 
940
977
  JSON:API response with related resources (supports all query features like filtering, pagination, etc.)
@@ -1007,6 +1044,8 @@ const result = await api.resources.[resourceType].getRelationship(params, contex
1007
1044
  }
1008
1045
  ```
1009
1046
 
1047
+ `id` is normalized with the effective `normalizeId` function before validation and lookup. If it normalizes to an empty value, the operation fails as `REST_API_RESOURCE` with subtype `not_found`.
1048
+
1010
1049
  #### Return Value
1011
1050
 
1012
1051
  JSON:API relationship document with resource identifiers only
@@ -1081,6 +1120,8 @@ const result = await api.resources.[resourceType].postRelationship(params, conte
1081
1120
  }
1082
1121
  ```
1083
1122
 
1123
+ The parent `id` is normalized with the parent resource's normalizer. Each `relationshipData[*].id` is normalized with the related target resource's normalizer before validation and persistence.
1124
+
1084
1125
  #### Return Value
1085
1126
 
1086
1127
  Returns `undefined` (204 No Content)
@@ -1116,6 +1157,8 @@ await api.resources.articles.postRelationship({
1116
1157
  // Existing tags remain, new tags are added
1117
1158
  ```
1118
1159
 
1160
+ If `normalizeId` is configured, both the parent id and each relationship identifier are normalized before the relationship is written.
1161
+
1119
1162
  ---
1120
1163
 
1121
1164
  ### patchRelationship - Replace Relationship
@@ -1138,6 +1181,8 @@ const result = await api.resources.[resourceType].patchRelationship(params, cont
1138
1181
  }
1139
1182
  ```
1140
1183
 
1184
+ The parent `id` is normalized with the parent resource's normalizer. Each relationship identifier inside `relationshipData` is normalized with the related target resource's normalizer before validation and persistence.
1185
+
1141
1186
  #### Return Value
1142
1187
 
1143
1188
  Returns `undefined` (204 No Content)
@@ -1219,6 +1264,8 @@ const result = await api.resources.[resourceType].deleteRelationship(params, con
1219
1264
  }
1220
1265
  ```
1221
1266
 
1267
+ The parent `id` is normalized with the parent resource's normalizer. Each `relationshipData[*].id` is normalized with the related target resource's normalizer before validation and persistence.
1268
+
1222
1269
  #### Return Value
1223
1270
 
1224
1271
  Returns `undefined` (204 No Content)
@@ -2075,24 +2122,30 @@ api.resource('articles').hook('afterDataQuery', async (context) => {
2075
2122
  ### Plugin Configuration
2076
2123
 
2077
2124
  ```javascript
2078
- const restApiPlugin = new RestApiPlugin({
2125
+ await api.use(RestApiPlugin, {
2079
2126
  // API behavior
2080
2127
  simplifiedApi: true, // Use simplified mode for programmatic calls (default: true)
2081
2128
  simplifiedTransport: false, // Use JSON:API for HTTP transport (default: false)
2129
+ normalizeId: (value) => {
2130
+ if (value === null || value === undefined) {
2131
+ return null
2132
+ }
2133
+
2134
+ const normalized = String(value).trim()
2135
+ return normalized ? normalized.toUpperCase() : null
2136
+ },
2082
2137
 
2083
2138
  // Return record configuration
2084
2139
  returnRecordApi: {
2085
2140
  post: 'full', // Return full record after create (default)
2086
2141
  put: 'full', // Return full record after replace (default)
2087
- patch: 'full', // Return full record after update (default)
2088
- delete: 'no' // Return nothing after delete (default)
2142
+ patch: 'full' // Return full record after update (default)
2089
2143
  },
2090
2144
 
2091
2145
  returnRecordTransport: {
2092
2146
  post: 'no', // Return 204 for HTTP POST (default)
2093
2147
  put: 'no', // Return 204 for HTTP PUT (default)
2094
- patch: 'no', // Return 204 for HTTP PATCH (default)
2095
- delete: 'no' // Return 204 for HTTP DELETE (default)
2148
+ patch: 'no' // Return 204 for HTTP PATCH (default)
2096
2149
  },
2097
2150
 
2098
2151
  // Query limits
@@ -2103,15 +2156,7 @@ const restApiPlugin = new RestApiPlugin({
2103
2156
  includeDepthLimit: 3, // Maximum relationship nesting depth
2104
2157
 
2105
2158
  // Performance
2106
- enablePaginationCounts: true, // Execute count queries for total pages
2107
-
2108
- // Error handling
2109
- exposeErrors: false, // Include error details in responses
2110
-
2111
- // Custom serializers
2112
- serializers: {
2113
- articles: customArticleSerializer
2114
- }
2159
+ enablePaginationCounts: true // Execute count queries for total pages
2115
2160
  });
2116
2161
  ```
2117
2162
 
@@ -2126,7 +2171,7 @@ api.addResource({
2126
2171
  name: 'articles',
2127
2172
 
2128
2173
  // Primary key configuration
2129
- idProperty: 'id', // Custom primary key field (default: 'id')
2174
+ idProperty: 'id', // Physical primary-key column name for table-backed resources
2130
2175
 
2131
2176
  schema: {
2132
2177
  // Attributes
@@ -2273,19 +2318,95 @@ api.addResource({
2273
2318
 
2274
2319
  #### ID Property Configuration
2275
2320
  ```javascript
2276
- // Custom primary key
2321
+ // Physical primary key column: user_id
2277
2322
  api.addResource({
2278
2323
  name: 'users',
2279
- idProperty: 'user_id', // Use 'user_id' instead of 'id'
2324
+ idProperty: 'user_id',
2280
2325
  schema: {
2281
- attributes: {
2282
- user_id: { type: 'number', required: true },
2283
- email: { type: 'string', required: true }
2326
+ id: { type: 'id', required: true, storage: { column: 'user_id' } },
2327
+ email: { type: 'string', required: true },
2328
+ displayName: { type: 'string' }
2329
+ }
2330
+ });
2331
+ ```
2332
+
2333
+ `idProperty` names the physical primary-key column for table-backed resources. At the API layer, the resource id remains `id`, so writes use `inputRecord.id` or `inputRecord.data.id`, reads return `id` or `data.id`, and the resource id is not part of `attributes`.
2334
+
2335
+ #### Resource ID Normalization
2336
+
2337
+ `normalizeId` canonicalizes resource identifiers before the library reads, writes, validates, or links records.
2338
+
2339
+ The default normalizer:
2340
+
2341
+ - trims surrounding whitespace from strings
2342
+ - converts finite numbers and bigints to strings
2343
+ - rejects values that normalize to an empty identifier
2344
+
2345
+ Provide a custom normalizer when your ids need additional canonicalization:
2346
+
2347
+ ```javascript
2348
+ await api.use(RestApiPlugin, {
2349
+ normalizeId: (value) => {
2350
+ if (value === null || value === undefined) {
2351
+ return null
2284
2352
  }
2353
+
2354
+ const normalized = String(value).trim()
2355
+ return normalized ? normalized.toUpperCase() : null
2356
+ }
2357
+ });
2358
+ ```
2359
+
2360
+ Resource-level overrides take precedence over the plugin-level default:
2361
+
2362
+ ```javascript
2363
+ await api.addResource('countries', {
2364
+ normalizeId: (value) => {
2365
+ if (value === null || value === undefined) {
2366
+ return null
2367
+ }
2368
+
2369
+ const normalized = String(value).trim()
2370
+ return normalized ? normalized.toLowerCase() : null
2371
+ },
2372
+ schema: {
2373
+ name: { type: 'string', required: true }
2374
+ }
2375
+ });
2376
+ ```
2377
+
2378
+ Normalization is applied to:
2379
+
2380
+ - parent ids used by `get`, `put`, `patch`, `delete`, `getRelated`, and `getRelationship`
2381
+ - explicit `POST`, `PUT`, and `PATCH` document ids
2382
+ - relationship identifiers used by `postRelationship`, `patchRelationship`, and `deleteRelationship`
2383
+
2384
+ Relationship identifiers use the normalizer of the related target resource, not the parent resource.
2385
+
2386
+ If an id normalizes to an empty value:
2387
+
2388
+ - existing-resource operations fail as `REST_API_RESOURCE` with subtype `not_found`
2389
+ - explicit `POST` document ids fail as `REST_API_VALIDATION` on `data.id`
2390
+
2391
+ #### Storage Column Naming
2392
+
2393
+ Table-backed resources use snake_case physical columns by default. For example, `displayName` maps to `display_name`.
2394
+
2395
+ Use `storage.column` when a field needs a specific column name:
2396
+
2397
+ ```javascript
2398
+ api.addResource({
2399
+ name: 'profiles',
2400
+ schema: {
2401
+ id: { type: 'id' },
2402
+ displayName: { type: 'string', required: true },
2403
+ legacyRef: { type: 'string', storage: { column: 'legacy_profile_ref' } }
2285
2404
  }
2286
2405
  });
2287
2406
  ```
2288
2407
 
2408
+ If physical columns should match logical field names exactly for an entire resource, use `storage: { naming: 'exact' }`.
2409
+
2289
2410
  #### Virtual Fields
2290
2411
  Virtual fields are excluded from database operations but can be used for temporary UI state:
2291
2412
 
@@ -325,6 +325,8 @@ const result = await api.resources.countries.post({
325
325
  });
326
326
  ```
327
327
 
328
+ If you need per-call options such as `transaction`, `returnFullRecord`, or `queryParams`, move the record under `inputRecord` and keep those options at the top level of the params object.
329
+
328
330
  By default, `simplifiedApi` is `true` for programmatic usage, making it easier to work with the API in your code while still maintaining full JSON:API compliance internally.
329
331
 
330
332
  #### API usage and returning records
@@ -778,6 +780,7 @@ Here is a full list of parameters and their respective variables:
778
780
  | `simplifiedApi` | `vars.simplifiedApi` | `true` | Use simplified format for programmatic API calls | ✓ |
779
781
  | `simplifiedTransport` | `vars.simplifiedTransport` | `false` | Use simplified format for HTTP/REST endpoints | ✓ |
780
782
  | `idProperty` | `vars.idProperty` | `'id'` | Name of the ID field in resources | ✓ |
783
+ | `normalizeId` | `vars.normalizeId` | trim strings, stringify finite numbers | Normalize resource identifiers before lookups and writes | ✓ |
781
784
  | `returnRecordApi` | `vars.returnRecordApi` | `{ post: 'full', put: 'full', patch: 'full' }` | What to return for programmatic API write operations | ✓ |
782
785
  | `returnRecordTransport` | `vars.returnRecordTransport` | `{ post: 'no', put: 'no', patch: 'no' }` | What to return for HTTP/REST write operations | ✓ |
783
786
 
@@ -795,9 +798,79 @@ Here is a full list of parameters and their respective variables:
795
798
  - An object: `{ post: 'full', put: 'minimal', patch: 'no' }` (per-method configuration)
796
799
  - All parameters support the cascade: per-call → resource-level → plugin-level default
797
800
 
798
- # Custom ID parameter
801
+ # Logical IDs and Physical Columns
802
+
803
+ For table-backed resources, `idProperty` defines the physical primary-key column. The API surface still uses the logical resource id.
804
+
805
+ - simplified writes use `inputRecord.id`
806
+ - JSON:API writes use `inputRecord.data.id`
807
+ - responses expose `id` or `data.id`
808
+ - the resource id is not part of `attributes`
809
+
810
+ ```javascript
811
+ await api.addResource('profiles', {
812
+ idProperty: 'user_id',
813
+ schema: {
814
+ id: { type: 'id', required: true, storage: { column: 'user_id' } },
815
+ displayName: { type: 'string', required: true },
816
+ loginCount: { type: 'number', defaultTo: 0 }
817
+ },
818
+ tableName: 'profiles'
819
+ });
820
+ ```
821
+
822
+ For table-backed resources, storage columns default to snake_case, so `displayName` maps to `display_name` and `loginCount` maps to `login_count`. Use `storage.column` for a per-field override, or `storage: { naming: 'exact' }` when physical column names should match logical field names exactly.
823
+
824
+ ## ID normalization
825
+
826
+ `normalizeId` lets you canonicalize resource identifiers before the library reads, writes, validates, or links records.
827
+
828
+ The default normalizer already handles the common cases:
829
+
830
+ - trims surrounding whitespace from strings
831
+ - converts finite numbers and bigints to strings
832
+ - rejects values that normalize to an empty identifier
833
+
834
+ You only need to provide `normalizeId` when your resource ids need additional canonicalization, such as uppercasing, lowercasing, or removing formatting characters.
835
+
836
+ ```javascript
837
+ await api.use(RestApiPlugin, {
838
+ normalizeId: (value) => {
839
+ if (value === null || value === undefined) return null
840
+
841
+ const normalized = String(value).trim()
842
+ return normalized ? normalized.toUpperCase() : null
843
+ }
844
+ })
845
+
846
+ await api.addResource('countries', {
847
+ // Override the plugin-wide normalizer for this one resource
848
+ normalizeId: (value) => {
849
+ if (value === null || value === undefined) return null
850
+
851
+ const normalized = String(value).trim()
852
+ return normalized ? normalized.toLowerCase() : null
853
+ },
854
+ schema: {
855
+ name: { type: 'string', required: true }
856
+ }
857
+ })
858
+ ```
859
+
860
+ Normalization is applied consistently to:
861
+
862
+ - `get`, `put`, `patch`, `delete`, `getRelated`, and `getRelationship` parent ids
863
+ - explicit resource ids in `POST`, `PUT`, and `PATCH` documents
864
+ - relationship identifiers supplied to `postRelationship`, `patchRelationship`, and `deleteRelationship`
865
+
866
+ The resource-level `normalizeId` override also applies to relationship identifiers when that resource appears as the related target.
867
+
868
+ If an id normalizes to an empty value:
869
+
870
+ - existing-resource operations behave as `not_found`
871
+ - explicit `POST` document ids are rejected as validation errors on `data.id`
799
872
 
800
- TODO: Explain how idParam works, clarify that for the api it's always 'id' and there is no ID in the attributes
873
+ For full examples of table creation, physical column naming, and migration generation, see [Knex Schema and Migrations](GUIDE_X_Knex_Schema_And_Migrations.md).
801
874
 
802
875
  # Helpers and Methods Provided by REST API Plugins
803
876
 
@@ -828,8 +901,14 @@ When you install the REST API plugins, the following helpers are added to `api.h
828
901
  const trx = await api.helpers.newTransaction();
829
902
  try {
830
903
  // Use transaction in multiple operations
831
- await api.resources.countries.post({ name: 'France', code: 'FR' }, { transaction: trx });
832
- await api.resources.publishers.post({ name: 'French Press', country: 1 }, { transaction: trx });
904
+ await api.resources.countries.post({
905
+ inputRecord: { name: 'France', code: 'FR' },
906
+ transaction: trx
907
+ });
908
+ await api.resources.publishers.post({
909
+ inputRecord: { name: 'French Press', country: 1 },
910
+ transaction: trx
911
+ });
833
912
  await trx.commit();
834
913
  } catch (error) {
835
914
  await trx.rollback();
@@ -29,8 +29,8 @@ Example:
29
29
  await api.addResource('memberships', {
30
30
  schema: {
31
31
  id: { type: 'id' },
32
- workspaceId: { type: 'id', required: true, storage: { column: 'workspace_id' } },
33
- userId: { type: 'id', required: true, storage: { column: 'user_id' } },
32
+ workspaceId: { type: 'id', required: true },
33
+ userId: { type: 'id', required: true },
34
34
  role: { type: 'string', enum: ['owner', 'member'], required: true, defaultTo: 'member' }
35
35
  },
36
36
 
@@ -64,6 +64,126 @@ await api.addResource('memberships', {
64
64
  })
65
65
  ```
66
66
 
67
+ ## Default Column Naming
68
+
69
+ Table-backed resources keep logical field names in the resource schema and map them to physical columns for Knex operations.
70
+
71
+ By default, physical columns use snake_case:
72
+
73
+ - `workspaceId` -> `workspace_id`
74
+ - `userId` -> `user_id`
75
+ - `createdAt` -> `created_at`
76
+
77
+ This keeps the API surface expressive without repeating `storage.column` on every camelCase field.
78
+
79
+ You only need `storage.column` when a field should use a non-standard column name:
80
+
81
+ ```js
82
+ await api.addResource('profiles', {
83
+ schema: {
84
+ id: { type: 'id' },
85
+ displayName: { type: 'string', required: true },
86
+ legacyRef: { type: 'string', storage: { column: 'legacy_profile_ref' } }
87
+ },
88
+ tableName: 'profiles'
89
+ })
90
+ ```
91
+
92
+ If you need physical columns to match the logical field names exactly for a whole resource, opt out explicitly:
93
+
94
+ ```js
95
+ await api.addResource('verbatim_profiles', {
96
+ storage: { naming: 'exact' },
97
+ schema: {
98
+ id: { type: 'id' },
99
+ displayName: { type: 'string', required: true }
100
+ },
101
+ tableName: 'verbatim_profiles'
102
+ })
103
+ ```
104
+
105
+ ## Logical IDs and `idProperty`
106
+
107
+ `idProperty` names the physical primary-key column for table-backed resources. The API contract still uses the logical resource id.
108
+
109
+ ```js
110
+ await api.addResource('profiles', {
111
+ idProperty: 'user_id',
112
+ schema: {
113
+ id: { type: 'id', required: true, storage: { column: 'user_id' } },
114
+ displayName: { type: 'string', required: true },
115
+ loginCount: { type: 'number', defaultTo: 0 }
116
+ },
117
+ tableName: 'profiles'
118
+ })
119
+ ```
120
+
121
+ With this definition:
122
+
123
+ - writes send the resource id as `id` or `data.id`
124
+ - reads return the resource id as `id` or `data.id`
125
+ - the primary key is stored in `user_id`
126
+ - `displayName` and `loginCount` are stored in `display_name` and `login_count`
127
+
128
+ The resource id is not part of `attributes`:
129
+
130
+ ```js
131
+ await api.resources.profiles.post({
132
+ inputRecord: {
133
+ data: {
134
+ type: 'profiles',
135
+ id: '42',
136
+ attributes: {
137
+ displayName: 'Mercury'
138
+ }
139
+ }
140
+ }
141
+ })
142
+ ```
143
+
144
+ ### ID normalization
145
+
146
+ If your primary keys need canonicalization beyond the default trim-and-stringify behavior, configure `normalizeId`.
147
+
148
+ ```js
149
+ await api.use(RestApiPlugin, {
150
+ normalizeId: (value) => {
151
+ if (value === null || value === undefined) return null
152
+
153
+ const normalized = String(value).trim()
154
+ return normalized ? normalized.toUpperCase() : null
155
+ }
156
+ })
157
+ ```
158
+
159
+ You can override that per resource:
160
+
161
+ ```js
162
+ await api.addResource('profiles', {
163
+ idProperty: 'user_id',
164
+ normalizeId: (value) => {
165
+ if (value === null || value === undefined) return null
166
+
167
+ const normalized = String(value).trim()
168
+ return normalized ? normalized.toLowerCase() : null
169
+ },
170
+ schema: {
171
+ id: { type: 'id', required: true, storage: { column: 'user_id' } },
172
+ displayName: { type: 'string', required: true }
173
+ },
174
+ tableName: 'profiles'
175
+ })
176
+ ```
177
+
178
+ For table-backed resources, `normalizeId` is applied before:
179
+
180
+ - reading a record by id
181
+ - replacing, patching, or deleting a record
182
+ - persisting an explicit `POST` resource id
183
+ - validating or writing relationship identifiers that point at the resource
184
+
185
+ If the normalizer returns an empty value, existing-resource operations fail as `not_found`, while explicit `POST` document ids fail validation on `data.id`.
186
+
67
187
  ## Create Tables
68
188
 
69
189
  Create the table directly from the resource definition:
@@ -3,7 +3,7 @@
3
3
  * Wraps any logger to provide comprehensive error logging
4
4
  */
5
5
 
6
- import { formatError, formatErrorSummary, formatErrorString } from './error-formatter.js'
6
+ import { formatError, formatErrorSummary } from './error-formatter.js'
7
7
 
8
8
  /**
9
9
  * Creates an enhanced logger that automatically formats errors
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "test": "node --test tests/*.test.js",
8
8
  "test:anyapi": "JSON_REST_API_STORAGE=anyapi node --test tests/*.test.js",
9
+ "test:id-contracts": "node --test tests/id-normalization.test.js tests/logical-id-write-validation.test.js tests/custom-id-simplified-post.test.js",
10
+ "test:id-contracts:anyapi": "JSON_REST_API_STORAGE=anyapi node --test tests/id-normalization.test.js",
9
11
  "docs": "node scripts/run-docs.js",
12
+ "docs:dev": "node scripts/run-docs.js --serve",
13
+ "verify": "npm run test:id-contracts && npm run test:id-contracts:anyapi && npm run docs",
10
14
  "sloc": "sloc --exclude \"(_site|node_modules|\\.git|vendor|docs)\" .",
11
15
  "slod": "cd docs;echo 'DOC LINES:'; cat `find . -iname \\*md | grep -v _site | grep -v vendor` | wc",
12
16
  "lint": "eslint .",
@@ -25,7 +29,7 @@
25
29
  "description": "REST API plugin for hooked-api with JSON:API compliance",
26
30
  "dependencies": {
27
31
  "hooked-api": "^1.0.24",
28
- "json-rest-schema": "^1.0.13",
32
+ "json-rest-schema": "^1.0.14",
29
33
  "semver": "^7.7.2"
30
34
  },
31
35
  "peerDependencies": {