json-rest-api 1.0.28 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +746 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +634 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +36 -0
  44. package/index.js +1 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +64 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -174
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +20 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +142 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1069
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -385
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +38 -69
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +39 -82
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -150
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -118
  251. package/tests/fixtures/api-configs.js +0 -2509
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-cursor-multifield.test.js +0 -643
  264. package/tests/pagination-enhanced.test.js +0 -563
  265. package/tests/polymorphic-relationships.test.js +0 -347
  266. package/tests/polymorphic-simple.test.js +0 -148
  267. package/tests/positioning-advanced.test.js +0 -692
  268. package/tests/positioning-extended.test.js +0 -915
  269. package/tests/positioning-multifilter.test.js +0 -699
  270. package/tests/positioning.test.js +0 -560
  271. package/tests/post-commit-hooks.test.js +0 -120
  272. package/tests/projected-fields.test.js +0 -319
  273. package/tests/public-exports.test.js +0 -15
  274. package/tests/put-create-parity.test.js +0 -216
  275. package/tests/queries.test.js +0 -521
  276. package/tests/query-limits.test.js +0 -584
  277. package/tests/relationship-endpoints.test.js +0 -620
  278. package/tests/relationship-include-storage-adapter.test.js +0 -56
  279. package/tests/relationships.test.js +0 -681
  280. package/tests/rest-api.test.js +0 -337
  281. package/tests/return-record-settings.test.js +0 -184
  282. package/tests/row-policy.test.js +0 -489
  283. package/tests/s3-storage.test.js +0 -38
  284. package/tests/searchschema-knex-behavior.test.js +0 -187
  285. package/tests/searchschema-merge.test.js +0 -151
  286. package/tests/socketio.test.js +0 -860
  287. package/tests/storage-mapping.test.js +0 -362
  288. package/tests/test-development-guide.md +0 -370
  289. package/tests/virtual-fields.test.js +0 -404
@@ -1,2509 +0,0 @@
1
- import { Api } from 'hooked-api'
2
- import {
3
- AutoFilterPlugin,
4
- FileHandlingPlugin,
5
- QueryProjectionsPlugin,
6
- RestApiAnyapiKnexPlugin,
7
- RestApiKnexPlugin,
8
- RestApiPlugin,
9
- RowPolicyPlugin,
10
- SocketIOPlugin
11
- } from '../../index.js'
12
- import { ExpressPlugin } from '../../plugins/core/connectors/express-plugin.js'
13
- import express from 'express'
14
- import { createServer } from 'http'
15
- import { ensureAnyApiSchema } from '../../plugins/core/lib/anyapi/schema-utils.js'
16
- import { storageMode } from '../helpers/storage-mode.js'
17
-
18
- async function resetAnyApiTables (knex) {
19
- if (!storageMode.isAnyApi()) return
20
- await knex('any_links').delete().catch(() => {})
21
- await knex('any_records').delete().catch(() => {})
22
- await knex('any_relationship_configs').delete().catch(() => {})
23
- await knex('any_field_configs').delete().catch(() => {})
24
- await knex('any_resource_configs').delete().catch(() => {})
25
- }
26
-
27
- async function useStoragePlugin (api, knex, { tenantId } = {}) {
28
- if (storageMode.isAnyApi()) {
29
- await ensureAnyApiSchema(knex)
30
- const sanitizedName = api?.name ? api.name.replace(/[^A-Za-z0-9]+/g, '_').toLowerCase() : 'tenant'
31
- const effectiveTenantId = tenantId || api?.anyapi?.tenantId || `${sanitizedName}_tenant`
32
- api.anyapi = api.anyapi || {}
33
- api.anyapi.tenantId = effectiveTenantId
34
- await api.use(RestApiAnyapiKnexPlugin, { knex, tenantId: effectiveTenantId })
35
- storageMode.setCurrentTenant(effectiveTenantId)
36
- } else {
37
- await api.use(RestApiKnexPlugin, { knex })
38
- }
39
- }
40
-
41
- function mapTable (tableName, resourceName) {
42
- if (storageMode.isAnyApi()) {
43
- storageMode.registerTable(tableName, resourceName)
44
- }
45
- }
46
-
47
- async function withTenantContext (tenantId, fn) {
48
- if (!storageMode.isAnyApi()) {
49
- return await fn()
50
- }
51
-
52
- const previousTenant = storageMode.currentTenant
53
- storageMode.setCurrentTenant(tenantId || storageMode.defaultTenant)
54
- try {
55
- return await fn()
56
- } finally {
57
- storageMode.setCurrentTenant(previousTenant)
58
- }
59
- }
60
-
61
- /**
62
- * Creates a basic API configuration with Countries, Publishers, Authors, Books
63
- */
64
- export async function createBasicApi (knex, pluginOptions = {}) {
65
- const apiName = pluginOptions.apiName || 'basic-test-api'
66
- const tablePrefix = pluginOptions.tablePrefix || 'basic'
67
- if (storageMode.isAnyApi()) {
68
- storageMode.clearRegistry()
69
- }
70
- const api = new Api({
71
- name: apiName,
72
- log: { level: process.env.LOG_LEVEL || 'info' }
73
- })
74
-
75
- const previousTenant = storageMode.currentTenant
76
- const tenantId = storageMode.isAnyApi()
77
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
78
- : storageMode.defaultTenant
79
- if (storageMode.isAnyApi()) {
80
- storageMode.setCurrentTenant(tenantId)
81
- }
82
-
83
- const restApiOptions = {
84
- simplifiedApi: false,
85
- simplifiedTransport: false,
86
- returnRecordApi: {
87
- post: true, // Need to return record to get ID for tests
88
- put: false,
89
- patch: false
90
- },
91
- returnRecordTransport: {
92
- post: 'full', // Tests expect full JSON:API response
93
- put: 'no',
94
- patch: 'minimal' // Tests expect minimal response with ID
95
- },
96
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'name', 'code'],
97
- ...pluginOptions['rest-api'] // Merge any custom options for rest-api plugin
98
- }
99
-
100
- await api.use(RestApiPlugin, restApiOptions)
101
- await useStoragePlugin(api, knex, { tenantId })
102
- await resetAnyApiTables(knex)
103
-
104
- // Add Express plugin if requested
105
- if (pluginOptions.includeExpress) {
106
- await api.use(ExpressPlugin, {
107
- mountPath: '/api', // Default mount path for tests
108
- ...(pluginOptions.express || {})
109
- })
110
- }
111
-
112
- try {
113
- // Countries table
114
- await api.addResource('countries', {
115
- schema: {
116
- id: { type: 'id' },
117
- name: { type: 'string', required: true, max: 100, search: true },
118
- code: { type: 'string', max: 2, unique: true }
119
- },
120
- relationships: {
121
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
122
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
123
- },
124
- tableName: `${tablePrefix}_countries`
125
- })
126
- await api.resources.countries.createKnexTable()
127
- mapTable(`${tablePrefix}_countries`, 'countries')
128
-
129
- // Publishers table
130
- await api.addResource('publishers', {
131
- schema: {
132
- id: { type: 'id' },
133
- name: { type: 'string', required: true, max: 200 },
134
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
135
- },
136
- relationships: {
137
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
138
- authors: { type: 'hasMany', target: 'authors', foreignKey: 'publisher_id' }
139
- },
140
- tableName: `${tablePrefix}_publishers`
141
- })
142
- await api.resources.publishers.createKnexTable()
143
- mapTable(`${tablePrefix}_publishers`, 'publishers')
144
-
145
- // Authors table
146
- await api.addResource('authors', {
147
- schema: {
148
- id: { type: 'id' },
149
- name: { type: 'string', required: true, max: 200 },
150
- publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher' }
151
- },
152
- relationships: {
153
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' }
154
- },
155
- tableName: `${tablePrefix}_authors`
156
- })
157
- await api.resources.authors.createKnexTable()
158
- mapTable(`${tablePrefix}_authors`, 'authors')
159
-
160
- // Books table
161
- await api.addResource('books', {
162
- schema: {
163
- id: { type: 'id' },
164
- title: { type: 'string', required: true, max: 300, search: true },
165
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
166
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
167
- },
168
- relationships: {
169
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' }
170
- },
171
- tableName: `${tablePrefix}_books`
172
- })
173
- await api.resources.books.createKnexTable()
174
- mapTable(`${tablePrefix}_books`, 'books')
175
-
176
- // Book-Authors pivot table
177
- await api.addResource('book_authors', {
178
- schema: {
179
- id: { type: 'id' },
180
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
181
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' }
182
- },
183
- tableName: `${tablePrefix}_book_authors`
184
- })
185
- await api.resources.book_authors.createKnexTable()
186
- mapTable(`${tablePrefix}_book_authors`, 'book_authors')
187
- if (storageMode.isAnyApi()) {
188
- const descriptorTenant = api.anyapi?.tenantId || tenantId
189
- const booksDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'books')
190
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'authors')
191
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
192
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
193
- storageMode.registerLink(
194
- `${tablePrefix}_book_authors`,
195
- 'books',
196
- 'authors',
197
- relationshipKey,
198
- inverseRelationshipKey
199
- )
200
- }
201
-
202
- return api
203
- } finally {
204
- if (storageMode.isAnyApi()) {
205
- storageMode.setCurrentTenant(previousTenant)
206
- }
207
- }
208
- }
209
-
210
- export async function createReturnRecordApi (knex, pluginOptions = {}) {
211
- const apiName = pluginOptions.apiName || 'return-record-test-api'
212
- const tablePrefix = pluginOptions.tablePrefix || 'return'
213
- if (storageMode.isAnyApi()) {
214
- storageMode.clearRegistry()
215
- }
216
-
217
- const api = new Api({
218
- name: apiName,
219
- log: { level: process.env.LOG_LEVEL || 'info' }
220
- })
221
-
222
- const previousTenant = storageMode.currentTenant
223
- const tenantId = storageMode.isAnyApi()
224
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
225
- : storageMode.defaultTenant
226
- if (storageMode.isAnyApi()) {
227
- storageMode.setCurrentTenant(tenantId)
228
- }
229
-
230
- await api.use(RestApiPlugin, {
231
- simplifiedApi: false,
232
- simplifiedTransport: false,
233
- returnRecordApi: {
234
- post: true,
235
- put: false,
236
- patch: false
237
- },
238
- returnRecordTransport: {
239
- post: false,
240
- put: true,
241
- patch: 'minimal'
242
- },
243
- sortableFields: ['id', 'name'],
244
- ...pluginOptions['rest-api']
245
- })
246
- await useStoragePlugin(api, knex, { tenantId })
247
- await resetAnyApiTables(knex)
248
-
249
- try {
250
- await api.addResource('global_items', {
251
- schema: {
252
- id: { type: 'id' },
253
- name: { type: 'string', required: true, max: 100 }
254
- },
255
- tableName: `${tablePrefix}_global_items`
256
- })
257
- await api.resources.global_items.createKnexTable()
258
- mapTable(`${tablePrefix}_global_items`, 'global_items')
259
-
260
- await api.addResource('scope_items', {
261
- schema: {
262
- id: { type: 'id' },
263
- name: { type: 'string', required: true, max: 100 }
264
- },
265
- tableName: `${tablePrefix}_scope_items`,
266
- returnRecordApi: {
267
- post: false,
268
- put: true,
269
- patch: 'minimal'
270
- },
271
- returnRecordTransport: true
272
- })
273
- await api.resources.scope_items.createKnexTable()
274
- mapTable(`${tablePrefix}_scope_items`, 'scope_items')
275
- } finally {
276
- if (storageMode.isAnyApi()) {
277
- storageMode.setCurrentTenant(previousTenant)
278
- }
279
- }
280
-
281
- return api
282
- }
283
-
284
- /**
285
- * Creates a small API that uses logical camelCase belongsTo fields with default snake_case storage mapping.
286
- * This reproduces the full JSON:API linkage path where storage column names differ from logical field names.
287
- */
288
- export async function createCamelCaseBelongsToApi (knex, pluginOptions = {}) {
289
- const apiName = pluginOptions.apiName || 'camelcase-belongsto-test-api'
290
- const tablePrefix = pluginOptions.tablePrefix || 'camel_fk'
291
-
292
- const api = new Api({
293
- name: apiName,
294
- log: { level: process.env.LOG_LEVEL || 'info' }
295
- })
296
-
297
- await api.use(RestApiPlugin, {
298
- simplifiedApi: false,
299
- simplifiedTransport: false,
300
- returnRecordApi: {
301
- post: true,
302
- put: false,
303
- patch: false
304
- },
305
- returnRecordTransport: {
306
- post: 'full',
307
- put: 'no',
308
- patch: 'full'
309
- },
310
- ...(pluginOptions['rest-api'] || {})
311
- })
312
-
313
- await api.use(RestApiKnexPlugin, { knex })
314
-
315
- await api.addResource('countries', {
316
- schema: {
317
- id: { type: 'id' },
318
- name: { type: 'string', required: true, max: 100 }
319
- },
320
- tableName: `${tablePrefix}_countries`
321
- })
322
- await api.resources.countries.createKnexTable()
323
-
324
- await api.addResource('publishers', {
325
- schema: {
326
- id: { type: 'id' },
327
- name: { type: 'string', required: true, max: 200 },
328
- countryId: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
329
- },
330
- tableName: `${tablePrefix}_publishers`
331
- })
332
- await api.resources.publishers.createKnexTable()
333
-
334
- return api
335
- }
336
-
337
- export async function createRelationshipIncludeStorageAdapterApi (knex) {
338
- const api = new Api({
339
- name: 'relationship-include-storage-adapter-test',
340
- log: { level: process.env.LOG_LEVEL || 'info' }
341
- })
342
-
343
- await api.use(RestApiPlugin, {
344
- simplifiedApi: false,
345
- simplifiedTransport: false
346
- })
347
- await api.use(RestApiKnexPlugin, { knex })
348
-
349
- await knex.schema.createTable('uninitialized_pets', table => {
350
- table.increments('id').primary()
351
- table.string('first_name').notNullable()
352
- table.string('last_name').notNullable()
353
- })
354
- await knex.schema.createTable('uninitialized_bookings', table => {
355
- table.increments('id').primary()
356
- table.string('reference').notNullable()
357
- table.integer('pet_id').notNullable()
358
- })
359
-
360
- await api.addResource('pets', {
361
- schema: {
362
- id: { type: 'id' },
363
- firstName: { type: 'string', required: true },
364
- lastName: { type: 'string', required: true }
365
- },
366
- tableName: 'uninitialized_pets'
367
- })
368
-
369
- await api.addResource('bookings', {
370
- schema: {
371
- id: { type: 'id' },
372
- reference: { type: 'string', required: true },
373
- petId: {
374
- type: 'id',
375
- required: true,
376
- belongsTo: 'pets',
377
- as: 'pet'
378
- }
379
- },
380
- tableName: 'uninitialized_bookings'
381
- })
382
-
383
- return api
384
- }
385
-
386
- export async function seedRelationshipIncludeStorageAdapterApi (knex) {
387
- await knex('uninitialized_pets').insert({
388
- id: 7,
389
- first_name: 'Coco',
390
- last_name: 'Spaniel'
391
- })
392
- await knex('uninitialized_bookings').insert({
393
- id: 41,
394
- reference: 'BOOK-41',
395
- pet_id: 7
396
- })
397
- }
398
-
399
- /**
400
- * Creates a small API focused on resource ID normalization behavior.
401
- */
402
- export async function createIdNormalizationApi (knex, pluginOptions = {}) {
403
- const apiName = pluginOptions.apiName || 'id-normalization-test-api'
404
- const tablePrefix = pluginOptions.tablePrefix || 'id_norm'
405
- if (storageMode.isAnyApi()) {
406
- storageMode.clearRegistry()
407
- }
408
-
409
- const api = new Api({
410
- name: apiName,
411
- log: { level: process.env.LOG_LEVEL || 'info' }
412
- })
413
-
414
- const previousTenant = storageMode.currentTenant
415
- const tenantId = storageMode.isAnyApi()
416
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
417
- : storageMode.defaultTenant
418
- if (storageMode.isAnyApi()) {
419
- storageMode.setCurrentTenant(tenantId)
420
- }
421
-
422
- await api.use(RestApiPlugin, {
423
- simplifiedApi: false,
424
- simplifiedTransport: false,
425
- returnRecordApi: {
426
- post: true,
427
- put: false,
428
- patch: false
429
- },
430
- returnRecordTransport: {
431
- post: 'full',
432
- put: 'no',
433
- patch: 'minimal'
434
- },
435
- ...(pluginOptions['rest-api'] || {})
436
- })
437
-
438
- try {
439
- await withTenantContext(tenantId, async () => {
440
- await useStoragePlugin(api, knex, { tenantId })
441
- await resetAnyApiTables(knex)
442
-
443
- await api.addResource('countries', {
444
- schema: {
445
- id: { type: 'string', required: true },
446
- name: { type: 'string', required: true, max: 100 }
447
- },
448
- tableName: `${tablePrefix}_countries`,
449
- ...(pluginOptions.countryResourceOptions || {})
450
- })
451
- await knex.schema.dropTableIfExists(`${tablePrefix}_countries`)
452
- await knex.schema.createTable(`${tablePrefix}_countries`, (table) => {
453
- table.string('id').primary()
454
- table.string('name').notNullable()
455
- })
456
- mapTable(`${tablePrefix}_countries`, 'countries')
457
-
458
- await api.addResource('publishers', {
459
- schema: {
460
- id: { type: 'string', required: true },
461
- name: { type: 'string', required: true, max: 100 },
462
- country_id: { type: 'string', nullable: true, belongsTo: 'countries', as: 'country' }
463
- },
464
- relationships: {
465
- tags: { type: 'manyToMany', through: 'publisher_tags', foreignKey: 'publisher_id', otherKey: 'tag_id' }
466
- },
467
- tableName: `${tablePrefix}_publishers`,
468
- ...(pluginOptions.publisherResourceOptions || {})
469
- })
470
- await knex.schema.dropTableIfExists(`${tablePrefix}_publishers`)
471
- await knex.schema.createTable(`${tablePrefix}_publishers`, (table) => {
472
- table.string('id').primary()
473
- table.string('name').notNullable()
474
- table.string('country_id').nullable()
475
- })
476
- mapTable(`${tablePrefix}_publishers`, 'publishers')
477
-
478
- await api.addResource('tags', {
479
- schema: {
480
- id: { type: 'string', required: true },
481
- name: { type: 'string', required: true, max: 100 }
482
- },
483
- tableName: `${tablePrefix}_tags`,
484
- ...(pluginOptions.tagResourceOptions || {})
485
- })
486
- await knex.schema.dropTableIfExists(`${tablePrefix}_tags`)
487
- await knex.schema.createTable(`${tablePrefix}_tags`, (table) => {
488
- table.string('id').primary()
489
- table.string('name').notNullable()
490
- })
491
- mapTable(`${tablePrefix}_tags`, 'tags')
492
-
493
- await api.addResource('publisher_tags', {
494
- schema: {
495
- id: { type: 'id' },
496
- publisher_id: { type: 'string', required: true, belongsTo: 'publishers', as: 'publisher' },
497
- tag_id: { type: 'string', required: true, belongsTo: 'tags', as: 'tag' }
498
- },
499
- tableName: `${tablePrefix}_publisher_tags`
500
- })
501
- await knex.schema.dropTableIfExists(`${tablePrefix}_publisher_tags`)
502
- await knex.schema.createTable(`${tablePrefix}_publisher_tags`, (table) => {
503
- table.increments('id').primary()
504
- table.string('publisher_id').notNullable()
505
- table.string('tag_id').notNullable()
506
- })
507
- mapTable(`${tablePrefix}_publisher_tags`, 'publisher_tags')
508
- if (storageMode.isAnyApi()) {
509
- const descriptorTenant = api.anyapi?.tenantId || tenantId
510
- const publishersDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'publishers')
511
- const tagsDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'tags')
512
- const relationshipKey = publishersDescriptor?.manyToMany?.tags?.relationship
513
- const inverseRelationshipKey = tagsDescriptor?.manyToMany?.publishers?.relationship
514
- storageMode.registerLink(
515
- `${tablePrefix}_publisher_tags`,
516
- 'publishers',
517
- 'tags',
518
- relationshipKey,
519
- inverseRelationshipKey
520
- )
521
- }
522
- })
523
-
524
- return api
525
- } finally {
526
- if (storageMode.isAnyApi()) {
527
- storageMode.setCurrentTenant(previousTenant)
528
- }
529
- }
530
- }
531
-
532
- /**
533
- * Creates a basic API with bulk operations enabled
534
- */
535
- export async function createBulkOperationsApi (knex, pluginOptions = {}) {
536
- const { BulkOperationsPlugin } = await import('../../plugins/core/bulk-operations-plugin.js')
537
-
538
- const api = await createBasicApi(knex, {
539
- ...pluginOptions,
540
- tenantId: pluginOptions.tenantId || 'bulk_ops_tenant'
541
- })
542
-
543
- // Add bulk operations plugin
544
- await api.use(BulkOperationsPlugin, {
545
- 'bulk-operations': {
546
- maxBulkOperations: 100,
547
- defaultAtomic: true,
548
- batchSize: 10,
549
- enableOptimizations: true,
550
- ...pluginOptions['bulk-operations']
551
- }
552
- })
553
-
554
- return api
555
- }
556
-
557
- /**
558
- * Creates an extended API with additional fields for more complex testing
559
- */
560
- export async function createExtendedApi (knex) {
561
- const api = new Api({
562
- name: 'extended-test-api',
563
- })
564
-
565
- const tenantId = storageMode.isAnyApi() ? 'extended_tenant' : storageMode.defaultTenant
566
-
567
- await api.use(RestApiPlugin, {
568
- simplifiedApi: false,
569
- simplifiedTransport: false,
570
- returnRecordApi: {
571
- post: true, // Need to return record to get ID for tests
572
- put: false,
573
- patch: false
574
- },
575
- returnRecordTransport: {
576
- post: 'full', // Tests expect full JSON:API response
577
- put: 'no',
578
- patch: 'minimal' // Tests expect minimal response with ID
579
- },
580
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'price', 'language', 'population', 'name', 'code']
581
- })
582
- await withTenantContext(tenantId, async () => {
583
- await useStoragePlugin(api, knex, { tenantId })
584
- await resetAnyApiTables(knex)
585
-
586
- // Countries with extended fields
587
- await api.addResource('countries', {
588
- schema: {
589
- id: { type: 'id' },
590
- name: { type: 'string', required: true, max: 100 },
591
- code: { type: 'string', max: 2, unique: true },
592
- capital: { type: 'string', max: 100 },
593
- population: { type: 'number' },
594
- currency: { type: 'string', max: 3 }
595
- },
596
- relationships: {
597
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
598
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' },
599
- authors: { type: 'hasMany', target: 'authors', foreignKey: 'nationality_id' }
600
- },
601
- tableName: 'ext_countries'
602
- })
603
- await api.resources.countries.createKnexTable()
604
- mapTable('ext_countries', 'countries')
605
-
606
- // Publishers with extended fields
607
- await api.addResource('publishers', {
608
- schema: {
609
- id: { type: 'id' },
610
- name: { type: 'string', required: true, max: 200 },
611
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' },
612
- founded_year: { type: 'number' },
613
- website: { type: 'string', max: 255 },
614
- active: { type: 'boolean', default: true }
615
- },
616
- relationships: {
617
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
618
- reviews: {
619
- type: 'hasMany',
620
- target: 'reviews',
621
- via: 'reviewable'
622
- }
623
- },
624
- tableName: 'ext_publishers'
625
- })
626
- await api.resources.publishers.createKnexTable()
627
- mapTable('ext_publishers', 'publishers')
628
-
629
- // Authors with extended fields
630
- await api.addResource('authors', {
631
- schema: {
632
- id: { type: 'id' },
633
- name: { type: 'string', required: true, max: 200 },
634
- birth_date: { type: 'date' },
635
- biography: { type: 'string', max: 5000 },
636
- nationality_id: { type: 'number', belongsTo: 'countries', as: 'nationality' }
637
- },
638
- relationships: {
639
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' },
640
- reviews: {
641
- type: 'hasMany',
642
- target: 'reviews',
643
- via: 'reviewable'
644
- }
645
- },
646
- tableName: 'ext_authors'
647
- })
648
- await api.resources.authors.createKnexTable()
649
- mapTable('ext_authors', 'authors')
650
-
651
- // Books with extended fields
652
- await api.addResource('books', {
653
- schema: {
654
- id: { type: 'id' },
655
- title: { type: 'string', required: true, max: 300, search: true },
656
- isbn: { type: 'string', max: 13 },
657
- pages: { type: 'number' },
658
- price: { type: 'number', search: true }, // Store price as string for decimal precision
659
- published_date: { type: 'date' },
660
- language: { type: 'string', max: 2, default: 'en', search: true },
661
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
662
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
663
- },
664
- relationships: {
665
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' },
666
- reviews: {
667
- type: 'hasMany',
668
- target: 'reviews',
669
- via: 'reviewable'
670
- }
671
- },
672
- tableName: 'ext_books'
673
- })
674
- await api.resources.books.createKnexTable()
675
- mapTable('ext_books', 'books')
676
-
677
- // Book-Authors pivot with extended fields
678
- await api.addResource('book_authors', {
679
- schema: {
680
- id: { type: 'id' },
681
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
682
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' },
683
- contribution_type: { type: 'string', max: 50 },
684
- order: { type: 'number' }
685
- },
686
- tableName: 'ext_book_authors'
687
- })
688
- await api.resources.book_authors.createKnexTable()
689
- mapTable('ext_book_authors', 'book_authors')
690
- if (storageMode.isAnyApi()) {
691
- const booksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'books')
692
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'authors')
693
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
694
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
695
- storageMode.registerLink('ext_book_authors', 'books', 'authors', relationshipKey, inverseRelationshipKey)
696
- }
697
-
698
- // Polymorphic reviews (can go on authors, books and publishers)
699
- await api.addResource('reviews', {
700
- schema: {
701
- id: { type: 'id' },
702
- rating: { type: 'number', required: true, min: 1, max: 5 },
703
- title: { type: 'string', max: 200 },
704
- content: { type: 'string', required: true, max: 5000 },
705
- reviewer_name: { type: 'string', required: true, max: 100 },
706
- review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
707
- helpful_count: { type: 'number', default: 0 },
708
- reviewable_type: { type: 'string', required: true },
709
- reviewable_id: { type: 'number', required: true }
710
- },
711
- relationships: {
712
- reviewable: {
713
- belongsToPolymorphic: {
714
- types: ['books', 'authors', 'publishers'],
715
- typeField: 'reviewable_type',
716
- idField: 'reviewable_id'
717
- }
718
- }
719
- },
720
- tableName: 'ext_reviews'
721
- })
722
- await api.resources.reviews.createKnexTable()
723
- mapTable('ext_reviews', 'reviews')
724
- })
725
-
726
- return api
727
- }
728
-
729
- /**
730
- * Creates an API with limited include depth for testing depth validation
731
- * Uses 'limited_' prefix for all tables to avoid conflicts
732
- */
733
- export async function createLimitedDepthApi (knex) {
734
- const api = new Api({
735
- name: 'limited-depth-api',
736
- })
737
-
738
- const tenantId = storageMode.isAnyApi() ? 'limited_depth_tenant' : storageMode.defaultTenant
739
-
740
- await api.use(RestApiPlugin, {
741
- simplifiedApi: false,
742
- simplifiedTransport: false,
743
- returnRecordApi: {
744
- post: true,
745
- put: false,
746
- patch: false
747
- },
748
- returnRecordTransport: {
749
- post: 'full',
750
- put: 'no',
751
- patch: 'minimal'
752
- },
753
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'name', 'code'],
754
- includeDepthLimit: 2 // Key difference: limit is 2 instead of default 3
755
- })
756
- await withTenantContext(tenantId, async () => {
757
- await useStoragePlugin(api, knex, { tenantId })
758
-
759
- // Use different table names with 'limited_' prefix to avoid conflicts
760
- await api.addResource('countries', {
761
- schema: {
762
- id: { type: 'id' },
763
- name: { type: 'string', required: true, max: 100 },
764
- code: { type: 'string', max: 2, unique: true }
765
- },
766
- relationships: {
767
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
768
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
769
- },
770
- tableName: 'limited_countries',
771
- })
772
- await api.resources.countries.createKnexTable()
773
- mapTable('limited_countries', 'countries')
774
-
775
- await api.addResource('publishers', {
776
- schema: {
777
- id: { type: 'id' },
778
- name: { type: 'string', required: true, max: 200 },
779
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
780
- },
781
- relationships: {
782
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
783
- authors: { type: 'hasMany', target: 'authors', foreignKey: 'publisher_id' }
784
- },
785
- tableName: 'limited_publishers'
786
- })
787
- await api.resources.publishers.createKnexTable()
788
- mapTable('limited_publishers', 'publishers')
789
-
790
- await api.addResource('authors', {
791
- schema: {
792
- id: { type: 'id' },
793
- name: { type: 'string', required: true, max: 200 },
794
- publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher' }
795
- },
796
- relationships: {
797
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' }
798
- },
799
- tableName: 'limited_authors'
800
- })
801
- await api.resources.authors.createKnexTable()
802
-
803
- await api.addResource('books', {
804
- schema: {
805
- id: { type: 'id' },
806
- title: { type: 'string', required: true, max: 300, search: true },
807
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
808
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
809
- },
810
- relationships: {
811
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' }
812
- },
813
- tableName: 'limited_books'
814
- })
815
- await api.resources.books.createKnexTable()
816
-
817
- await api.addResource('book_authors', {
818
- schema: {
819
- id: { type: 'id' },
820
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
821
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' }
822
- },
823
- tableName: 'limited_book_authors'
824
- })
825
- await api.resources.book_authors.createKnexTable()
826
- mapTable('limited_book_authors', 'book_authors')
827
- if (storageMode.isAnyApi()) {
828
- const booksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'books')
829
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'authors')
830
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
831
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
832
- storageMode.registerLink('limited_book_authors', 'books', 'authors', relationshipKey, inverseRelationshipKey)
833
- }
834
- })
835
-
836
- return api
837
- }
838
-
839
- /**
840
- * Creates an API configuration for pagination testing
841
- */
842
- export async function createPaginationApi (knex, options = {}) {
843
- const api = new Api({
844
- name: 'pagination-test-api',
845
- })
846
-
847
- const tenantId = storageMode.isAnyApi() ? 'pagination_tenant' : storageMode.defaultTenant
848
-
849
- const restApiOptions = {
850
- simplifiedApi: false,
851
- simplifiedTransport: false,
852
- returnRecordApi: {
853
- post: true,
854
- put: false,
855
- patch: false
856
- },
857
- returnRecordTransport: {
858
- post: 'full',
859
- put: 'no',
860
- patch: 'minimal'
861
- },
862
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'name', 'code'],
863
- ...options // Allow overriding options likereturnBasePath, enablePaginationCounts
864
- }
865
-
866
- await api.use(RestApiPlugin, restApiOptions)
867
- await withTenantContext(tenantId, async () => {
868
- await useStoragePlugin(api, knex, { tenantId })
869
-
870
- // Countries table
871
- await api.addResource('countries', {
872
- schema: {
873
- id: { type: 'id' },
874
- name: { type: 'string', required: true, max: 100 },
875
- code: { type: 'string', max: 2, unique: true }
876
- },
877
- relationships: {
878
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
879
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
880
- },
881
- tableName: 'pagination_countries'
882
- })
883
- await api.resources.countries.createKnexTable()
884
- mapTable('pagination_countries', 'countries')
885
-
886
- // Publishers table
887
- await api.addResource('publishers', {
888
- schema: {
889
- id: { type: 'id' },
890
- name: { type: 'string', required: true, max: 200 },
891
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
892
- },
893
- relationships: {
894
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' }
895
- },
896
- tableName: 'pagination_publishers'
897
- })
898
- await api.resources.publishers.createKnexTable()
899
- mapTable('pagination_publishers', 'publishers')
900
-
901
- // Books table
902
- await api.addResource('books', {
903
- schema: {
904
- id: { type: 'id' },
905
- title: { type: 'string', required: true, max: 300, search: true },
906
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
907
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
908
- },
909
- tableName: 'pagination_books'
910
- })
911
- await api.resources.books.createKnexTable()
912
- mapTable('pagination_books', 'books')
913
- })
914
-
915
- return api
916
- }
917
-
918
- /**
919
- * Creates an API with WebSocket/Socket.IO support for testing
920
- */
921
- export async function createWebSocketApi (knex, pluginOptions = {}) {
922
- const { jwtVerify } = await import('jose')
923
-
924
- const api = await createBasicApi(knex, {
925
- ...pluginOptions,
926
- tenantId: pluginOptions.tenantId || 'socketio_tenant',
927
- includeExpress: true,
928
- express: {
929
- port: 0 // Let OS assign a port
930
- }
931
- })
932
-
933
- const socketioOptions = { ...(pluginOptions['socketio'] || {}) }
934
- const authOptions = { ...(socketioOptions.auth || {}) }
935
- const sharedSecret = authOptions.sharedSecret || socketioOptions.sharedSecret || 'test-secret-key'
936
-
937
- if (!authOptions.authenticate) {
938
- authOptions.authenticate = async ({ socket }) => {
939
- const token = socket.handshake.auth?.token
940
- if (!token) {
941
- throw new Error('Authentication required')
942
- }
943
-
944
- const encoder = new TextEncoder()
945
- const key = encoder.encode(sharedSecret)
946
- const { payload } = await jwtVerify(token, key)
947
-
948
- const roles = Array.isArray(payload.roles)
949
- ? payload.roles
950
- : payload.role ? [payload.role] : []
951
-
952
- const authContext = {
953
- userId: payload.userId || payload.sub,
954
- roles,
955
- token: payload
956
- }
957
-
958
- if (!authContext.userId) {
959
- throw new Error('Token is missing required user identifier')
960
- }
961
-
962
- return authContext
963
- }
964
- }
965
-
966
- if (authOptions.requireAuth === undefined) {
967
- authOptions.requireAuth = true
968
- }
969
-
970
- socketioOptions.auth = authOptions
971
-
972
- await api.use(SocketIOPlugin, socketioOptions)
973
-
974
- // Create and start Express server
975
- const app = express()
976
- api.http.express.app = app
977
-
978
- // Mount the API routes
979
- app.use('/api', api.http.express.router)
980
-
981
- // Create HTTP server
982
- const server = createServer(app)
983
-
984
- // Start Socket.IO server
985
- await api.startSocketServer(server)
986
-
987
- // Start listening
988
- await new Promise((resolve) => {
989
- server.listen(0, () => {
990
- resolve()
991
- })
992
- })
993
-
994
- return { api, server }
995
- }
996
-
997
- /**
998
- * Creates an API with computed fields for testing
999
- */
1000
- export async function createComputedFieldsApi (knex, pluginOptions = {}) {
1001
- const api = new Api({
1002
- name: 'computed-fields-test-api',
1003
- log: { level: process.env.LOG_LEVEL || 'info' }
1004
- })
1005
-
1006
- const tenantId = storageMode.isAnyApi() ? 'computed_fields_tenant' : storageMode.defaultTenant
1007
-
1008
- await api.use(RestApiPlugin, {
1009
- simplifiedApi: true,
1010
- simplifiedTransport: true
1011
- })
1012
-
1013
- await withTenantContext(tenantId, async () => {
1014
- await useStoragePlugin(api, knex, { tenantId })
1015
- await resetAnyApiTables(knex)
1016
-
1017
- // Products resource with computed fields
1018
- await api.addResource('products', {
1019
- schema: {
1020
- id: { type: 'id' },
1021
- name: { type: 'string', required: true, max: 255 },
1022
- price: { type: 'number', required: true, min: 0 },
1023
- cost: { type: 'number', required: true, min: 0, normallyHidden: true },
1024
- internal_notes: { type: 'string', normallyHidden: true },
1025
- profit_margin: {
1026
- type: 'number',
1027
- computed: true,
1028
- dependencies: ['price', 'cost'],
1029
- compute: ({ attributes }) => {
1030
- if (!attributes.price || attributes.price === 0) return 0
1031
- return Number(((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2))
1032
- }
1033
- },
1034
- profit_amount: {
1035
- type: 'number',
1036
- computed: true,
1037
- dependencies: ['price', 'cost'],
1038
- compute: ({ attributes }) => {
1039
- return Number((attributes.price - attributes.cost).toFixed(2))
1040
- }
1041
- },
1042
- calculated_at: {
1043
- type: 'dateTime',
1044
- temporalPrecision: 0,
1045
- computed: true,
1046
- compute: () => new Date('2026-08-25T23:45:01.987Z')
1047
- },
1048
- calculated_time: {
1049
- type: 'time',
1050
- temporalPrecision: 3,
1051
- computed: true,
1052
- compute: () => new Date('2026-08-25T23:45:01.987Z')
1053
- }
1054
- },
1055
- relationships: {
1056
- reviews: { type: 'hasMany', target: 'reviews', foreignKey: 'product_id' }
1057
- },
1058
- tableName: 'test_products'
1059
- })
1060
- await api.resources.products.createKnexTable()
1061
- mapTable('searchmerge_products', 'products')
1062
- mapTable('test_products', 'products')
1063
-
1064
- // Reviews resource with computed fields
1065
- await api.addResource('reviews', {
1066
- schema: {
1067
- id: { type: 'id' },
1068
- product_id: { type: 'number', required: true, belongsTo: 'products', as: 'product' },
1069
- reviewer_name: { type: 'string', required: true },
1070
- rating: { type: 'number', required: true, min: 1, max: 5 },
1071
- comment: { type: 'string', max: 1000 },
1072
- helpful_votes: { type: 'number', default: 0 },
1073
- total_votes: { type: 'number', default: 0 },
1074
- spam_score: { type: 'number', default: 0, normallyHidden: true },
1075
- helpfulness_score: {
1076
- type: 'number',
1077
- computed: true,
1078
- dependencies: ['helpful_votes', 'total_votes'],
1079
- compute: ({ attributes }) => {
1080
- if (attributes.total_votes === 0) return null
1081
- return Number(((attributes.helpful_votes / attributes.total_votes) * 100).toFixed(0))
1082
- }
1083
- },
1084
- is_helpful: {
1085
- type: 'boolean',
1086
- computed: true,
1087
- dependencies: ['helpful_votes', 'total_votes', 'spam_score'],
1088
- compute: ({ attributes }) => {
1089
- if (attributes.total_votes < 10) return null
1090
- const helpfulnessScore = (attributes.helpful_votes / attributes.total_votes) * 100
1091
- return helpfulnessScore > 70 && attributes.spam_score < 0.5
1092
- }
1093
- },
1094
- calculated_at: {
1095
- type: 'dateTime',
1096
- temporalPrecision: 0,
1097
- computed: true,
1098
- compute: () => new Date('2026-08-25T23:46:02.654Z')
1099
- }
1100
- },
1101
- tableName: 'test_reviews'
1102
- })
1103
- await api.resources.reviews.createKnexTable()
1104
- mapTable('test_reviews', 'reviews')
1105
- })
1106
-
1107
- return api
1108
- }
1109
-
1110
- /**
1111
- * Creates an API with field getters for testing
1112
- */
1113
- export async function createFieldGettersApi (knex, pluginOptions = {}) {
1114
- const api = new Api({
1115
- name: 'field-getters-test-api',
1116
- log: { level: process.env.LOG_LEVEL || 'info' }
1117
- })
1118
-
1119
- const tenantId = storageMode.isAnyApi() ? 'field_getters_tenant' : storageMode.defaultTenant
1120
- const previousTenant = storageMode.currentTenant
1121
- if (storageMode.isAnyApi()) {
1122
- storageMode.setCurrentTenant(tenantId)
1123
- }
1124
-
1125
- await api.use(RestApiPlugin, {
1126
- simplifiedApi: true,
1127
- simplifiedTransport: true
1128
- })
1129
-
1130
- await useStoragePlugin(api, knex, { tenantId })
1131
- await resetAnyApiTables(knex)
1132
-
1133
- // Users resource with basic getters
1134
- await api.addResource('users', {
1135
- schema: {
1136
- id: { type: 'id' },
1137
- email: {
1138
- type: 'string',
1139
- nullable: true,
1140
- getter: (value) => value?.toLowerCase().trim()
1141
- },
1142
- name: {
1143
- type: 'string',
1144
- nullable: true,
1145
- getter: (value) => value?.trim()
1146
- },
1147
- phone: {
1148
- type: 'string',
1149
- nullable: true,
1150
- getter: (value) => {
1151
- if (!value) return null
1152
- // Remove all non-digits and format
1153
- const digits = value.replace(/\D/g, '')
1154
- if (digits.length === 10) {
1155
- return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
1156
- }
1157
- return value
1158
- }
1159
- },
1160
- metadata_json: {
1161
- type: 'string',
1162
- nullable: true,
1163
- getter: (value) => {
1164
- if (!value) return {}
1165
- try {
1166
- return JSON.parse(value)
1167
- } catch {
1168
- return {}
1169
- }
1170
- }
1171
- },
1172
- tags_csv: {
1173
- type: 'string',
1174
- nullable: true,
1175
- getter: (value) => {
1176
- if (!value) return []
1177
- return value.split(',').map(t => t.trim()).filter(t => t)
1178
- }
1179
- }
1180
- },
1181
- tableName: 'getter_users'
1182
- })
1183
- await api.resources.users.createKnexTable()
1184
- mapTable('searchmerge_users', 'users')
1185
- mapTable('getter_users', 'users')
1186
-
1187
- // Products resource with getters and computed fields
1188
- await api.addResource('products', {
1189
- schema: {
1190
- id: { type: 'id' },
1191
- name: {
1192
- type: 'string',
1193
- getter: (value) => value?.toUpperCase()
1194
- },
1195
- description: {
1196
- type: 'string',
1197
- getter: (value) => {
1198
- // Truncate long descriptions
1199
- if (!value) return ''
1200
- return value.length > 50 ? value.substring(0, 47) + '...' : value
1201
- }
1202
- },
1203
- price_str: {
1204
- type: 'string',
1205
- getter: (value) => parseFloat(value) || 0
1206
- },
1207
- tax_rate_str: {
1208
- type: 'string',
1209
- getter: (value) => parseFloat(value) || 0
1210
- },
1211
- total_price: {
1212
- type: 'number',
1213
- computed: true,
1214
- dependencies: ['price_str', 'tax_rate_str'],
1215
- compute: ({ attributes }) => {
1216
- // Should see numbers from getters, not strings
1217
- return attributes.price_str * (1 + attributes.tax_rate_str)
1218
- }
1219
- }
1220
- },
1221
- relationships: {
1222
- reviews: { type: 'hasMany', target: 'reviews', foreignKey: 'product_id' }
1223
- },
1224
- tableName: 'getter_products'
1225
- })
1226
- await api.resources.products.createKnexTable()
1227
- mapTable('getter_products', 'products')
1228
-
1229
- // Reviews resource with getters
1230
- await api.addResource('reviews', {
1231
- schema: {
1232
- id: { type: 'id' },
1233
- product_id: { type: 'id', belongsTo: 'products', as: 'product' },
1234
- content: {
1235
- type: 'string',
1236
- getter: (value) => `[REVIEW] ${value}`
1237
- },
1238
- rating: { type: 'number' }
1239
- },
1240
- tableName: 'getter_reviews'
1241
- })
1242
- await api.resources.reviews.createKnexTable()
1243
- mapTable('getter_reviews', 'reviews')
1244
-
1245
- // Resource with getter dependencies
1246
- await api.addResource('formatted_data', {
1247
- schema: {
1248
- id: { type: 'id' },
1249
- step1: {
1250
- type: 'string',
1251
- nullable: true,
1252
- getter: (value) => value?.trim()
1253
- },
1254
- step2: {
1255
- type: 'string',
1256
- nullable: true,
1257
- getter: (value, { attributes }) => {
1258
- if (!value) return value // Return null/undefined as-is
1259
- // Depends on step1 being trimmed first
1260
- return `${value} [step1: ${attributes.step1}]`
1261
- },
1262
- runGetterAfter: ['step1']
1263
- },
1264
- step3: {
1265
- type: 'string',
1266
- getter: (value, { attributes }) => {
1267
- if (!value) return value // Return null/undefined as-is
1268
- // Depends on step2
1269
- return `${value} [step2: ${attributes.step2}]`
1270
- },
1271
- runGetterAfter: ['step2']
1272
- }
1273
- },
1274
- tableName: 'getter_formatted'
1275
- })
1276
- await api.resources.formatted_data.createKnexTable()
1277
- mapTable('getter_formatted', 'formatted_data')
1278
-
1279
- // Async getters example
1280
- await api.addResource('encrypted_data', {
1281
- schema: {
1282
- id: { type: 'id' },
1283
- secret: {
1284
- type: 'string',
1285
- nullable: true,
1286
- getter: async (value) => {
1287
- if (!value) return null
1288
- // Simulate async decryption
1289
- await new Promise(resolve => setTimeout(resolve, 5))
1290
- return Buffer.from(value, 'base64').toString()
1291
- }
1292
- },
1293
- data: {
1294
- type: 'string',
1295
- nullable: true,
1296
- getter: async (value, context) => {
1297
- if (!value) return null
1298
- await new Promise(resolve => setTimeout(resolve, 5))
1299
- const decrypted = Buffer.from(value, 'base64').toString()
1300
- return `[${context.scopeName}] ${decrypted}`
1301
- }
1302
- }
1303
- },
1304
- tableName: 'getter_encrypted'
1305
- })
1306
- await api.resources.encrypted_data.createKnexTable()
1307
- mapTable('getter_encrypted', 'encrypted_data')
1308
-
1309
- if (storageMode.isAnyApi()) {
1310
- storageMode.setCurrentTenant(previousTenant)
1311
- }
1312
-
1313
- return api
1314
- }
1315
-
1316
- export async function createProjectedFieldsApi (knex, pluginOptions = {}) {
1317
- const api = new Api({
1318
- name: 'projected-fields-test-api',
1319
- log: { level: process.env.LOG_LEVEL || 'info' }
1320
- })
1321
-
1322
- const tenantId = storageMode.isAnyApi() ? 'projected_fields_tenant' : storageMode.defaultTenant
1323
- const previousTenant = storageMode.currentTenant
1324
- if (storageMode.isAnyApi()) {
1325
- storageMode.setCurrentTenant(tenantId)
1326
- }
1327
-
1328
- await api.use(RestApiPlugin, {
1329
- simplifiedApi: false,
1330
- simplifiedTransport: false,
1331
- returnRecordApi: {
1332
- post: true,
1333
- put: false,
1334
- patch: false
1335
- },
1336
- returnRecordTransport: {
1337
- post: 'full',
1338
- put: 'no',
1339
- patch: 'minimal'
1340
- },
1341
- sortableFields: ['id', 'first_name', 'last_name'],
1342
- ...pluginOptions['rest-api']
1343
- })
1344
- await api.use(QueryProjectionsPlugin)
1345
-
1346
- await useStoragePlugin(api, knex, { tenantId })
1347
- await resetAnyApiTables(knex)
1348
-
1349
- await api.addResource('authors', {
1350
- schema: {
1351
- id: { type: 'id' },
1352
- first_name: { type: 'string', required: true, max: 100 },
1353
- last_name: { type: 'string', required: true, max: 100 }
1354
- },
1355
- queryFields: {
1356
- full_name: {
1357
- type: 'string',
1358
- sortable: true,
1359
- select: ({ knex, column }) => knex.raw(
1360
- "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
1361
- [column('first_name'), column('last_name')]
1362
- )
1363
- },
1364
- sort_name: {
1365
- type: 'string',
1366
- sortable: true,
1367
- normallyHidden: true,
1368
- select: ({ knex, column }) => knex.raw(
1369
- "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
1370
- [column('first_name'), column('last_name')]
1371
- )
1372
- }
1373
- },
1374
- relationships: {
1375
- books: { type: 'hasMany', target: 'books', foreignKey: 'author_id' }
1376
- },
1377
- sortableFields: ['id', 'first_name', 'last_name'],
1378
- defaultSort: ['full_name'],
1379
- tableName: 'projected_authors'
1380
- })
1381
- await api.resources.authors.createKnexTable()
1382
- mapTable('projected_authors', 'authors')
1383
-
1384
- await api.addResource('books', {
1385
- schema: {
1386
- id: { type: 'id' },
1387
- title: { type: 'string', required: true, max: 200 },
1388
- author_id: { type: 'id', required: true, belongsTo: 'authors', as: 'author' }
1389
- },
1390
- tableName: 'projected_books'
1391
- })
1392
- await api.resources.books.createKnexTable()
1393
- mapTable('projected_books', 'books')
1394
-
1395
- if (storageMode.isAnyApi()) {
1396
- storageMode.setCurrentTenant(previousTenant)
1397
- }
1398
-
1399
- return api
1400
- }
1401
-
1402
- /**
1403
- * Creates an API with field setters for testing
1404
- */
1405
- export async function createFieldSettersApi (knex, pluginOptions = {}) {
1406
- const api = new Api({
1407
- name: 'field-setters-test-api',
1408
- log: { level: process.env.LOG_LEVEL || 'info' }
1409
- })
1410
-
1411
- const tenantId = storageMode.isAnyApi() ? 'field_setters_tenant' : storageMode.defaultTenant
1412
- const previousTenant = storageMode.currentTenant
1413
- if (storageMode.isAnyApi()) {
1414
- storageMode.setCurrentTenant(tenantId)
1415
- }
1416
-
1417
- await api.use(RestApiPlugin, {
1418
- simplifiedApi: true,
1419
- simplifiedTransport: true,
1420
- returnRecordApi: {
1421
- post: true,
1422
- put: true,
1423
- patch: true
1424
- }
1425
- })
1426
-
1427
- await useStoragePlugin(api, knex, { tenantId })
1428
- await resetAnyApiTables(knex)
1429
-
1430
- // Users resource with basic setters
1431
- await api.addResource('users', {
1432
- schema: {
1433
- id: { type: 'id' },
1434
- email: {
1435
- type: 'string',
1436
- required: true,
1437
- setter: (value) => value?.toLowerCase().trim()
1438
- },
1439
- username: {
1440
- type: 'string',
1441
- required: true,
1442
- setter: (value) => value?.toLowerCase().replace(/\s+/g, '')
1443
- },
1444
- tags: {
1445
- type: 'string', // Accepts string, stored as string
1446
- setter: (value) => value // Pass through
1447
- },
1448
- preferences: {
1449
- type: 'string', // Accepts string, stored as string
1450
- setter: (value) => value // Pass through
1451
- }
1452
- },
1453
- tableName: 'setter_users'
1454
- })
1455
- await api.resources.users.createKnexTable()
1456
-
1457
- // Products with type conversion setters
1458
- await api.addResource('products', {
1459
- schema: {
1460
- id: { type: 'id' },
1461
- name: { type: 'string', required: true },
1462
- price: {
1463
- type: 'number',
1464
- required: true,
1465
- min: 0,
1466
- setter: (value) => {
1467
- // Convert to cents and round
1468
- return Math.round(value * 100)
1469
- }
1470
- },
1471
- discount_percent: {
1472
- type: 'number',
1473
- min: 0,
1474
- max: 100,
1475
- setter: (value) => Math.round(value)
1476
- },
1477
- metadata: {
1478
- type: 'object',
1479
- setter: (value) => JSON.stringify(value || {})
1480
- }
1481
- },
1482
- tableName: 'setter_products'
1483
- })
1484
- await api.resources.products.createKnexTable()
1485
-
1486
- // Secure data with async setters
1487
- await api.addResource('secure_data', {
1488
- schema: {
1489
- id: { type: 'id' },
1490
- password: {
1491
- type: 'string',
1492
- required: true,
1493
- min: 8,
1494
- setter: async (value) => {
1495
- // Simulate async password hashing
1496
- await new Promise(resolve => setTimeout(resolve, 5))
1497
- return `hashed:${value}`
1498
- }
1499
- },
1500
- api_key: {
1501
- type: 'string',
1502
- setter: async (value) => {
1503
- if (!value) return null
1504
- // Simulate encryption
1505
- await new Promise(resolve => setTimeout(resolve, 5))
1506
- return Buffer.from(value).toString('base64')
1507
- }
1508
- },
1509
- data: {
1510
- type: 'string',
1511
- setter: async (value) => {
1512
- if (!value) return null
1513
- return Buffer.from(value).toString('base64')
1514
- }
1515
- }
1516
- },
1517
- tableName: 'setter_secure'
1518
- })
1519
- await api.resources.secure_data.createKnexTable()
1520
-
1521
- // Resource with setter dependencies
1522
- await api.addResource('computed_data', {
1523
- schema: {
1524
- id: { type: 'id' },
1525
- base_value: {
1526
- type: 'number',
1527
- setter: (value) => value || 0
1528
- },
1529
- multiplier: {
1530
- type: 'number',
1531
- setter: (value) => value || 1
1532
- },
1533
- adjustment: {
1534
- type: 'number',
1535
- setter: (value) => value || 0
1536
- },
1537
- calculated_value: {
1538
- type: 'number',
1539
- setter: (value, { attributes }) => {
1540
- // Calculate based on other fields
1541
- return (attributes.base_value || 0) * (attributes.multiplier || 1)
1542
- },
1543
- runSetterAfter: ['base_value', 'multiplier']
1544
- },
1545
- final_value: {
1546
- type: 'number',
1547
- setter: (value, { attributes }) => {
1548
- // Depends on calculated_value
1549
- return (attributes.calculated_value || 0) + (attributes.adjustment || 0)
1550
- },
1551
- runSetterAfter: ['calculated_value', 'adjustment']
1552
- }
1553
- },
1554
- tableName: 'setter_computed'
1555
- })
1556
- await api.resources.computed_data.createKnexTable()
1557
-
1558
- // Nullable fields handling
1559
- await api.addResource('nullable_data', {
1560
- schema: {
1561
- id: { type: 'id' },
1562
- field1: {
1563
- type: 'string',
1564
- nullable: true,
1565
- setter: (value) => value === undefined ? null : value
1566
- },
1567
- field2: {
1568
- type: 'string',
1569
- nullable: true,
1570
- setter: (value) => value === undefined ? null : value
1571
- },
1572
- field3: {
1573
- type: 'string',
1574
- setter: (value) => value === '' ? 'empty' : value
1575
- },
1576
- field4: {
1577
- type: 'number',
1578
- setter: (value) => value === 0 ? -1 : value
1579
- }
1580
- },
1581
- tableName: 'setter_nullable'
1582
- })
1583
- await api.resources.nullable_data.createKnexTable()
1584
-
1585
- // Validated data
1586
- await api.addResource('validated_data', {
1587
- schema: {
1588
- id: { type: 'id' },
1589
- email: {
1590
- type: 'string',
1591
- required: true,
1592
- format: 'email',
1593
- setter: (value) => value.toLowerCase().trim()
1594
- },
1595
- age: {
1596
- type: 'number',
1597
- min: 0,
1598
- max: 120,
1599
- setter: (value) => value // No transformation, just to verify it's a number
1600
- },
1601
- score: {
1602
- type: 'number',
1603
- min: 0,
1604
- max: 100,
1605
- setter: (value) => Math.ceil(value) // Round up
1606
- }
1607
- },
1608
- tableName: 'setter_validated'
1609
- })
1610
- await api.resources.validated_data.createKnexTable()
1611
-
1612
- if (storageMode.isAnyApi()) {
1613
- storageMode.setCurrentTenant(previousTenant)
1614
- }
1615
-
1616
- return api
1617
- }
1618
-
1619
- export async function createAutoFilterApi (knex, pluginOptions = {}) {
1620
- const { AutoFilterPlugin } = await import('../../plugins/core/rest-api-autofilter-plugin.js')
1621
-
1622
- const api = new Api({
1623
- name: 'autofilter-test-api',
1624
- })
1625
-
1626
- const tenantId = storageMode.isAnyApi() ? 'autofilter_tenant' : storageMode.defaultTenant
1627
- const previousTenant = storageMode.currentTenant
1628
- if (storageMode.isAnyApi()) {
1629
- storageMode.setCurrentTenant(tenantId)
1630
- }
1631
-
1632
- await api.use(RestApiPlugin, {
1633
- simplifiedApi: false,
1634
- simplifiedTransport: false,
1635
- returnRecordApi: {
1636
- post: true,
1637
- put: false,
1638
- patch: false
1639
- },
1640
- returnRecordTransport: {
1641
- post: 'full',
1642
- put: 'no',
1643
- patch: 'minimal'
1644
- },
1645
- sortableFields: ['id', 'title', 'name', 'workspace_id', 'user_id']
1646
- })
1647
-
1648
- await useStoragePlugin(api, knex, { tenantId })
1649
- await resetAnyApiTables(knex)
1650
-
1651
- // Add Express plugin if requested for transport testing
1652
- if (pluginOptions.includeExpress) {
1653
- await api.use(ExpressPlugin, pluginOptions.express || {})
1654
- }
1655
-
1656
- const autofilterOptions = pluginOptions.autofilter || {}
1657
- const workspaceField = pluginOptions.workspaceField || 'workspace_id'
1658
- const userField = pluginOptions.userField || 'user_id'
1659
-
1660
- await api.use(AutoFilterPlugin, {
1661
- ...autofilterOptions,
1662
- resolvers: {
1663
- workspace: ({ context }) => context.scopeValues?.workspaceId,
1664
- user: ({ context }) => context.scopeValues?.userId,
1665
- currentProject: ({ context }) => context.scopeValues?.projectId ?? null,
1666
- ...(autofilterOptions.resolvers || {})
1667
- },
1668
- presets: {
1669
- public: { filters: [] },
1670
- workspace: {
1671
- filters: [{ field: workspaceField, resolver: 'workspace' }]
1672
- },
1673
- user: {
1674
- filters: [{ field: userField, resolver: 'user' }]
1675
- },
1676
- workspace_user: {
1677
- filters: [
1678
- { field: workspaceField, resolver: 'workspace' },
1679
- { field: userField, resolver: 'user' }
1680
- ]
1681
- },
1682
- ...(autofilterOptions.presets || {})
1683
- }
1684
- })
1685
-
1686
- await api.addResource('workspace_reports', {
1687
- schema: {
1688
- id: { type: 'id' },
1689
- title: { type: 'string', required: true, max: 200 },
1690
- workspace_id: { type: 'string', required: true }
1691
- },
1692
- autofilter: 'workspace',
1693
- tableName: 'autofilter_workspace_reports'
1694
- })
1695
- await api.resources.workspace_reports.createKnexTable()
1696
- mapTable('autofilter_workspace_reports', 'workspace_reports')
1697
-
1698
- await api.addResource('user_notes', {
1699
- schema: {
1700
- id: { type: 'id' },
1701
- body: { type: 'string', required: true, max: 500 },
1702
- user_id: { type: 'number', required: true }
1703
- },
1704
- autofilter: 'user',
1705
- tableName: 'autofilter_user_notes'
1706
- })
1707
- await api.resources.user_notes.createKnexTable()
1708
- mapTable('autofilter_user_notes', 'user_notes')
1709
-
1710
- await api.addResource('projects', {
1711
- schema: {
1712
- id: { type: 'id' },
1713
- name: { type: 'string', required: true, max: 200 },
1714
- description: { type: 'string', max: 1000 },
1715
- status: { type: 'string', defaultTo: 'active' },
1716
- workspace_id: { type: 'string', required: true },
1717
- user_id: { type: 'number', required: true }
1718
- },
1719
- relationships: {
1720
- tasks: { type: 'hasMany', target: 'tasks', foreignKey: 'project_id' }
1721
- },
1722
- autofilter: 'workspace_user',
1723
- tableName: 'autofilter_projects'
1724
- })
1725
- await api.resources.projects.createKnexTable()
1726
- mapTable('autofilter_projects', 'projects')
1727
-
1728
- await api.addResource('tasks', {
1729
- schema: {
1730
- id: { type: 'id' },
1731
- title: { type: 'string', required: true, max: 200 },
1732
- completed: { type: 'boolean', defaultTo: false },
1733
- project_id: { type: 'number', belongsTo: 'projects', as: 'project' },
1734
- workspace_id: { type: 'string', required: true },
1735
- user_id: { type: 'number', required: true }
1736
- },
1737
- autofilter: 'workspace_user',
1738
- tableName: 'autofilter_tasks'
1739
- })
1740
- await api.resources.tasks.createKnexTable()
1741
- mapTable('autofilter_tasks', 'tasks')
1742
-
1743
- await api.addResource('optional_tasks', {
1744
- schema: {
1745
- id: { type: 'id' },
1746
- title: { type: 'string', required: true, max: 200 },
1747
- project_id: { type: 'number', belongsTo: 'projects', as: 'project', nullable: true }
1748
- },
1749
- autofilter: {
1750
- filters: [{ field: 'project_id', resolver: 'currentProject' }]
1751
- },
1752
- tableName: 'autofilter_optional_tasks'
1753
- })
1754
- await api.resources.optional_tasks.createKnexTable()
1755
- mapTable('autofilter_optional_tasks', 'optional_tasks')
1756
-
1757
- await api.addResource('system_settings', {
1758
- schema: {
1759
- id: { type: 'id' },
1760
- key: { type: 'string', required: true, unique: true },
1761
- value: { type: 'string', required: true }
1762
- },
1763
- autofilter: 'public',
1764
- tableName: 'autofilter_system_settings'
1765
- })
1766
- await api.resources.system_settings.createKnexTable()
1767
- mapTable('autofilter_system_settings', 'system_settings')
1768
-
1769
- if (storageMode.isAnyApi()) {
1770
- storageMode.setCurrentTenant(previousTenant)
1771
- }
1772
-
1773
- return api
1774
- }
1775
-
1776
- export async function createRowPolicyApi (knex, pluginOptions = {}) {
1777
- const api = new Api({
1778
- name: 'row-policy-test-api',
1779
- })
1780
-
1781
- const tenantId = storageMode.isAnyApi() ? 'row_policy_tenant' : storageMode.defaultTenant
1782
- const previousTenant = storageMode.currentTenant
1783
- if (storageMode.isAnyApi()) {
1784
- storageMode.setCurrentTenant(tenantId)
1785
- }
1786
-
1787
- await api.use(RestApiPlugin, {
1788
- simplifiedApi: false,
1789
- simplifiedTransport: false,
1790
- returnRecordApi: {
1791
- post: true,
1792
- put: false,
1793
- patch: false
1794
- },
1795
- sortableFields: ['id', 'name', 'title', 'access_group', 'project_id']
1796
- })
1797
-
1798
- await useStoragePlugin(api, knex, { tenantId })
1799
- await resetAnyApiTables(knex)
1800
-
1801
- const rowPolicyOptions = pluginOptions.rowPolicy || {}
1802
- await api.use(RowPolicyPlugin, {
1803
- ...rowPolicyOptions,
1804
- policies: {
1805
- groupVisibility: ({ query, context, column, value, scopeName, queryPurpose }) => {
1806
- pluginOptions.onPolicy?.({ scopeName, queryPurpose })
1807
-
1808
- if (context.visibility?.all === true) {
1809
- return true
1810
- }
1811
-
1812
- const groups = context.visibility?.groups
1813
- if (!Array.isArray(groups) || groups.length === 0) {
1814
- return false
1815
- }
1816
-
1817
- query.where(function visibilityGrants () {
1818
- this.whereRaw('1 = 0')
1819
- this.orWhere(function groupVisibilityGrant () {
1820
- this.whereIn(
1821
- column('access_group'),
1822
- groups.map((group) => value('access_group', group))
1823
- )
1824
- })
1825
- })
1826
- return true
1827
- },
1828
- ...(rowPolicyOptions.policies || {})
1829
- }
1830
- })
1831
-
1832
- await api.use(AutoFilterPlugin, {
1833
- resolvers: {
1834
- workspace: ({ context }) => context.scopeValues?.workspaceId
1835
- },
1836
- presets: {
1837
- workspace: {
1838
- filters: [{ field: 'workspace_id', resolver: 'workspace' }]
1839
- }
1840
- }
1841
- })
1842
-
1843
- await api.addResource('policy_projects', {
1844
- schema: {
1845
- id: { type: 'id' },
1846
- name: { type: 'string', required: true, max: 200 },
1847
- access_group: { type: 'string', required: true },
1848
- workspace_id: { type: 'string', required: true }
1849
- },
1850
- relationships: {
1851
- tasks: { type: 'hasMany', target: 'policy_tasks', foreignKey: 'project_id' },
1852
- shared_tasks: {
1853
- type: 'manyToMany',
1854
- target: 'policy_tasks',
1855
- through: 'policy_project_tasks',
1856
- foreignKey: 'project_id',
1857
- otherKey: 'task_id'
1858
- }
1859
- },
1860
- autofilter: 'workspace',
1861
- rowPolicy: 'groupVisibility',
1862
- tableName: 'row_policy_projects'
1863
- })
1864
- await api.resources.policy_projects.createKnexTable()
1865
- mapTable('row_policy_projects', 'policy_projects')
1866
-
1867
- await api.addResource('policy_tasks', {
1868
- schema: {
1869
- id: { type: 'id' },
1870
- title: { type: 'string', required: true, max: 200, search: true },
1871
- access_group: { type: 'string', required: true },
1872
- workspace_id: { type: 'string', required: true },
1873
- project_id: {
1874
- type: 'number',
1875
- nullable: true,
1876
- belongsTo: 'policy_projects',
1877
- as: 'project',
1878
- search: true
1879
- }
1880
- },
1881
- relationships: {
1882
- shared_projects: {
1883
- type: 'manyToMany',
1884
- target: 'policy_projects',
1885
- through: 'policy_project_tasks',
1886
- foreignKey: 'task_id',
1887
- otherKey: 'project_id'
1888
- }
1889
- },
1890
- autofilter: 'workspace',
1891
- rowPolicy: 'groupVisibility',
1892
- tableName: 'row_policy_tasks'
1893
- })
1894
- await api.resources.policy_tasks.createKnexTable()
1895
- mapTable('row_policy_tasks', 'policy_tasks')
1896
-
1897
- await api.addResource('policy_project_tasks', {
1898
- schema: {
1899
- id: { type: 'id' },
1900
- project_id: { type: 'number', required: true, belongsTo: 'policy_projects', as: 'project' },
1901
- task_id: { type: 'number', required: true, belongsTo: 'policy_tasks', as: 'task' }
1902
- },
1903
- tableName: 'row_policy_project_tasks'
1904
- })
1905
- await api.resources.policy_project_tasks.createKnexTable()
1906
- mapTable('row_policy_project_tasks', 'policy_project_tasks')
1907
-
1908
- if (storageMode.isAnyApi()) {
1909
- const projectsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_projects')
1910
- const tasksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_tasks')
1911
- storageMode.registerLink(
1912
- 'row_policy_project_tasks',
1913
- 'policy_projects',
1914
- 'shared_tasks',
1915
- projectsDescriptor?.manyToMany?.shared_tasks?.relationship,
1916
- tasksDescriptor?.manyToMany?.shared_projects?.relationship
1917
- )
1918
- }
1919
-
1920
- await api.addResource('policy_broken', {
1921
- schema: {
1922
- id: { type: 'id' },
1923
- name: { type: 'string', required: true }
1924
- },
1925
- rowPolicy: () => undefined,
1926
- tableName: 'row_policy_broken'
1927
- })
1928
- await api.resources.policy_broken.createKnexTable()
1929
- mapTable('row_policy_broken', 'policy_broken')
1930
-
1931
- if (storageMode.isAnyApi()) {
1932
- storageMode.setCurrentTenant(previousTenant)
1933
- }
1934
-
1935
- return api
1936
- }
1937
-
1938
- /**
1939
- * Creates an API with positioning support for testing
1940
- */
1941
- export async function createPositioningApi (knex, pluginOptions = {}) {
1942
- const apiName = pluginOptions.apiName || 'positioning-test-api'
1943
- const tablePrefix = pluginOptions.tablePrefix || 'positioning'
1944
- const api = new Api({
1945
- name: apiName,
1946
- })
1947
-
1948
- const restApiOptions = {
1949
- simplifiedApi: true, // Changed to true to allow simplified API calls in tests
1950
- simplifiedTransport: false,
1951
- returnRecordApi: {
1952
- post: true,
1953
- put: true,
1954
- patch: true
1955
- },
1956
- returnRecordTransport: {
1957
- post: 'full',
1958
- put: 'full',
1959
- patch: 'full'
1960
- },
1961
- sortableFields: ['id', 'title', 'name', 'position', 'sort_order', 'category_id', 'project_id', 'status'],
1962
- ...pluginOptions['rest-api']
1963
- }
1964
-
1965
- await api.use(RestApiPlugin, restApiOptions)
1966
- await useStoragePlugin(api, knex)
1967
- await resetAnyApiTables(knex)
1968
-
1969
- // Categories (for grouping tasks)
1970
- await api.addResource('categories', {
1971
- schema: {
1972
- id: { type: 'id' },
1973
- name: { type: 'string', required: true, max: 100 }
1974
- },
1975
- tableName: `${tablePrefix}_categories`
1976
- })
1977
- await api.resources.categories.createKnexTable()
1978
- mapTable(`${tablePrefix}_categories`, 'categories')
1979
-
1980
- // Tasks (main positioning test resource)
1981
- await api.addResource('tasks', {
1982
- schema: {
1983
- id: { type: 'id' },
1984
- title: { type: 'string', required: true, max: 200 },
1985
- category_id: { type: 'number', nullable: true, belongsTo: 'categories', as: 'category', search: true },
1986
- position: { type: 'string', max: 255, nullable: true },
1987
- beforeId: { type: 'string', virtual: true }, // Virtual field for positioning
1988
- deleted_at: { type: 'dateTime', nullable: true, search: true }, // For soft delete tests
1989
- version: { type: 'number', defaultTo: 1, search: true } // For versioning tests
1990
- },
1991
- relationships: {
1992
- category: { belongsTo: 'categories' }
1993
- },
1994
- tableName: `${tablePrefix}_tasks`
1995
- })
1996
- await api.resources.tasks.createKnexTable()
1997
- mapTable(`${tablePrefix}_tasks`, 'tasks')
1998
-
1999
- // Projects (for multi-filter testing)
2000
- await api.addResource('projects', {
2001
- schema: {
2002
- id: { type: 'id' },
2003
- name: { type: 'string', required: true, max: 100 }
2004
- },
2005
- tableName: `${tablePrefix}_projects`
2006
- })
2007
- await api.resources.projects.createKnexTable()
2008
- mapTable(`${tablePrefix}_projects`, 'projects')
2009
-
2010
- // Items (flexible resource for various positioning tests)
2011
- await api.addResource('items', {
2012
- schema: {
2013
- id: { type: 'id' },
2014
- name: { type: 'string', required: true, max: 200 },
2015
- project_id: { type: 'number', nullable: true, belongsTo: 'projects', as: 'project', search: true },
2016
- status: { type: 'string', defaultTo: 'active', nullable: true, search: true },
2017
- position: { type: 'string', max: 255, nullable: true },
2018
- sort_order: { type: 'string', max: 255, nullable: true }, // Alternative position field
2019
- beforeId: { type: 'string', virtual: true },
2020
- priority: { type: 'string', defaultTo: 'medium', search: true } // For multi-filter tests
2021
- },
2022
- relationships: {
2023
- project: { belongsTo: 'projects' }
2024
- },
2025
- tableName: `${tablePrefix}_items`
2026
- })
2027
- await api.resources.items.createKnexTable()
2028
- mapTable(`${tablePrefix}_items`, 'items')
2029
-
2030
- return api
2031
- }
2032
-
2033
- /**
2034
- * Creates an API with custom idProperty for all resources to test idProperty functionality
2035
- * Uses 'custom_id_' prefix for all tables to avoid conflicts
2036
- */
2037
- export async function createCustomIdPropertyApi (knex, pluginOptions = {}) {
2038
- const apiName = pluginOptions.apiName || 'custom-id-test-api'
2039
- const tablePrefix = pluginOptions.tablePrefix || 'custom_id'
2040
- const api = new Api({
2041
- name: apiName,
2042
- log: { level: process.env.LOG_LEVEL || 'info' }
2043
- })
2044
-
2045
- if (storageMode.isAnyApi()) {
2046
- storageMode.clearRegistry()
2047
- }
2048
-
2049
- const restApiOptions = {
2050
- simplifiedApi: false,
2051
- simplifiedTransport: false,
2052
- returnRecordApi: {
2053
- post: 'full',
2054
- put: 'full',
2055
- patch: 'full'
2056
- },
2057
- returnRecordTransport: {
2058
- post: 'full',
2059
- put: 'full',
2060
- patch: 'full'
2061
- },
2062
- sortableFields: ['country_id', 'publisher_id', 'author_id', 'book_id', 'title', 'name', 'code'],
2063
- ...pluginOptions['rest-api'] // Merge any custom options for rest-api plugin
2064
- }
2065
-
2066
- await api.use(RestApiPlugin, restApiOptions)
2067
- await useStoragePlugin(api, knex)
2068
- await resetAnyApiTables(knex)
2069
-
2070
- // Countries table with custom idProperty
2071
- await api.addResource('countries', {
2072
- schema: {
2073
- // NO id: { type: 'id' } - this is key!
2074
- name: { type: 'string', required: true, max: 100, search: true },
2075
- code: { type: 'string', max: 2, unique: true }
2076
- },
2077
- relationships: {
2078
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
2079
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
2080
- },
2081
- tableName: `${tablePrefix}_countries`,
2082
- idProperty: 'country_id' // Custom ID property
2083
- })
2084
- await api.resources.countries.createKnexTable()
2085
- mapTable(`${tablePrefix}_countries`, 'countries')
2086
-
2087
- // Publishers table with custom idProperty
2088
- await api.addResource('publishers', {
2089
- schema: {
2090
- name: { type: 'string', required: true, max: 200 },
2091
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
2092
- },
2093
- relationships: {
2094
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
2095
- reviews: {
2096
- type: 'hasMany',
2097
- target: 'reviews',
2098
- via: 'reviewable'
2099
- }
2100
- },
2101
- tableName: `${tablePrefix}_publishers`,
2102
- idProperty: 'publisher_id'
2103
- })
2104
- await api.resources.publishers.createKnexTable()
2105
- mapTable(`${tablePrefix}_publishers`, 'publishers')
2106
-
2107
- // Authors table with custom idProperty
2108
- await api.addResource('authors', {
2109
- schema: {
2110
- name: { type: 'string', required: true, max: 200 },
2111
- biography: { type: 'string', max: 5000 },
2112
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
2113
- },
2114
- relationships: {
2115
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' },
2116
- reviews: {
2117
- type: 'hasMany',
2118
- target: 'reviews',
2119
- via: 'reviewable'
2120
- }
2121
- },
2122
- tableName: `${tablePrefix}_authors`,
2123
- idProperty: 'author_id'
2124
- })
2125
- await api.resources.authors.createKnexTable()
2126
- mapTable(`${tablePrefix}_authors`, 'authors')
2127
-
2128
- // Books table with custom idProperty
2129
- await api.addResource('books', {
2130
- schema: {
2131
- title: { type: 'string', required: true, max: 300, search: true },
2132
- isbn: { type: 'string', max: 13 },
2133
- pages: { type: 'number' },
2134
- published_date: { type: 'date' },
2135
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
2136
- publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher', search: true }
2137
- },
2138
- relationships: {
2139
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' },
2140
- reviews: {
2141
- type: 'hasMany',
2142
- target: 'reviews',
2143
- via: 'reviewable'
2144
- }
2145
- },
2146
- tableName: `${tablePrefix}_books`,
2147
- idProperty: 'book_id'
2148
- })
2149
- await api.resources.books.createKnexTable()
2150
- mapTable(`${tablePrefix}_books`, 'books')
2151
-
2152
- // Book-Authors pivot table with custom idProperty
2153
- await api.addResource('book_authors', {
2154
- schema: {
2155
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
2156
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' },
2157
- contribution_type: { type: 'string', max: 50 },
2158
- order: { type: 'number' }
2159
- },
2160
- tableName: `${tablePrefix}_book_authors`,
2161
- idProperty: 'book_author_id'
2162
- })
2163
- await api.resources.book_authors.createKnexTable()
2164
- mapTable(`${tablePrefix}_book_authors`, 'book_authors')
2165
-
2166
- // Polymorphic reviews with custom idProperty
2167
- await api.addResource('reviews', {
2168
- schema: {
2169
- rating: { type: 'number', required: true, min: 1, max: 5 },
2170
- title: { type: 'string', max: 200 },
2171
- content: { type: 'string', required: true, max: 5000 },
2172
- reviewer_name: { type: 'string', required: true, max: 100 },
2173
- review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
2174
- reviewable_type: { type: 'string', required: true, search: true },
2175
- reviewable_id: { type: 'number', required: true },
2176
- // Define the polymorphic field in schema
2177
- reviewable: {
2178
- belongsToPolymorphic: {
2179
- types: ['books', 'authors', 'publishers'],
2180
- typeField: 'reviewable_type',
2181
- idField: 'reviewable_id'
2182
- },
2183
- as: 'reviewable'
2184
- }
2185
- },
2186
- relationships: {},
2187
- tableName: `${tablePrefix}_reviews`,
2188
- idProperty: 'review_id'
2189
- })
2190
- await api.resources.reviews.createKnexTable()
2191
- mapTable(`${tablePrefix}_reviews`, 'reviews')
2192
-
2193
- if (storageMode.isAnyApi()) {
2194
- const tenant = storageMode.defaultTenant || 'default'
2195
- const booksDescriptor = await api.anyapi.registry.getDescriptor(tenant, 'books')
2196
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(tenant, 'authors')
2197
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
2198
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
2199
- storageMode.registerLink(
2200
- `${tablePrefix}_book_authors`,
2201
- 'books',
2202
- 'authors',
2203
- relationshipKey,
2204
- inverseRelationshipKey
2205
- )
2206
- }
2207
-
2208
- return api
2209
- }
2210
-
2211
- /**
2212
- * Creates an API configuration for multi-field cursor pagination testing
2213
- * Uses 'cursor_' prefix for all tables to avoid conflicts
2214
- */
2215
- export async function createCursorPaginationApi (knex) {
2216
- const api = new Api({
2217
- name: 'cursor-pagination-test-api',
2218
- })
2219
-
2220
- const restApiOptions = {
2221
- simplified: false,
2222
- responseFormat: {
2223
- post: 'full',
2224
- put: 'full',
2225
- patch: 'minimal'
2226
- },
2227
- sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'createdAt', 'code', 'status', 'type'],
2228
- queryDefaultLimit: 20,
2229
- queryMaxLimit: 100,
2230
- returnBasePath: 'https://api.example.com'
2231
- }
2232
-
2233
- await api.use(RestApiPlugin, restApiOptions)
2234
- await useStoragePlugin(api, knex)
2235
- await resetAnyApiTables(knex)
2236
-
2237
- // Create products table with fields suitable for multi-field sorting
2238
- await api.addResource('products', {
2239
- schema: {
2240
- id: { type: 'id' },
2241
- name: { type: 'string', required: true, max: 200, search: true },
2242
- category: { type: 'string', required: true, max: 100, search: true, indexed: true },
2243
- brand: { type: 'string', required: true, max: 100, search: true, indexed: true },
2244
- price: { type: 'number', required: true },
2245
- sku: { type: 'string', required: true, max: 50, unique: true },
2246
- status: { type: 'string', max: 50, default: 'active' },
2247
- createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
2248
- },
2249
- tableName: 'cursor_products'
2250
- })
2251
- await api.resources.products.createKnexTable()
2252
- mapTable('cursor_products', 'products')
2253
-
2254
- // Create items table with custom ID property
2255
- await api.addResource('items', {
2256
- schema: {
2257
- item_id: { type: 'id' },
2258
- code: { type: 'string', required: true, max: 10, unique: true },
2259
- name: { type: 'string', required: true, max: 200 },
2260
- category: { type: 'string', required: true, max: 100 },
2261
- type: { type: 'string', required: true, max: 50 }
2262
- },
2263
- idProperty: 'item_id',
2264
- tableName: 'cursor_items'
2265
- })
2266
- await api.resources.items.createKnexTable()
2267
- mapTable('cursor_items', 'items')
2268
-
2269
- return api
2270
- }
2271
-
2272
- /**
2273
- * Creates an API configuration for testing virtual fields validation
2274
- */
2275
- export async function createVirtualFieldsApi (knex, pluginOptions = {}) {
2276
- const apiName = pluginOptions.apiName || 'virtual-fields-test-api'
2277
- const api = new Api({
2278
- name: apiName,
2279
- log: { level: process.env.LOG_LEVEL || 'info' }
2280
- })
2281
-
2282
- const restApiOptions = {
2283
- simplifiedApi: false,
2284
- simplifiedTransport: false,
2285
- returnRecordApi: {
2286
- post: true,
2287
- put: false,
2288
- patch: false
2289
- },
2290
- returnRecordTransport: {
2291
- post: 'full',
2292
- put: 'no',
2293
- patch: 'minimal'
2294
- },
2295
- ...pluginOptions['rest-api']
2296
- }
2297
-
2298
- await api.use(RestApiPlugin, restApiOptions)
2299
- await useStoragePlugin(api, knex)
2300
- await resetAnyApiTables(knex)
2301
-
2302
- // Add a test resource with virtual fields
2303
- await api.addResource('users', {
2304
- schema: {
2305
- // Regular fields
2306
- username: { type: 'string', required: true },
2307
- email: { type: 'string', required: true },
2308
- age: { type: 'number' },
2309
-
2310
- // Virtual fields with validation rules
2311
- password: { type: 'string', virtual: true, minLength: 8 },
2312
- passwordConfirmation: { type: 'string', virtual: true, minLength: 8 },
2313
- termsAccepted: { type: 'boolean', virtual: true, required: true },
2314
- captchaScore: { type: 'number', virtual: true, min: 0, max: 1 },
2315
- tags: { type: 'array', virtual: true },
2316
- metadata: { type: 'object', virtual: true }
2317
- },
2318
- tableName: 'virtual_users'
2319
- })
2320
-
2321
- await api.resources.users.createKnexTable()
2322
-
2323
- return api
2324
- }
2325
-
2326
- /**
2327
- * Creates an API configuration for testing searchSchema merge behavior.
2328
- * Shared suites assert merge/validation behavior across storage backends;
2329
- * backend-specific execution cases live in storage-specific tests.
2330
- */
2331
- export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
2332
- const api = new Api({
2333
- name: 'searchschema-merge-test-api',
2334
- log: { level: process.env.LOG_LEVEL || 'info' }
2335
- })
2336
-
2337
- await api.use(RestApiPlugin, {
2338
- simplifiedApi: false,
2339
- simplifiedTransport: false,
2340
- returnRecordApi: {
2341
- post: true,
2342
- put: false,
2343
- patch: false
2344
- },
2345
- ...pluginOptions['rest-api']
2346
- })
2347
-
2348
- await useStoragePlugin(api, knex)
2349
- await resetAnyApiTables(knex)
2350
-
2351
- // Test resource with both search:true and searchSchema
2352
- await api.addResource('products', {
2353
- schema: {
2354
- name: { type: 'string', search: true }, // Should be added to searchSchema
2355
- description: { type: 'string', search: true }, // Should be added to searchSchema
2356
- price: { type: 'number', search: true }, // Should be overridden by searchSchema
2357
- category: { type: 'number' }, // Not searchable
2358
- sku: { type: 'string', search: true }, // Should be added to searchSchema
2359
- status: { type: 'string' } // Not searchable but added via searchSchema
2360
- },
2361
- searchSchema: {
2362
- // This should override the price field from search:true
2363
- // 'between' operator requires array type for validation [min, max]
2364
- price: {
2365
- type: 'array',
2366
- filterOperator: 'between'
2367
- },
2368
- // Arbitrary public filter name that searches across multiple fields.
2369
- // The key `term` is part of the API contract even though there is no `term` column.
2370
- term: {
2371
- type: 'string',
2372
- oneOf: ['name', 'description'],
2373
- filterOperator: 'like'
2374
- },
2375
- // This is a virtual field
2376
- category_name: {
2377
- type: 'string',
2378
- actualField: 'category.name',
2379
- filterOperator: 'like'
2380
- },
2381
- // Arbitrary public filter name with fully custom server-side meaning.
2382
- // The Knex query layer decides what `availability` means.
2383
- availability: {
2384
- type: 'boolean',
2385
- applyFilter: (query, value) => {
2386
- query.where('status', value ? 'active' : 'inactive')
2387
- }
2388
- },
2389
- // This is an explicit field not marked with search:true
2390
- // 'in' operator requires array type for validation
2391
- status: {
2392
- type: 'array',
2393
- filterOperator: 'in'
2394
- }
2395
- },
2396
- tableName: 'searchmerge_products'
2397
- })
2398
- await api.resources.products.createKnexTable()
2399
-
2400
- // Test resource with only search:true fields
2401
- await api.addResource('users', {
2402
- schema: {
2403
- username: { type: 'string', search: true },
2404
- email: { type: 'string', search: true },
2405
- age: { type: 'number' },
2406
- bio: { type: 'string', search: { filterOperator: 'like' } }
2407
- },
2408
- tableName: 'searchmerge_users'
2409
- })
2410
- await api.resources.users.createKnexTable()
2411
-
2412
- // Test resource with only explicit searchSchema
2413
- await api.addResource('orders', {
2414
- schema: {
2415
- order_number: { type: 'string' },
2416
- total: { type: 'number' },
2417
- status: { type: 'string' }
2418
- },
2419
- searchSchema: {
2420
- order_number: { type: 'string', filterOperator: '=' },
2421
- status: { type: 'array', filterOperator: 'in' } // 'in' operator requires array type
2422
- },
2423
- tableName: 'searchmerge_orders'
2424
- })
2425
- await api.resources.orders.createKnexTable()
2426
- mapTable('searchmerge_orders', 'orders')
2427
-
2428
- return api
2429
- }
2430
-
2431
- export async function createFileUploadApi (knex, pluginOptions = {}) {
2432
- const apiName = pluginOptions.apiName || 'file-upload-test-api'
2433
- const tableName = pluginOptions.tableName || 'file_documents'
2434
- const tablePrefix = pluginOptions.tablePrefix || 'file'
2435
- const storage = pluginOptions.storage
2436
- const detectorState = pluginOptions.detectorState || { payload: null }
2437
-
2438
- if (!storage) {
2439
- throw new Error('createFileUploadApi requires a storage adapter')
2440
- }
2441
-
2442
- if (storageMode.isAnyApi()) {
2443
- storageMode.clearRegistry()
2444
- }
2445
-
2446
- const api = new Api({
2447
- name: apiName,
2448
- log: { level: process.env.LOG_LEVEL || 'info' }
2449
- })
2450
-
2451
- const previousTenant = storageMode.currentTenant
2452
- const tenantId = storageMode.isAnyApi()
2453
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
2454
- : storageMode.defaultTenant
2455
-
2456
- if (storageMode.isAnyApi()) {
2457
- storageMode.setCurrentTenant(tenantId)
2458
- }
2459
-
2460
- try {
2461
- await api.use(RestApiPlugin, {
2462
- simplifiedApi: false,
2463
- simplifiedTransport: false,
2464
- returnRecordApi: {
2465
- post: true,
2466
- put: false,
2467
- patch: false
2468
- },
2469
- returnRecordTransport: {
2470
- post: 'full',
2471
- put: 'no',
2472
- patch: 'minimal'
2473
- },
2474
- sortableFields: ['id', 'title'],
2475
- ...pluginOptions['rest-api']
2476
- })
2477
- await useStoragePlugin(api, knex, { tenantId })
2478
- await resetAnyApiTables(knex)
2479
- await api.use(FileHandlingPlugin)
2480
-
2481
- api.rest.registerFileDetector({
2482
- name: 'test-file-detector',
2483
- detect: () => Boolean(detectorState.payload),
2484
- parse: async () => detectorState.payload
2485
- })
2486
-
2487
- await api.addResource('documents', {
2488
- schema: {
2489
- id: { type: 'id' },
2490
- title: { type: 'string', required: true },
2491
- attachment: {
2492
- type: 'file',
2493
- storage,
2494
- accepts: ['image/png'],
2495
- maxSize: '1mb'
2496
- }
2497
- },
2498
- tableName
2499
- })
2500
- await api.resources.documents.createKnexTable()
2501
- mapTable(tableName, 'documents')
2502
-
2503
- return api
2504
- } finally {
2505
- if (storageMode.isAnyApi()) {
2506
- storageMode.setCurrentTenant(previousTenant)
2507
- }
2508
- }
2509
- }