@api-client/core 0.19.18 → 0.19.20

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 (154) hide show
  1. package/build/src/authorization/Utils.js +3 -3
  2. package/build/src/authorization/Utils.js.map +1 -1
  3. package/build/src/browser.d.ts +1 -1
  4. package/build/src/browser.d.ts.map +1 -1
  5. package/build/src/browser.js.map +1 -1
  6. package/build/src/index.d.ts +1 -1
  7. package/build/src/index.d.ts.map +1 -1
  8. package/build/src/index.js.map +1 -1
  9. package/build/src/mocking/lib/Organization.d.ts +5 -1
  10. package/build/src/mocking/lib/Organization.d.ts.map +1 -1
  11. package/build/src/mocking/lib/Organization.js +17 -0
  12. package/build/src/mocking/lib/Organization.js.map +1 -1
  13. package/build/src/modeling/ApiModel.d.ts +16 -5
  14. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  15. package/build/src/modeling/ApiModel.js +17 -2
  16. package/build/src/modeling/ApiModel.js.map +1 -1
  17. package/build/src/modeling/ApiValidation.d.ts.map +1 -1
  18. package/build/src/modeling/ApiValidation.js +2 -1
  19. package/build/src/modeling/ApiValidation.js.map +1 -1
  20. package/build/src/modeling/DomainProperty.d.ts +12 -0
  21. package/build/src/modeling/DomainProperty.d.ts.map +1 -1
  22. package/build/src/modeling/DomainProperty.js +23 -28
  23. package/build/src/modeling/DomainProperty.js.map +1 -1
  24. package/build/src/modeling/DomainSerialization.js +1 -1
  25. package/build/src/modeling/DomainSerialization.js.map +1 -1
  26. package/build/src/modeling/ExposedEntity.d.ts +15 -1
  27. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  28. package/build/src/modeling/ExposedEntity.js +42 -4
  29. package/build/src/modeling/ExposedEntity.js.map +1 -1
  30. package/build/src/modeling/actions/Action.d.ts.map +1 -1
  31. package/build/src/modeling/actions/Action.js +1 -0
  32. package/build/src/modeling/actions/Action.js.map +1 -1
  33. package/build/src/modeling/actions/ListAction.d.ts +3 -17
  34. package/build/src/modeling/actions/ListAction.d.ts.map +1 -1
  35. package/build/src/modeling/actions/ListAction.js +18 -38
  36. package/build/src/modeling/actions/ListAction.js.map +1 -1
  37. package/build/src/modeling/actions/SearchAction.d.ts +4 -4
  38. package/build/src/modeling/actions/SearchAction.d.ts.map +1 -1
  39. package/build/src/modeling/actions/SearchAction.js +16 -13
  40. package/build/src/modeling/actions/SearchAction.js.map +1 -1
  41. package/build/src/modeling/generators/oas_312/OasGenerator.d.ts +32 -0
  42. package/build/src/modeling/generators/oas_312/OasGenerator.d.ts.map +1 -0
  43. package/build/src/modeling/generators/oas_312/OasGenerator.js +1452 -0
  44. package/build/src/modeling/generators/oas_312/OasGenerator.js.map +1 -0
  45. package/build/src/modeling/generators/oas_312/OasSchemaGenerator.d.ts +27 -0
  46. package/build/src/modeling/generators/oas_312/OasSchemaGenerator.d.ts.map +1 -0
  47. package/build/src/modeling/generators/oas_312/OasSchemaGenerator.js +295 -0
  48. package/build/src/modeling/generators/oas_312/OasSchemaGenerator.js.map +1 -0
  49. package/build/src/modeling/generators/oas_312/types.d.ts +1010 -0
  50. package/build/src/modeling/generators/oas_312/types.d.ts.map +1 -0
  51. package/build/src/modeling/generators/oas_312/types.js +2 -0
  52. package/build/src/modeling/generators/oas_312/types.js.map +1 -0
  53. package/build/src/modeling/generators/oas_320/OasGenerator.d.ts +16 -0
  54. package/build/src/modeling/generators/oas_320/OasGenerator.d.ts.map +1 -0
  55. package/build/src/modeling/generators/oas_320/OasGenerator.js +306 -0
  56. package/build/src/modeling/generators/oas_320/OasGenerator.js.map +1 -0
  57. package/build/src/modeling/generators/oas_320/OasSchemaGenerator.d.ts +25 -0
  58. package/build/src/modeling/generators/oas_320/OasSchemaGenerator.d.ts.map +1 -0
  59. package/build/src/modeling/generators/oas_320/OasSchemaGenerator.js +237 -0
  60. package/build/src/modeling/generators/oas_320/OasSchemaGenerator.js.map +1 -0
  61. package/build/src/modeling/generators/oas_320/types.d.ts +1219 -0
  62. package/build/src/modeling/generators/oas_320/types.d.ts.map +1 -0
  63. package/build/src/modeling/generators/oas_320/types.js +2 -0
  64. package/build/src/modeling/generators/oas_320/types.js.map +1 -0
  65. package/build/src/modeling/types.d.ts +50 -13
  66. package/build/src/modeling/types.d.ts.map +1 -1
  67. package/build/src/modeling/types.js.map +1 -1
  68. package/build/src/modeling/validation/api_model_rules.d.ts +1 -0
  69. package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
  70. package/build/src/modeling/validation/api_model_rules.js +105 -29
  71. package/build/src/modeling/validation/api_model_rules.js.map +1 -1
  72. package/build/src/models/ProjectRequest.d.ts.map +1 -1
  73. package/build/src/models/ProjectRequest.js +0 -4
  74. package/build/src/models/ProjectRequest.js.map +1 -1
  75. package/build/src/models/store/Organization.d.ts +13 -0
  76. package/build/src/models/store/Organization.d.ts.map +1 -1
  77. package/build/src/models/store/Organization.js.map +1 -1
  78. package/build/src/models/transformers/ArcDexieTransformer.d.ts.map +1 -1
  79. package/build/src/models/transformers/ArcDexieTransformer.js +0 -4
  80. package/build/src/models/transformers/ArcDexieTransformer.js.map +1 -1
  81. package/build/src/models/transformers/ImportUtils.js +1 -1
  82. package/build/src/models/transformers/ImportUtils.js.map +1 -1
  83. package/build/src/models/transformers/PostmanBackupTransformer.d.ts.map +1 -1
  84. package/build/src/models/transformers/PostmanBackupTransformer.js +0 -4
  85. package/build/src/models/transformers/PostmanBackupTransformer.js.map +1 -1
  86. package/build/src/runtime/constants.d.ts +7 -0
  87. package/build/src/runtime/constants.d.ts.map +1 -0
  88. package/build/src/runtime/constants.js +8 -0
  89. package/build/src/runtime/constants.js.map +1 -0
  90. package/build/src/runtime/http-engine/ntlm/Des.d.ts.map +1 -1
  91. package/build/src/runtime/http-engine/ntlm/Des.js +1 -0
  92. package/build/src/runtime/http-engine/ntlm/Des.js.map +1 -1
  93. package/build/src/runtime/variables/EvalFunctions.d.ts.map +1 -1
  94. package/build/src/runtime/variables/EvalFunctions.js +0 -1
  95. package/build/src/runtime/variables/EvalFunctions.js.map +1 -1
  96. package/build/src/sdk/OrganizationsSdk.d.ts +17 -1
  97. package/build/src/sdk/OrganizationsSdk.d.ts.map +1 -1
  98. package/build/src/sdk/OrganizationsSdk.js +76 -0
  99. package/build/src/sdk/OrganizationsSdk.js.map +1 -1
  100. package/build/src/sdk/RouteBuilder.d.ts +2 -0
  101. package/build/src/sdk/RouteBuilder.d.ts.map +1 -1
  102. package/build/src/sdk/RouteBuilder.js +6 -0
  103. package/build/src/sdk/RouteBuilder.js.map +1 -1
  104. package/build/src/sdk/SdkMock.d.ts +12 -0
  105. package/build/src/sdk/SdkMock.d.ts.map +1 -1
  106. package/build/src/sdk/SdkMock.js +32 -0
  107. package/build/src/sdk/SdkMock.js.map +1 -1
  108. package/build/tsconfig.tsbuildinfo +1 -1
  109. package/eslint.config.js +6 -0
  110. package/package.json +3 -1
  111. package/src/authorization/Utils.ts +3 -3
  112. package/src/mocking/lib/Organization.ts +22 -1
  113. package/src/modeling/ApiModel.ts +23 -8
  114. package/src/modeling/ApiValidation.ts +2 -0
  115. package/src/modeling/DomainProperty.ts +22 -18
  116. package/src/modeling/DomainSerialization.ts +1 -1
  117. package/src/modeling/ExposedEntity.ts +44 -4
  118. package/src/modeling/actions/Action.ts +1 -0
  119. package/src/modeling/actions/ListAction.ts +12 -30
  120. package/src/modeling/actions/SearchAction.ts +11 -8
  121. package/src/modeling/generators/oas_312/OasGenerator.ts +1685 -0
  122. package/src/modeling/generators/oas_312/OasSchemaGenerator.ts +322 -0
  123. package/src/modeling/generators/oas_312/types.ts +1052 -0
  124. package/src/modeling/generators/oas_320/OasGenerator.ts +359 -0
  125. package/src/modeling/generators/oas_320/OasSchemaGenerator.ts +255 -0
  126. package/src/modeling/generators/oas_320/types.ts +1259 -0
  127. package/src/modeling/types.ts +55 -22
  128. package/src/modeling/validation/api_model_rules.ts +103 -32
  129. package/src/models/ProjectRequest.ts +0 -4
  130. package/src/models/store/Organization.ts +14 -0
  131. package/src/models/transformers/ArcDexieTransformer.ts +0 -4
  132. package/src/models/transformers/ImportUtils.ts +1 -1
  133. package/src/models/transformers/PostmanBackupTransformer.ts +0 -5
  134. package/src/runtime/constants.ts +9 -0
  135. package/src/runtime/http-engine/ntlm/Des.ts +1 -0
  136. package/src/runtime/variables/EvalFunctions.ts +0 -1
  137. package/src/sdk/OrganizationsSdk.ts +81 -1
  138. package/src/sdk/RouteBuilder.ts +8 -0
  139. package/src/sdk/SdkMock.ts +50 -0
  140. package/tests/test-utils.ts +6 -2
  141. package/tests/unit/decorators/observed.spec.ts +8 -24
  142. package/tests/unit/decorators/observed_recursive.spec.ts +0 -1
  143. package/tests/unit/events/EventsTestHelpers.ts +0 -1
  144. package/tests/unit/events/events_polyfills.ts +0 -1
  145. package/tests/unit/legacy-transformers/DataTestHelper.ts +0 -2
  146. package/tests/unit/legacy-transformers/LegacyExportProcessor.spec.ts +0 -1
  147. package/tests/unit/modeling/actions/ListAction.spec.ts +9 -69
  148. package/tests/unit/modeling/actions/SearchAction.spec.ts +9 -35
  149. package/tests/unit/modeling/api_model.spec.ts +28 -0
  150. package/tests/unit/modeling/definitions/sku.spec.ts +0 -2
  151. package/tests/unit/modeling/domain_property.spec.ts +20 -1
  152. package/tests/unit/modeling/exposed_entity.spec.ts +71 -0
  153. package/tests/unit/modeling/generators/OasGenerator.spec.ts +302 -0
  154. package/tests/unit/modeling/validation/api_model_rules.spec.ts +113 -15
@@ -0,0 +1,1685 @@
1
+ import { type JSONSchema } from 'json-schema-typed/draft-2020-12'
2
+ import pluralize from '@jarrodek/pluralize'
3
+ import type {
4
+ InfoObject,
5
+ OpenApi312,
6
+ ParameterObject,
7
+ PathItemObject,
8
+ PathsObject,
9
+ SecurityRequirementObject,
10
+ SecuritySchemeObject,
11
+ OperationObject,
12
+ LinkObject,
13
+ SchemaObject,
14
+ } from './types.js'
15
+ import type { ApiModel } from '../../ApiModel.js'
16
+ import type { Action } from '../../actions/Action.js'
17
+ import { OasSchemaGenerator } from './OasSchemaGenerator.js'
18
+ import type { MatchUserRoleAccessRule } from '../../rules/MatchUserRole.js'
19
+ import type { UpdateAction } from '../../actions/UpdateAction.js'
20
+ import type { ListAction } from '../../actions/ListAction.js'
21
+ import type { CreateAction } from '../../actions/CreateAction.js'
22
+ import type { ReadAction } from '../../actions/ReadAction.js'
23
+ import type { DeleteAction } from '../../actions/DeleteAction.js'
24
+ import {
25
+ CURSOR_PAGINATION_DEFAULT_LIMIT,
26
+ CURSOR_PAGINATION_MAX_LIMIT,
27
+ CURSOR_PAGINATION_MIN_LIMIT,
28
+ OFFSET_PAGINATION_DEFAULT_LIMIT,
29
+ OFFSET_PAGINATION_MAX_LIMIT,
30
+ OFFSET_PAGINATION_MIN_LIMIT,
31
+ } from '../../../runtime/constants.js'
32
+ import { DomainEntity } from '../../DomainEntity.js'
33
+ import type { DomainPropertyType } from '../../DataFormat.js'
34
+ import type {
35
+ CursorPaginationStrategy,
36
+ OffsetPaginationStrategy,
37
+ PaginationContract,
38
+ ResourceFilterOperator,
39
+ } from '../../types.js'
40
+ import type { DomainProperty } from '../../DomainProperty.js'
41
+ import type { SearchAction } from '../../actions/SearchAction.js'
42
+
43
+ export class OasGenerator {
44
+ private whereAstInjected = false
45
+
46
+ #hasListAction = false
47
+
48
+ get hasListAction(): boolean {
49
+ return this.#hasListAction
50
+ }
51
+
52
+ #hasSearchAction = false
53
+
54
+ /**
55
+ * A flag that determines whether any of the entities has the `Search` action.
56
+ */
57
+ get hasSearchAction(): boolean {
58
+ return this.#hasSearchAction
59
+ }
60
+
61
+ /**
62
+ * @param model The API model to generate OAS for.
63
+ */
64
+ constructor(private model: ApiModel) {}
65
+
66
+ generate(): OpenApi312 {
67
+ const { domain } = this.model
68
+ if (!domain) {
69
+ throw new Error('Data domain is required for OAS generation')
70
+ }
71
+ const schemaGen = new OasSchemaGenerator()
72
+ const paths = this.generatePaths(schemaGen)
73
+
74
+ const schemas = schemaGen.getSchemas()
75
+ Object.assign(schemas, this.generateErrorSchemas())
76
+ const securitySchemes = this.generateSecuritySchemes()
77
+ const components: NonNullable<OpenApi312['components']> = {}
78
+
79
+ if (this.whereAstInjected) {
80
+ Object.assign(schemas, getAstComponentSchemas())
81
+ }
82
+ if (this.hasListAction || this.hasSearchAction) {
83
+ const paginationMeta =
84
+ this.model.pagination.kind === 'cursor' ? createCursorPaginationMeta() : createOffsetPaginationMeta()
85
+ schemas.PaginationMeta = paginationMeta
86
+ }
87
+ if (this.hasSearchAction) {
88
+ schemas.SearchRequestBody = generateSearchRequestBody(this.model.pagination)
89
+ }
90
+ components.schemas = schemas
91
+ if (Object.keys(securitySchemes).length > 0) {
92
+ components.securitySchemes = securitySchemes
93
+ }
94
+
95
+ const result: OpenApi312 = {
96
+ openapi: '3.1.0',
97
+ info: this.generateInfo(),
98
+ }
99
+
100
+ if (Object.keys(paths).length > 0) {
101
+ result.paths = paths
102
+ }
103
+
104
+ if (Object.keys(components).length > 0) {
105
+ result.components = components
106
+ }
107
+
108
+ const securityRequirements: SecurityRequirementObject[] = []
109
+ if (this.model.session?.jwt?.enabled) {
110
+ securityRequirements.push({ BearerAuth: [] })
111
+ }
112
+ if (this.model.session?.cookie?.enabled) {
113
+ securityRequirements.push({ CookieAuth: [] })
114
+ }
115
+
116
+ if (securityRequirements.length > 0) {
117
+ result.security = securityRequirements
118
+ }
119
+
120
+ if (schemaGen.hasFileUpload) {
121
+ if (!result.tags) {
122
+ result.tags = []
123
+ }
124
+ result.tags.push({
125
+ name: 'File Upload',
126
+ description:
127
+ 'To maintain a strict `application/json` data exchange contract for standard API entity actions, ' +
128
+ 'this API utilizes a decoupled upload architecture for handling binary data.\n\n' +
129
+ '### The `POST /upload` System Endpoint\nThis API exposes a dedicated, API-level ' +
130
+ '`POST /upload` endpoint to handle multipart binary uploads for properties configured with `FileURL` ' +
131
+ 'or `ImageURL` semantics.\n\n1. **Upload:** The API Consumer uploads the file to ' +
132
+ '`POST /upload`. ' +
133
+ '*Note: This endpoint strictly enforces the Max File Size and Allowed MIME Types configured by the API ' +
134
+ 'Author.*\n2. **Response:** The Runtime stores the file and returns a temporary, ' +
135
+ 'platform-managed URL string.\n3. **Attach:** The API Consumer passes this URL string as a standard JSON ' +
136
+ 'value when calling the `POST` (Create) or `PUT/PATCH` (Update) action for the actual entity.\n\n' +
137
+ '### Asset Expiration (Orphan Prevention)\nTo prevent storage bloat from abandoned uploads, any file ' +
138
+ 'uploaded to the `/upload` endpoint is assigned a Time-to-Live (TTL) limit. If the generated URL is not ' +
139
+ 'successfully associated with an entity record (via Create or Update actions) within this time limit, ' +
140
+ 'the asset is automatically purged from storage. The endpoint explicitly informs the API Consumer ' +
141
+ 'of this limit via the `expires_at` response property and the `X-Asset-Expiration` HTTP header.',
142
+ })
143
+ paths['/upload'] = {
144
+ post: {
145
+ tags: ['File Upload'],
146
+ summary: 'Upload a binary file',
147
+ description:
148
+ 'Uploads a physical file directly in the request body as raw binary data. ' +
149
+ 'This endpoint acts purely as a blob store; it does not accept or store ' +
150
+ 'file metadata (such as original filenames). Metadata should be managed ' +
151
+ 'within your entity schemas.\n\nPass the raw file bytes as the payload and ' +
152
+ 'specify the exact MIME type in the `Content-Type` header (e.g., `image/jpeg` or `application/pdf`).',
153
+ requestBody: {
154
+ required: true,
155
+ content: {
156
+ '*/*': {
157
+ schema: {
158
+ type: 'string',
159
+ format: 'binary',
160
+ description: 'The raw bytes of the file.',
161
+ },
162
+ },
163
+ },
164
+ },
165
+ responses: {
166
+ '201': {
167
+ description: 'File uploaded successfully. Returns the temporary URL and expiration details.',
168
+ headers: {
169
+ 'X-Asset-Expiration': {
170
+ description:
171
+ 'The exact timestamp when this temporary file will be purged if not attached to an entity.',
172
+ schema: {
173
+ type: 'string',
174
+ format: 'date-time',
175
+ },
176
+ },
177
+ },
178
+ content: {
179
+ 'application/json': {
180
+ schema: {
181
+ type: 'object',
182
+ properties: {
183
+ url: {
184
+ type: 'string',
185
+ format: 'uri',
186
+ description:
187
+ 'The temporary URL of the uploaded file. Pass this string into the entity Create ' +
188
+ 'or Update payload.',
189
+ },
190
+ expires_at: {
191
+ type: 'string',
192
+ format: 'date-time',
193
+ description:
194
+ 'The exact timestamp when this temporary file will be purged if not attached to an entity.',
195
+ },
196
+ },
197
+ required: ['url', 'expires_at'],
198
+ },
199
+ },
200
+ },
201
+ },
202
+ '400': {
203
+ description:
204
+ 'Validation Error. The file exceeded the maximum allowed size, its MIME type is restricted, ' +
205
+ 'or the payload is missing the required `file` property.',
206
+ summary: 'Bad Request',
207
+ content: {
208
+ 'application/json': {
209
+ schema: {
210
+ $ref: '#/components/schemas/Error400BadRequestValidationFailed',
211
+ },
212
+ },
213
+ },
214
+ },
215
+ '401': {
216
+ description: 'Unauthorized. The API key is missing or invalid.',
217
+ summary: 'Unauthorized',
218
+ content: {
219
+ 'application/json': {
220
+ schema: {
221
+ $ref: '#/components/schemas/Error401Unauthorized',
222
+ },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ },
228
+ }
229
+ }
230
+
231
+ return result
232
+ }
233
+
234
+ private generateInfo(): InfoObject {
235
+ const info: InfoObject = {
236
+ title: this.model.info.displayName || this.model.info.name || 'API',
237
+ version: this.model.info.version || '1.0.0',
238
+ }
239
+
240
+ if (this.model.info.description) {
241
+ info.description = this.model.info.description
242
+ }
243
+
244
+ if (this.model.termsOfService) {
245
+ info.termsOfService = this.model.termsOfService
246
+ }
247
+
248
+ if (this.model.contact) {
249
+ info.contact = { ...this.model.contact }
250
+ }
251
+
252
+ if (this.model.license) {
253
+ info.license = { ...this.model.license }
254
+ }
255
+
256
+ return info
257
+ }
258
+
259
+ private generateSecuritySchemes(): Record<string, SecuritySchemeObject> {
260
+ const schemes: Record<string, SecuritySchemeObject> = {}
261
+
262
+ // OpenAPI doesn't have a native "Username/Password" strategy (except via OAuth2 flows).
263
+ // The actual security required to make calls to the API is defined by the Session transport.
264
+ if (this.model.session) {
265
+ if (this.model.session.jwt?.enabled) {
266
+ schemes['BearerAuth'] = {
267
+ type: 'http',
268
+ scheme: 'bearer',
269
+ bearerFormat: 'JWT',
270
+ description: 'JWT authorization obtained after trading Username/Password credentials',
271
+ }
272
+ }
273
+ if (this.model.session.cookie?.enabled) {
274
+ schemes['CookieAuth'] = {
275
+ type: 'apiKey',
276
+ in: 'cookie',
277
+ name: this.model.session.cookie.name || 'as',
278
+ description: 'Session cookie obtained after authenticating',
279
+ }
280
+ }
281
+ }
282
+
283
+ return schemes
284
+ }
285
+
286
+ private generateErrorSchemas(): Record<string, SchemaObject> {
287
+ const createErrorSchema = (
288
+ name: string,
289
+ status: number,
290
+ typeUri: string,
291
+ title: string,
292
+ description: string
293
+ ): SchemaObject => {
294
+ const properties: Record<string, SchemaObject> = {
295
+ type: {
296
+ type: 'string',
297
+ format: 'uri',
298
+ description: 'A URI reference that identifies the problem type.',
299
+ example: typeUri,
300
+ },
301
+ title: {
302
+ type: 'string',
303
+ description: 'A short, human-readable summary of the problem type.',
304
+ example: title,
305
+ },
306
+ status: {
307
+ type: 'integer',
308
+ description: 'The HTTP status code generated by the origin server for this occurrence of the problem.',
309
+ example: status,
310
+ },
311
+ detail: {
312
+ type: 'string',
313
+ description: 'A human-readable explanation specific to this occurrence of the problem.',
314
+ },
315
+ instance: {
316
+ type: 'string',
317
+ format: 'uri',
318
+ description: 'A URI reference that identifies the specific occurrence of the problem.',
319
+ },
320
+ }
321
+
322
+ // Add a generic "errors" array specifically for validation errors to align with popular JSON frameworks
323
+ if (name === 'ValidationError') {
324
+ properties['errors'] = {
325
+ type: 'array',
326
+ description: 'A list of specifically mapped validation rule failures.',
327
+ items: {
328
+ type: 'object',
329
+ properties: {
330
+ field: { type: 'string', description: 'The property name that failed validation.' },
331
+ message: { type: 'string', description: 'Description of the validation failure.' },
332
+ },
333
+ },
334
+ }
335
+ }
336
+
337
+ return {
338
+ title: name,
339
+ type: 'object',
340
+ description,
341
+ properties,
342
+ required: ['type', 'title', 'status'],
343
+ }
344
+ }
345
+
346
+ return {
347
+ Error400BadRequestValidationFailed: createErrorSchema(
348
+ 'ValidationError',
349
+ 400,
350
+ 'https://docs.apinow.app/errors/validation-failed',
351
+ 'Validation Error',
352
+ 'Request payload fails schema validation (missing fields, wrong types) or contains invalid query parameters.'
353
+ ),
354
+ Error400BadRequestInvalidFormat: createErrorSchema(
355
+ 'InvalidRequestFormatError',
356
+ 400,
357
+ 'https://docs.apinow.app/errors/invalid-request',
358
+ 'Invalid Request Format',
359
+ 'The request body is not parsable as JSON (when expected), or other malformations.'
360
+ ),
361
+ Error401Unauthorized: createErrorSchema(
362
+ 'AuthenticationRequiredError',
363
+ 401,
364
+ 'https://docs.apinow.app/errors/authentication-required',
365
+ 'Authentication Required',
366
+ 'JWT is missing, malformed, or invalid (e.g., signature mismatch, expired).'
367
+ ),
368
+ Error403Forbidden: createErrorSchema(
369
+ 'AccessDeniedError',
370
+ 403,
371
+ 'https://docs.apinow.app/errors/access-denied',
372
+ 'Access Denied',
373
+ 'An authenticated user does not have permission to perform the requested operation on the resource ' +
374
+ 'based on rules.'
375
+ ),
376
+ Error404NotFound: createErrorSchema(
377
+ 'ResourceNotFoundError',
378
+ 404,
379
+ 'https://docs.apinow.app/errors/resource-not-found',
380
+ 'Resource Not Found',
381
+ 'The requested resource (e.g., specific entity instance via ID) does not exist.'
382
+ ),
383
+ Error409Conflict: createErrorSchema(
384
+ 'ResourceConflictError',
385
+ 409,
386
+ 'https://docs.apinow.app/errors/resource-conflict',
387
+ 'Resource Conflict',
388
+ 'Attempt to create a resource that would violate a uniqueness constraint (e.g., unique email already exists).'
389
+ ),
390
+ Error429TooManyRequests: createErrorSchema(
391
+ 'RateLimitExceededError',
392
+ 429,
393
+ 'https://docs.apinow.app/errors/rate-limit-exceeded',
394
+ 'Rate Limit Exceeded',
395
+ 'API consumer has exceeded configured rate limits.'
396
+ ),
397
+ Error500InternalServerError: createErrorSchema(
398
+ 'InternalServerError',
399
+ 500,
400
+ 'https://docs.apinow.app/errors/internal-server-error',
401
+ 'Internal Server Error',
402
+ 'An unexpected error occurred on the server while processing the request. Details should not expose ' +
403
+ 'sensitive info.'
404
+ ),
405
+ }
406
+ }
407
+
408
+ private generatePaths(schemaGen: OasSchemaGenerator): PathsObject {
409
+ const paths: PathsObject = {}
410
+
411
+ // Depending on the configured session transports, the API will expose different login endpoints.
412
+ if (this.model.authentication?.strategy === 'UsernamePassword') {
413
+ if (this.model.session?.jwt?.enabled) {
414
+ const jwtEndpoints = createJwtEndpoints()
415
+ Object.assign(paths, jwtEndpoints)
416
+ }
417
+
418
+ if (this.model.session?.cookie?.enabled) {
419
+ const cookieEndpoints = createCookieEndpoints()
420
+ Object.assign(paths, cookieEndpoints)
421
+ }
422
+ }
423
+
424
+ for (const expose of this.model.exposes.values()) {
425
+ const colPath = expose.getAbsoluteCollectionPath()
426
+ const resPath = expose.getAbsoluteResourcePath()
427
+
428
+ const domainEntity = this.model.domain?.findEntity(expose.entity.key)
429
+ if (!domainEntity) {
430
+ // This should not happen after the API model is validated.
431
+ // Our runtime require a schema for each expose.
432
+ continue
433
+ }
434
+ const schemaRef = schemaGen.generateRootRef(domainEntity)
435
+ if (!schemaRef) {
436
+ // This should not happen after the API model is validated.
437
+ // Our runtime require a schema for each expose.
438
+ continue
439
+ }
440
+ const links: Record<string, LinkObject> = {}
441
+ for (const assoc of domainEntity.associations) {
442
+ if (assoc.schema?.linked === false) {
443
+ continue
444
+ }
445
+ const targets = Array.from(assoc.listTargets())
446
+ if (targets.length === 0) continue
447
+
448
+ const targetEntity = targets[0]
449
+ const targetExpose = Array.from(this.model.exposes.values()).find(
450
+ (e) => e.entity.key === targetEntity.key && e.actions.some((a) => a.kind === 'read')
451
+ )
452
+
453
+ if (targetExpose) {
454
+ const targetResPath = targetExpose.getAbsoluteResourcePath()
455
+ if (targetResPath) {
456
+ const params = this.extractPathParameters(targetResPath)
457
+ const paramName = params[0]?.name || 'id'
458
+ const propName = assoc.info.name || assoc.key
459
+
460
+ links[`${targetEntity.info.name || targetEntity.key}By${propName}`] = {
461
+ operationId: `read_${targetEntity.info.name || 'entity ' + targetEntity.key}`,
462
+ parameters: {
463
+ [paramName]: `$response.body#/${propName}`,
464
+ },
465
+ description: `Fetch the associated ${targetEntity.info.name || targetEntity.key} using the ${propName} property`,
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ const hasLinks = Object.keys(links).length > 0
472
+ const operationIdKey = domainEntity.info.name || domainEntity.key
473
+ const entityName = domainEntity.info.getLabel()
474
+ const entityNameUpper = upperFirst(entityName) as string
475
+ const pluralName = pluralize(entityNameUpper)
476
+ // We discover the search action in the collection processing,
477
+ // but since it's a dedicated path, we process it separately.
478
+ let searchAction: SearchAction | undefined
479
+
480
+ if (expose.hasCollection && colPath) {
481
+ const pathItem: PathItemObject = paths[colPath] || {}
482
+ paths[colPath] = pathItem
483
+
484
+ const listAction = expose.actions.find((a) => a.kind === 'list') as ListAction | undefined
485
+ const createAction = expose.actions.find((a) => a.kind === 'create') as CreateAction | undefined
486
+ searchAction = expose.actions.find((a) => a.kind === 'search') as SearchAction | undefined
487
+
488
+ if (listAction) {
489
+ this.#hasListAction = true
490
+ const isCursor = this.model.pagination.kind === 'cursor'
491
+ const paginationParameters = isCursor
492
+ ? this.createCursorPaginationQueryParameters(colPath)
493
+ : this.createOffsetPaginationQueryParameters(colPath)
494
+ const op: OperationObject = {
495
+ operationId: `list_${operationIdKey}`,
496
+ summary: `Retrieves a paginated list of ${pluralName}`,
497
+ description:
498
+ `This endpoint is optimized for high-performance index lookups. It supports advanced filtering, ` +
499
+ `sorting, and ${this.model.pagination.kind}-based pagination.\n\n` +
500
+ `You can filter the list using bracket notation directly in the query string. Filters are appended to the field name: \`?field[operator]=value\`.\n\n` +
501
+ `Note: You can apply multiple filters to the same request. By default, all filters are evaluated using logical \`AND\`.`,
502
+ parameters: [
503
+ ...paginationParameters,
504
+ ...this.createListParameters(domainEntity, colPath, expose.paginationContract),
505
+ ],
506
+ responses: {
507
+ '200': {
508
+ description: `Successfully retrieved a paginated list of **${entityNameUpper}** records.\n\nReturns an array of records wrapped in a standard \`data\` envelope. The response also includes a \`meta\` object containing pagination details.`,
509
+ content: {
510
+ 'application/json': {
511
+ schema: {
512
+ title: `Paginated${upperFirst(domainEntity.info.name) || domainEntity.key}List`,
513
+ type: 'object',
514
+ properties: {
515
+ data: {
516
+ type: 'array',
517
+ items: schemaRef,
518
+ description: `List of page results for ${pluralName}. Can be empty. See the \`meta\` object for pagination details.`,
519
+ },
520
+ meta: { $ref: '#/components/schemas/PaginationMeta' },
521
+ },
522
+ required: ['data', 'meta'],
523
+ },
524
+ },
525
+ },
526
+ },
527
+ },
528
+ }
529
+ this.applyActionSecurity(listAction, op)
530
+ this.applyStandardResponse(listAction, op, { hasBody: false, isResource: false, isCreate: false })
531
+ pathItem.get = op
532
+ }
533
+
534
+ if (createAction) {
535
+ const op: OperationObject = {
536
+ operationId: `create_${operationIdKey}`,
537
+ summary: `Creates a ${entityNameUpper}`,
538
+ description:
539
+ `Creates a new **${entityNameUpper}** record.\n\n` +
540
+ `This endpoint accepts a standard JSON payload representing the properties of the new entity. ` +
541
+ `The provided payload is strictly validated against the ${entityNameUpper} schema.\n\n` +
542
+ '**Required Fields:** Any property marked as required must be included in the payload, ' +
543
+ 'otherwise the request will be rejected with a `400 Bad Request`.\n' +
544
+ '* **System Fields:** System-generated properties (such as `id`, `created_at`, or `updated_at`) ' +
545
+ 'cannot be set via this endpoint and will be safely ignored if included.\n\n' +
546
+ '*Note: This endpoint does not accept `multipart/form-data` payloads. If your resource contains ' +
547
+ "binary properties, you must first upload the physical file to the API's `/upload` endpoint, " +
548
+ 'and then pass the resulting URL string in this JSON payload.*',
549
+ requestBody: {
550
+ required: true,
551
+ content: {
552
+ 'application/json': {
553
+ schema: schemaRef,
554
+ },
555
+ },
556
+ },
557
+ responses: {
558
+ '201': {
559
+ description: `The **${entityNameUpper}** was successfully created.\n\nReturns the fully realized record. The payload includes all properties validated from the request, alongside all system-generated fields such as the unique \`id\` and initialization timestamps (\`created_at\`, \`updated_at\`).`,
560
+ content: {
561
+ 'application/json': {
562
+ schema: schemaRef,
563
+ },
564
+ },
565
+ ...(hasLinks && { links }),
566
+ },
567
+ },
568
+ }
569
+ this.applyActionSecurity(createAction, op)
570
+ this.applyStandardResponse(createAction, op, { hasBody: true, isResource: false, isCreate: true })
571
+ pathItem.post = op
572
+ }
573
+ }
574
+
575
+ if (resPath) {
576
+ const pathItem: PathItemObject = paths[resPath] || {}
577
+ paths[resPath] = pathItem
578
+
579
+ const readAction = expose.actions.find((a) => a.kind === 'read') as ReadAction | undefined
580
+ const updateAction = expose.actions.find((a) => a.kind === 'update') as UpdateAction | undefined
581
+ const deleteAction = expose.actions.find((a) => a.kind === 'delete') as DeleteAction | undefined
582
+
583
+ const parameters = this.extractPathParameters(resPath)
584
+
585
+ if (readAction) {
586
+ const op: OperationObject = {
587
+ operationId: `read_${operationIdKey}`,
588
+ summary: `Retrieves the details of an existing record for the ${entityNameUpper}`,
589
+ description: `You must supply the unique system-generated identifier (id) that was returned upon creation.`,
590
+ parameters,
591
+ responses: {
592
+ '200': {
593
+ description: `Successfully retrieved the **${entityNameUpper}** record.`,
594
+ content: {
595
+ 'application/json': { schema: schemaRef },
596
+ },
597
+ ...(hasLinks && { links }),
598
+ },
599
+ },
600
+ }
601
+ this.applyActionSecurity(readAction, op)
602
+ this.applyStandardResponse(readAction, op, { hasBody: false, isResource: true, isCreate: false })
603
+ pathItem.get = op
604
+ }
605
+
606
+ if (updateAction) {
607
+ if (updateAction.allowedMethods.includes('PUT')) {
608
+ pathItem.put = {
609
+ parameters,
610
+ requestBody: {
611
+ required: true,
612
+ content: { 'application/json': { schema: schemaRef } },
613
+ },
614
+ responses: {
615
+ '200': {
616
+ description: `The **${entityNameUpper}** was successfully replaced.\n\nReturns the complete, current state of the record. The payload reflects all modifications made during the request, as well as any system-generated updates (such as a newly modified \`updated_at\` timestamp).`,
617
+ content: {
618
+ 'application/json': { schema: schemaRef },
619
+ },
620
+ ...(hasLinks && { links }),
621
+ },
622
+ },
623
+ operationId: `replace_${operationIdKey}`,
624
+ summary: `Replace a ${entityNameUpper}`,
625
+ description: `Replaces the entire **${entityNameUpper}** record with the provided JSON payload. \n\nThis is a strict replacement operation. Any editable properties that are omitted from the request payload will be cleared or reset to their default values. The payload is strictly validated against the ${entityNameUpper} schema. System-generated fields cannot be modified and will be ignored if included.\n\n*Note: For properties configured as \`FileURL\` or \`ImageURL\`, you must upload the file to the API's \`/upload\` endpoint first and pass the resulting URL string in this payload.*`,
626
+ }
627
+ this.applyActionSecurity(updateAction, pathItem.put)
628
+ this.applyStandardResponse(updateAction, pathItem.put, { hasBody: true, isResource: true, isCreate: false })
629
+ }
630
+ if (updateAction.allowedMethods.includes('PATCH')) {
631
+ // @todo: the patch operation makes all properties optional
632
+ pathItem.patch = {
633
+ parameters,
634
+ requestBody: {
635
+ required: true,
636
+ content: { 'application/json': { schema: schemaRef } },
637
+ },
638
+ responses: {
639
+ '200': {
640
+ description: `The **${entityNameUpper}** was successfully updated.\n\nReturns the complete, current state of the record. The payload reflects all modifications made during the request, as well as any system-generated updates (such as a newly modified \`updated_at\` timestamp).`,
641
+ content: {
642
+ 'application/json': { schema: schemaRef },
643
+ },
644
+ ...(hasLinks && { links }),
645
+ },
646
+ },
647
+ operationId: `partial_update_${operationIdKey}`,
648
+ summary: `Update a ${entityNameUpper}`,
649
+ description: `Updates specific properties of the specified **${entityNameUpper}** without affecting other existing data. \n\nOnly the fields provided in the payload will be modified; any parameters omitted from the request will remain unchanged. The request payload is validated against the ${entityNameUpper} schema. System-generated fields (like \`id\` and \`created_at\`) cannot be modified.\n\n*Note: For properties configured as \`FileURL\` or \`ImageURL\`, you must upload the file to the API's \`/upload\` endpoint first and pass the resulting URL string in this payload.*`,
650
+ }
651
+ this.applyActionSecurity(updateAction, pathItem.patch)
652
+ this.applyStandardResponse(updateAction, pathItem.patch, {
653
+ hasBody: true,
654
+ isResource: true,
655
+ isCreate: false,
656
+ })
657
+ }
658
+ }
659
+
660
+ if (deleteAction) {
661
+ const isSoftDelete = deleteAction.strategy === 'soft'
662
+ const actionText = isSoftDelete ? 'marked for deletion' : 'permanently deleted'
663
+ const op: OperationObject = {
664
+ operationId: `delete_${operationIdKey}`,
665
+ parameters,
666
+ responses: {
667
+ '204': {
668
+ description: `The **${entityNameUpper}** was successfully ${actionText}.\n\nThis endpoint returns a \`204 No Content\` status code upon success, indicating that the server has fulfilled the request and there is intentionally no content to send in the response payload body.`,
669
+ },
670
+ },
671
+ }
672
+ if (isSoftDelete) {
673
+ op.summary = `Delete a ${entityNameUpper}`
674
+ op.description = `Soft-deletes the specified **${entityNameUpper}** record.\n\nThis action immediately removes the record from standard API responses (such as List and Read operations). The record is retained securely in the system for a recovery period of **${deleteAction.retentionPeriod} days**, after which it is permanently destroyed. \n\nIf the record is successfully marked for deletion, the endpoint returns a \`204 No Content\` response with an empty body.`
675
+ } else {
676
+ op.summary = `Permanently delete a ${entityNameUpper}`
677
+ op.description = `Permanently deletes the specified **${entityNameUpper}** record.\n\nThis action is immediate and **cannot be undone**. You must supply the unique system-generated identifier (\`id\`) of the record to delete. If the record is successfully deleted, the endpoint returns a \`204 No Content\` response with an empty body.`
678
+ }
679
+ this.applyActionSecurity(deleteAction, op)
680
+ this.applyStandardResponse(deleteAction, op, { hasBody: false, isResource: true, isCreate: false })
681
+ pathItem.delete = op
682
+ }
683
+ }
684
+
685
+ if (searchAction) {
686
+ this.#hasSearchAction = true
687
+ const searchPath = `${colPath}/search`
688
+ const pathItem: PathItemObject = paths[searchPath] || {}
689
+ paths[searchPath] = pathItem
690
+
691
+ const searchProperties: DomainProperty[] = []
692
+ for (const prop of domainEntity.properties) {
693
+ if (prop.search) {
694
+ searchProperties.push(prop)
695
+ }
696
+ }
697
+ const formattedSearchFields =
698
+ searchProperties.length > 0
699
+ ? searchProperties.map((p) => `\`${p.info.name}\``).join(', ')
700
+ : '*None configured*'
701
+
702
+ if (!this.whereAstInjected) {
703
+ this.whereAstInjected = true
704
+ }
705
+
706
+ const maxLimit = this.model.pagination.maxLimit ?? 100
707
+
708
+ const op: OperationObject = {
709
+ tags: [entityNameUpper],
710
+ operationId: `search_${entityName}s`,
711
+ summary: `Search ${entityName}s`,
712
+ description: `Retrieves a paginated list of **${entityNameUpper}** records using an advanced JSON query payload.\n\nUnlike the standard \`GET\` List endpoint, this endpoint uses a \`POST\` request to accept a complex Abstract Syntax Tree (AST) for deeply nested logical filtering (\`and\`, \`or\`) and full-text substring searching.\n\n### Full-Text Search Capabilities\nThe API Author has explicitly enabled the \`contains\` operator for the following fields: ${formattedSearchFields}. Attempting to use the \`contains\` operator on any other field will result in a \`400 Bad Request\` to prevent unoptimized table scans.\n\n### Payload Structure\n* \`where\`: The root query object. Supports nested \`and\` / \`or\` arrays, and standard field operators (\`eq\`, \`in\`, \`gt\`, \`contains\`).\n* \`sort\`: An array of objects specifying the \`field\` name and sort \`direction\` (\`asc\` or \`desc\`).\n* \`limit\`: The maximum number of records to return (Max ${maxLimit}).\n* \`cursor\`: The opaque pagination token.`,
713
+ requestBody: {
714
+ required: true,
715
+ content: {
716
+ 'application/json': {
717
+ schema: { $ref: '#/components/schemas/SearchRequestBody' },
718
+ example: {
719
+ where: {
720
+ and: [
721
+ { status: { in: ['active', 'pending'] } },
722
+ searchProperties.length > 0
723
+ ? { [searchProperties[0].info.name as string]: { contains: 'search keyword' } }
724
+ : { created_at: { gte: '2025-01-01T00:00:00Z' } },
725
+ {
726
+ or: [{ role: { eq: 'admin' } }, { views: { gt: 100 } }],
727
+ },
728
+ ],
729
+ },
730
+ sort: [
731
+ { field: 'created_at', direction: 'desc' },
732
+ { field: 'id', direction: 'asc' },
733
+ ],
734
+ limit: 25,
735
+ },
736
+ },
737
+ },
738
+ },
739
+ responses: {
740
+ '200': {
741
+ description: `Successfully retrieved a paginated list of **${entityNameUpper}** records matching the search criteria.\n\nReturns an array of records wrapped in a standard \`data\` envelope. The response also includes a \`meta\` object containing pagination details.`,
742
+ content: {
743
+ 'application/json': {
744
+ schema: {
745
+ type: 'object',
746
+ properties: {
747
+ data: {
748
+ type: 'array',
749
+ items: schemaGen.generateRootRef(domainEntity),
750
+ },
751
+ meta: { $ref: '#/components/schemas/PaginationMeta' },
752
+ },
753
+ },
754
+ },
755
+ },
756
+ },
757
+ '400': {
758
+ description:
759
+ 'Bad Request. The AST payload is malformed, uses an unsupported operator for a field, ' +
760
+ 'or targets a non-existent property.',
761
+ content: {
762
+ 'application/json': {
763
+ schema: { $ref: '#/components/schemas/Error400BadRequestValidationFailed' },
764
+ },
765
+ },
766
+ },
767
+ '401': {
768
+ description: 'Unauthorized. Valid authentication is required.',
769
+ content: {
770
+ 'application/json': {
771
+ schema: { $ref: '#/components/schemas/Error401Unauthorized' },
772
+ },
773
+ },
774
+ },
775
+ '403': {
776
+ description: 'Forbidden. The authenticated user lacks permission to search this resource.',
777
+ content: {
778
+ 'application/json': {
779
+ schema: { $ref: '#/components/schemas/Error403Forbidden' },
780
+ },
781
+ },
782
+ },
783
+ },
784
+ }
785
+ pathItem.post = op
786
+ }
787
+ }
788
+
789
+ return paths
790
+ }
791
+
792
+ private extractPathParameters(path: string): ParameterObject[] {
793
+ const params: ParameterObject[] = []
794
+ const regex = /\{([^}]+)\}/g
795
+ let match: RegExpExecArray | null
796
+ while ((match = regex.exec(path)) !== null) {
797
+ params.push({
798
+ name: match[1],
799
+ in: 'path',
800
+ required: true,
801
+ schema: { type: 'string' },
802
+ })
803
+ }
804
+ return params
805
+ }
806
+
807
+ private applyActionSecurity(action: Action, operation: OperationObject): void {
808
+ const rules = action.accessRule
809
+
810
+ // Check if anonymous access is explicitly allowed via an AllowPublic rule
811
+ const isPublic = rules.some((r) => r.type === 'allowPublic')
812
+ if (isPublic) {
813
+ operation.security = []
814
+ return
815
+ }
816
+
817
+ if (rules.length > 0) {
818
+ // It's restricted. Define operational security requirements.
819
+ const items: SecurityRequirementObject[] = []
820
+ if (this.model.session?.jwt?.enabled) items.push({ BearerAuth: [] })
821
+ if (this.model.session?.cookie?.enabled) items.push({ CookieAuth: [] })
822
+
823
+ if (items.length > 0) {
824
+ operation.security = items
825
+ }
826
+ }
827
+
828
+ // Extract any matching roles specific to the RBAC capabilities
829
+ const roleRules = rules.filter((r) => r.type === 'matchUserRole') as MatchUserRoleAccessRule[]
830
+ const roles = roleRules.flatMap((r) => r.role || [])
831
+
832
+ if (roles.length > 0) {
833
+ const uniqueRoles = Array.from(new Set(roles))
834
+
835
+ // Inject natively as vendor extension for processing tooling
836
+ // ;(operation as any)['x-roles'] = uniqueRoles
837
+
838
+ // Append strictly to the markdown description so developers always see the RBAC bounds
839
+ const rolesDesc = `**Required Roles:** ${uniqueRoles.join(', ')}`
840
+ operation.description = operation.description ? `${operation.description}\n\n${rolesDesc}` : rolesDesc
841
+ }
842
+ }
843
+
844
+ private applyStandardResponse(
845
+ action: Action,
846
+ operation: OperationObject,
847
+ opts: { hasBody: boolean; isResource: boolean; isCreate: boolean }
848
+ ): void {
849
+ const rules = action.getAllRules()
850
+ const isPublic = rules.some((r) => r.type === 'allowPublic')
851
+ if (!operation.responses) {
852
+ operation.responses = {}
853
+ }
854
+ if (opts.hasBody) {
855
+ operation.responses['400'] = {
856
+ description: 'The request body is not parsable as JSON (when expected), or other malformations.',
857
+ summary: 'Bad Request',
858
+ content: {
859
+ 'application/json': {
860
+ schema: {
861
+ $ref: '#/components/schemas/Error400BadRequestInvalidFormat',
862
+ },
863
+ },
864
+ },
865
+ }
866
+ } else {
867
+ operation.responses['400'] = {
868
+ description:
869
+ 'The request payload failed schema validation (e.g., missing required fields, invalid data types, ' +
870
+ 'or a string exceeding the maximum configured length). The response body will contain an RFC 7807 ' +
871
+ 'problem details object explaining the specific validation failures.',
872
+ summary: 'Bad Request',
873
+ content: {
874
+ 'application/json': {
875
+ schema: {
876
+ $ref: '#/components/schemas/Error400BadRequestValidationFailed',
877
+ },
878
+ },
879
+ },
880
+ }
881
+ }
882
+ if (!isPublic) {
883
+ operation.responses['401'] = {
884
+ description: 'JWT or session cookie is missing, malformed, or invalid (e.g., signature mismatch, expired).',
885
+ summary: 'Unauthorized',
886
+ content: {
887
+ 'application/json': {
888
+ schema: {
889
+ $ref: '#/components/schemas/Error401Unauthorized',
890
+ },
891
+ },
892
+ },
893
+ }
894
+ operation.responses['403'] = {
895
+ description:
896
+ 'An authenticated user does not have permission to perform the requested operation on ' +
897
+ 'the resource based on rules.',
898
+ summary: 'Forbidden',
899
+ content: {
900
+ 'application/json': {
901
+ schema: {
902
+ $ref: '#/components/schemas/Error403Forbidden',
903
+ },
904
+ },
905
+ },
906
+ }
907
+ }
908
+ if (opts.isResource) {
909
+ operation.responses['404'] = {
910
+ description: 'The requested resource (e.g., specific entity instance via ID) does not exist.',
911
+ summary: 'Not Found',
912
+ content: {
913
+ 'application/json': {
914
+ schema: {
915
+ $ref: '#/components/schemas/Error404NotFound',
916
+ },
917
+ },
918
+ },
919
+ }
920
+ }
921
+ if (opts.isCreate) {
922
+ operation.responses['409'] = {
923
+ description:
924
+ 'Attempt to create a resource that would violate a uniqueness constraint ' +
925
+ '(e.g., unique email already exists).',
926
+ summary: 'Resource Conflict',
927
+ content: {
928
+ 'application/json': {
929
+ schema: {
930
+ $ref: '#/components/schemas/Error409Conflict',
931
+ },
932
+ },
933
+ },
934
+ }
935
+ }
936
+ operation.responses['429'] = {
937
+ description:
938
+ 'The request has been rejected because the client has sent too many requests in a given amount of time.',
939
+ summary: 'Too Many Requests',
940
+ content: {
941
+ 'application/json': {
942
+ schema: {
943
+ $ref: '#/components/schemas/Error429TooManyRequests',
944
+ },
945
+ },
946
+ },
947
+ }
948
+ operation.responses['500'] = {
949
+ description:
950
+ 'An unexpected error occurred on the server while processing the request. Details should not ' +
951
+ 'expose sensitive info.',
952
+ summary: 'Internal Server Error',
953
+ content: {
954
+ 'application/json': {
955
+ schema: {
956
+ $ref: '#/components/schemas/Error500InternalServerError',
957
+ },
958
+ },
959
+ },
960
+ }
961
+ }
962
+
963
+ private resolvePaginationDefault(
964
+ configuredDefault: number | undefined,
965
+ min: number,
966
+ maximum: number,
967
+ fallbackDefault: number
968
+ ): number {
969
+ const defaultValue =
970
+ typeof configuredDefault === 'number' && !Number.isNaN(configuredDefault) ? configuredDefault : fallbackDefault
971
+ return Math.max(min, Math.min(defaultValue, maximum))
972
+ }
973
+
974
+ private resolvePaginationMaximum(configuredMax: number | undefined, min: number, fallbackMax: number): number {
975
+ if (typeof configuredMax !== 'number' || Number.isNaN(configuredMax)) {
976
+ return fallbackMax
977
+ }
978
+ return Math.max(min, Math.min(configuredMax, fallbackMax))
979
+ }
980
+
981
+ private createCursorPaginationQueryParameters(path: string): ParameterObject[] {
982
+ const maximum = this.resolvePaginationMaximum(
983
+ this.model.pagination.maxLimit,
984
+ CURSOR_PAGINATION_MIN_LIMIT,
985
+ CURSOR_PAGINATION_MAX_LIMIT
986
+ )
987
+ const defaultLimit = this.resolvePaginationDefault(
988
+ this.model.pagination.defaultLimit,
989
+ CURSOR_PAGINATION_MIN_LIMIT,
990
+ maximum,
991
+ CURSOR_PAGINATION_DEFAULT_LIMIT
992
+ )
993
+ const result: ParameterObject[] = [
994
+ {
995
+ in: 'query',
996
+ name: 'limit',
997
+ required: false,
998
+ schema: {
999
+ type: 'integer',
1000
+ default: defaultLimit,
1001
+ minimum: CURSOR_PAGINATION_MIN_LIMIT,
1002
+ maximum,
1003
+ description: 'Number of items to return. Used with the cursor pagination strategy.',
1004
+ },
1005
+ example: `${path}?limit=10`,
1006
+ },
1007
+ {
1008
+ in: 'query',
1009
+ name: 'cursor',
1010
+ required: false,
1011
+ schema: {
1012
+ type: 'string',
1013
+ description: 'The cursor to use for pagination. Use the `next_cursor` from the previous response.',
1014
+ },
1015
+ example: `${path}?cursor=eyJ2IjpbInVzcl85eTIwYTMiXSwicyI6W3siZiI6ImlkIiwiZCI6ImFzYyJ9XX0=`,
1016
+ },
1017
+ ]
1018
+ return result
1019
+ }
1020
+
1021
+ private createOffsetPaginationQueryParameters(path: string): ParameterObject[] {
1022
+ const maximum = this.resolvePaginationMaximum(
1023
+ this.model.pagination.maxLimit,
1024
+ OFFSET_PAGINATION_MIN_LIMIT,
1025
+ OFFSET_PAGINATION_MAX_LIMIT
1026
+ )
1027
+ const defaultLimit = this.resolvePaginationDefault(
1028
+ this.model.pagination.defaultLimit,
1029
+ OFFSET_PAGINATION_MIN_LIMIT,
1030
+ maximum,
1031
+ OFFSET_PAGINATION_DEFAULT_LIMIT
1032
+ )
1033
+ const result: ParameterObject[] = [
1034
+ {
1035
+ in: 'query',
1036
+ name: 'limit',
1037
+ required: false,
1038
+ schema: {
1039
+ type: 'integer',
1040
+ default: defaultLimit,
1041
+ minimum: OFFSET_PAGINATION_MIN_LIMIT,
1042
+ maximum,
1043
+ description: 'Number of items to return. Used with the offset pagination strategy.',
1044
+ },
1045
+ example: `${path}?limit=10`,
1046
+ },
1047
+ {
1048
+ in: 'query',
1049
+ name: 'page',
1050
+ required: false,
1051
+ schema: {
1052
+ type: 'integer',
1053
+ default: 1,
1054
+ minimum: 1,
1055
+ description: 'The page number to return. Used with the offset pagination strategy.',
1056
+ },
1057
+ example: `${path}?page=2`,
1058
+ },
1059
+ ]
1060
+ return result
1061
+ }
1062
+
1063
+ private createListParameters(
1064
+ entity: DomainEntity,
1065
+ path: string,
1066
+ paginationContract?: PaginationContract
1067
+ ): ParameterObject[] {
1068
+ const ff = paginationContract?.filterableFields
1069
+ const filterableFields = Array.isArray(ff) && ff.length ? new Set(ff) : undefined
1070
+ const sf = paginationContract?.sortableFields
1071
+ const sortableFields = Array.isArray(sf) && sf.length ? new Set(sf) : undefined
1072
+ const result: ParameterObject[] = []
1073
+ if (sortableFields?.size) {
1074
+ result.push(this.createSortParameter(path, entity))
1075
+ }
1076
+ for (const prop of entity.properties) {
1077
+ if (prop.index) {
1078
+ if (filterableFields && !filterableFields.has(prop.key)) {
1079
+ // The API author explicitly excluded this property from filtering
1080
+ continue
1081
+ }
1082
+ const applicableOperators = OPERATOR_MAPPING[prop.type]
1083
+ for (const operator of applicableOperators) {
1084
+ result.push(this.createListParameter(prop, operator, path))
1085
+ }
1086
+ }
1087
+ }
1088
+ return result
1089
+ }
1090
+
1091
+ private createListParameter(
1092
+ property: DomainProperty,
1093
+ operator: ResourceFilterOperator,
1094
+ path: string
1095
+ ): ParameterObject {
1096
+ const paramName = `${property.info.name}[${operator}]`
1097
+ const name = `${property.info.name}`
1098
+
1099
+ // Base Parameter scaffolding
1100
+ const result: ParameterObject = {
1101
+ in: 'query',
1102
+ name: paramName,
1103
+ description: `${OPERATOR_DESCRIPTIONS[operator]}`,
1104
+ required: false,
1105
+ example: OPERATOR_EXAMPLES[operator](path, name),
1106
+ schema: {},
1107
+ }
1108
+
1109
+ // The 'exists' operator ALWAYS takes a boolean, regardless of the underlying field type
1110
+ if (operator === 'exists') {
1111
+ result.schema = { type: 'boolean' }
1112
+ return result
1113
+ }
1114
+
1115
+ // Get the base schema for the field
1116
+ const baseSchema = resolveOasSchema(property.type)
1117
+ // ParameterObject already has the example. We do not need it on the schema anymore.
1118
+
1119
+ // If the operator is 'in' or 'nin', we must wrap the schema in an Array
1120
+ if (operator === 'in' || operator === 'nin') {
1121
+ result.style = 'form'
1122
+ result.explode = false
1123
+ result.schema = {
1124
+ type: 'array',
1125
+ items: baseSchema,
1126
+ }
1127
+ } else {
1128
+ // For scalar operators (eq, lt, gt), just use the base schema directly
1129
+ result.schema = baseSchema
1130
+ }
1131
+ return result
1132
+ }
1133
+
1134
+ private createSortParameter(path: string, entity: DomainEntity): ParameterObject {
1135
+ const indexedFields: string[] = []
1136
+ for (const prop of entity.properties) {
1137
+ if (prop.index && prop.info.name) {
1138
+ indexedFields.push(prop.info.name)
1139
+ }
1140
+ }
1141
+ if (indexedFields.length === 0) {
1142
+ indexedFields.push('name')
1143
+ indexedFields.push('created_at')
1144
+ } else if (indexedFields.length === 1) {
1145
+ indexedFields.push('created_at')
1146
+ }
1147
+ const result: ParameterObject = {
1148
+ in: 'query',
1149
+ name: 'sort',
1150
+ description: 'Comma-separated list of fields to sort by. Prefix with `-` for descending order.',
1151
+ required: false,
1152
+ example: `${path}?sort=${indexedFields[0]},-${indexedFields[1]}`,
1153
+ schema: {
1154
+ type: 'string',
1155
+ },
1156
+ }
1157
+ return result
1158
+ }
1159
+ }
1160
+
1161
+ const OPERATOR_MAPPING: Record<DomainPropertyType, ResourceFilterOperator[]> = {
1162
+ string: ['eq', 'neq', 'in', 'nin', 'exists'],
1163
+ number: ['eq', 'neq', 'in', 'nin', 'lt', 'lte', 'gt', 'gte', 'exists'],
1164
+ datetime: ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'exists'],
1165
+ date: ['eq', 'neq', 'in', 'nin', 'lt', 'lte', 'gt', 'gte', 'exists'],
1166
+ time: ['eq', 'neq', 'in', 'nin', 'lt', 'lte', 'gt', 'gte', 'exists'],
1167
+ boolean: ['eq', 'neq', 'exists'],
1168
+ binary: ['exists'], // Binary data/URLs should only be queried for existence, not exact match
1169
+ }
1170
+ const OPERATOR_DESCRIPTIONS: Record<ResourceFilterOperator, string> = {
1171
+ eq: 'Returns records where the property equals this exact value.',
1172
+ neq: 'Returns records where the property does not equal this value.',
1173
+ in: 'Requires a comma-separated list. Returns records matching any of the listed values.',
1174
+ nin: 'Requires a comma-separated list. Omits records matching any of the listed values.',
1175
+ lt: 'Numeric or Date comparison. Returns records strictly less than the provided value.',
1176
+ lte: 'Numeric or Date comparison. Returns records less than or equal to the provided value.',
1177
+ gt: 'Numeric or Date comparison. Returns records strictly greater than the provided value.',
1178
+ gte: 'Numeric or Date comparison. Returns records greater than or equal to the provided value.',
1179
+ exists: 'Requires a boolean (true/false). Checks if the property is defined and not null.',
1180
+ // These are not used in query parameters
1181
+ contains: 'Not implemented yet',
1182
+ match: 'Not implemented yet',
1183
+ }
1184
+ const OPERATOR_EXAMPLES: Record<ResourceFilterOperator, (path: string, key: string) => string> = {
1185
+ eq: (path, key) => `${path}?${key}[eq]=value`,
1186
+ neq: (path, key) => `${path}?${key}[neq]=value`,
1187
+ in: (path, key) => `${path}?${key}[in]=value1,value2,value3`,
1188
+ nin: (path, key) => `${path}?${key}[nin]=value1,value2,value3`,
1189
+ lt: (path, key) => `${path}?${key}[lt]=value`,
1190
+ lte: (path, key) => `${path}?${key}[lte]=value`,
1191
+ gt: (path, key) => `${path}?${key}[gt]=value`,
1192
+ gte: (path, key) => `${path}?${key}[gte]=value`,
1193
+ exists: (path, key) => `${path}?${key}[exists]=true`,
1194
+ contains: (path, key) => `${path}?${key}[contains]=value`,
1195
+ match: (path, key) => `${path}?${key}[match]=value`,
1196
+ }
1197
+
1198
+ /**
1199
+ * Helper: Maps the internal FieldType to a valid OAS 3.1 Schema Object
1200
+ */
1201
+ function resolveOasSchema(fieldType: DomainPropertyType): JSONSchema {
1202
+ switch (fieldType) {
1203
+ case 'number':
1204
+ return { type: 'number' }
1205
+ case 'boolean':
1206
+ return { type: 'boolean' }
1207
+ case 'datetime':
1208
+ return { type: 'string', format: 'date-time' }
1209
+ case 'date':
1210
+ return { type: 'string', format: 'date' }
1211
+ case 'time':
1212
+ return { type: 'string', format: 'time' }
1213
+ case 'binary':
1214
+ return { type: 'string', format: 'uri' }
1215
+ case 'string':
1216
+ default:
1217
+ return { type: 'string' }
1218
+ }
1219
+ }
1220
+
1221
+ function upperFirst(value?: string): string | undefined {
1222
+ if (!value) {
1223
+ return undefined
1224
+ }
1225
+ return value.charAt(0).toUpperCase() + value.slice(1)
1226
+ }
1227
+
1228
+ /**
1229
+ * Injects the global AST schema definitions into the OAS document.
1230
+ * Call this once when initializing your OAS generator.
1231
+ */
1232
+ function getAstComponentSchemas() {
1233
+ return {
1234
+ SearchWhereAST: {
1235
+ type: 'object',
1236
+ description: 'The root query object. Supports nested logical operators and field-level conditions.',
1237
+ $ref: '#/components/schemas/SearchWhereNode',
1238
+ },
1239
+ SearchWhereNode: {
1240
+ type: 'object',
1241
+ description: 'A node in the AST. Can be a logical operator (AND/OR) or a specific field condition.',
1242
+ oneOf: [
1243
+ {
1244
+ title: 'LogicalAndNode',
1245
+ type: 'object',
1246
+ properties: {
1247
+ and: {
1248
+ type: 'array',
1249
+ items: { $ref: '#/components/schemas/SearchWhereNode' },
1250
+ },
1251
+ },
1252
+ required: ['and'],
1253
+ additionalProperties: false,
1254
+ },
1255
+ {
1256
+ title: 'LogicalOrNode',
1257
+ type: 'object',
1258
+ properties: {
1259
+ or: {
1260
+ type: 'array',
1261
+ items: { $ref: '#/components/schemas/SearchWhereNode' },
1262
+ },
1263
+ },
1264
+ required: ['or'],
1265
+ additionalProperties: false,
1266
+ },
1267
+ {
1268
+ title: 'FieldConditionNode',
1269
+ type: 'object',
1270
+ minProperties: 1,
1271
+ maxProperties: 1,
1272
+ description: 'A dynamic key representing the field name, mapped to its operator conditions.',
1273
+ patternProperties: {
1274
+ '^[a-zA-Z0-9_]+$': {
1275
+ $ref: '#/components/schemas/SearchWhereOperatorMap',
1276
+ },
1277
+ },
1278
+ additionalProperties: false,
1279
+ },
1280
+ ],
1281
+ },
1282
+ SearchWhereOperatorMap: {
1283
+ type: 'object',
1284
+ minProperties: 1,
1285
+ maxProperties: 1,
1286
+ properties: {
1287
+ eq: {},
1288
+ neq: {},
1289
+ in: { type: 'array' },
1290
+ nin: { type: 'array' },
1291
+ gt: { type: ['number', 'string'] },
1292
+ gte: { type: ['number', 'string'] },
1293
+ lt: { type: ['number', 'string'] },
1294
+ lte: { type: ['number', 'string'] },
1295
+ contains: { type: 'string' },
1296
+ match: { type: 'string' },
1297
+ exists: { type: 'boolean' },
1298
+ },
1299
+ additionalProperties: false,
1300
+ },
1301
+ }
1302
+ }
1303
+
1304
+ function createJwtEndpoints(): PathsObject {
1305
+ const paths: PathsObject = {}
1306
+ paths['/auth/token'] = {
1307
+ post: {
1308
+ tags: ['Authentication'],
1309
+ operationId: 'session_token_exchange',
1310
+ summary: 'Create a session token',
1311
+ description:
1312
+ 'Authenticates a user via credentials and returns a JSON Web Token (JWT). \n\n' +
1313
+ 'This token represents an active user session. You must include this token in the ' +
1314
+ '`Authorization` header as a Bearer token (`Authorization: Bearer <token>`) for all ' +
1315
+ 'subsequent requests to protected API endpoints. \n\n*Note: Tokens have a limited ' +
1316
+ 'lifespan. Clients are responsible for securely storing the token and handling expiration gracefully.*',
1317
+ requestBody: {
1318
+ required: true,
1319
+ content: {
1320
+ 'application/json': {
1321
+ schema: {
1322
+ type: 'object',
1323
+ properties: {
1324
+ username: {
1325
+ type: 'string',
1326
+ description: 'The unique identifier or email address of the user.',
1327
+ example: 'alex.carter@example.com',
1328
+ },
1329
+ password: {
1330
+ type: 'string',
1331
+ format: 'password',
1332
+ description: 'The plain-text password for the account.',
1333
+ example: 'correct-horse-battery-staple',
1334
+ },
1335
+ },
1336
+ required: ['username', 'password'],
1337
+ } as SchemaObject,
1338
+ },
1339
+ },
1340
+ },
1341
+ responses: {
1342
+ '200': {
1343
+ description: 'Authentication successful. Returns the JWT required for accessing protected endpoints.',
1344
+ content: {
1345
+ 'application/json': {
1346
+ schema: {
1347
+ type: 'object',
1348
+ properties: {
1349
+ token: {
1350
+ type: 'string',
1351
+ description: 'The JSON Web Token (JWT) representing the active session.',
1352
+ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI...',
1353
+ },
1354
+ },
1355
+ required: ['token'],
1356
+ } as SchemaObject,
1357
+ },
1358
+ },
1359
+ },
1360
+ '400': {
1361
+ description: 'Bad Request. The request payload is missing the required username or password.',
1362
+ },
1363
+ '401': {
1364
+ description: 'Unauthorized. The provided credentials are incorrect, or the account is locked.',
1365
+ },
1366
+ },
1367
+ security: [], // Explicitly requires no prior auth to hit this endpoint
1368
+ },
1369
+ }
1370
+
1371
+ paths['/auth/token/{token}'] = {
1372
+ delete: {
1373
+ tags: ['Authentication'],
1374
+ operationId: 'session_token_revoke',
1375
+ summary: 'Revoke a session token',
1376
+ description:
1377
+ 'Invalidates an active JSON Web Token (JWT), effectively logging the user out and terminating ' +
1378
+ 'their session on the API. \n\nOnce revoked, the token is added to a system ' +
1379
+ 'blocklist and will be immediately rejected by all protected endpoints. For security best practices, ' +
1380
+ 'client applications should call this endpoint whenever a user explicitly logs out.',
1381
+ parameters: [
1382
+ {
1383
+ name: 'token',
1384
+ in: 'path',
1385
+ required: true,
1386
+ schema: { type: 'string' },
1387
+ description: 'The exact session token string to be revoked.',
1388
+ },
1389
+ ],
1390
+ responses: {
1391
+ '204': {
1392
+ description:
1393
+ 'The token was successfully revoked. This endpoint intentionally returns an empty response body.',
1394
+ },
1395
+ '401': {
1396
+ description: 'Unauthorized. The token provided is already expired, malformed, or previously revoked.',
1397
+ },
1398
+ },
1399
+ security: [], // Usually no global security is needed if the token itself is in the path
1400
+ },
1401
+ }
1402
+ return paths
1403
+ }
1404
+
1405
+ function createCookieEndpoints(): PathsObject {
1406
+ const paths: PathsObject = {}
1407
+ paths['/auth/cookie'] = {
1408
+ post: {
1409
+ tags: ['Authentication'],
1410
+ operationId: 'session_cookie_exchange',
1411
+ summary: 'Authenticate and set session cookie',
1412
+ description:
1413
+ 'Authenticates a user via credentials and establishes a secure session cookie. \n\n' +
1414
+ 'Unlike the JWT token endpoint, this route is specifically designed for browser-based ' +
1415
+ 'applications. Upon successful authentication, the API will attach a ' +
1416
+ '`Set-Cookie` header (configured as `HttpOnly`, `Secure`, and `SameSite`) to the response, ' +
1417
+ "and return a `302 Found` status to automatically redirect the user's browser to the provided " +
1418
+ '`redirectUri`.',
1419
+ requestBody: {
1420
+ required: true,
1421
+ content: {
1422
+ 'application/json': {
1423
+ schema: {
1424
+ type: 'object',
1425
+ properties: {
1426
+ username: {
1427
+ type: 'string',
1428
+ description: 'The unique identifier or email address of the user.',
1429
+ example: 'alex.carter@example.com',
1430
+ },
1431
+ password: {
1432
+ type: 'string',
1433
+ format: 'password',
1434
+ description: 'The plain-text password for the account.',
1435
+ example: 'correct-horse-battery-staple',
1436
+ },
1437
+ redirectUri: {
1438
+ type: 'string',
1439
+ format: 'uri',
1440
+ description:
1441
+ 'The exact URL destination where the browser should be redirected after ' +
1442
+ 'the cookie is successfully set.',
1443
+ example: 'https://app.yourdomain.com/dashboard',
1444
+ },
1445
+ },
1446
+ required: ['username', 'password', 'redirectUri'],
1447
+ } as SchemaObject,
1448
+ },
1449
+ },
1450
+ },
1451
+ responses: {
1452
+ '302': {
1453
+ description:
1454
+ 'Authentication successful. The browser is redirected to the `redirectUri` with the session ' +
1455
+ 'cookie established.',
1456
+ headers: {
1457
+ 'Set-Cookie': {
1458
+ description:
1459
+ 'The secure session cookie containing the authentication state. Managed automatically ' +
1460
+ 'by the browser.',
1461
+ schema: { type: 'string' },
1462
+ },
1463
+ 'Location': {
1464
+ description: 'The URL destination for the browser redirect.',
1465
+ schema: { type: 'string' },
1466
+ },
1467
+ },
1468
+ },
1469
+ '400': {
1470
+ description: 'Bad Request. The payload is missing the username, password, or redirectUri.',
1471
+ },
1472
+ '401': {
1473
+ description: 'Unauthorized. The provided credentials are incorrect.',
1474
+ },
1475
+ },
1476
+ security: [], // No prior authentication required
1477
+ },
1478
+ }
1479
+
1480
+ paths['/auth/cookie/logout'] = {
1481
+ post: {
1482
+ tags: ['Authentication'],
1483
+ operationId: 'session_cookie_logout',
1484
+ summary: 'Clear session cookie and redirect',
1485
+ description:
1486
+ 'Terminates an active browser session by clearing the authentication cookie and redirecting ' +
1487
+ 'the user.\n\nThe API will return a `302 Found` response with a `Set-Cookie` ' +
1488
+ 'header that explicitly invalidates the session cookie (by setting its expiration date to the past). ' +
1489
+ 'The browser is then seamlessly redirected to the provided `redirectUri` ' +
1490
+ '(e.g., a public login page or homepage).',
1491
+ requestBody: {
1492
+ required: true,
1493
+ content: {
1494
+ 'application/json': {
1495
+ schema: {
1496
+ type: 'object',
1497
+ properties: {
1498
+ redirectUri: {
1499
+ type: 'string',
1500
+ format: 'uri',
1501
+ description:
1502
+ 'The URL destination where the browser should be redirected after the cookie is destroyed.',
1503
+ example: 'https://app.yourdomain.com/login?logged_out=true',
1504
+ },
1505
+ },
1506
+ required: ['redirectUri'],
1507
+ } as SchemaObject,
1508
+ },
1509
+ },
1510
+ },
1511
+ responses: {
1512
+ '302': {
1513
+ description: 'Logout successful. The session cookie is cleared and the browser is redirected.',
1514
+ headers: {
1515
+ 'Set-Cookie': {
1516
+ description: 'An invalidated cookie payload instructing the browser to delete the stored session data.',
1517
+ schema: { type: 'string' },
1518
+ },
1519
+ 'Location': {
1520
+ description: 'The URL destination for the browser redirect.',
1521
+ schema: { type: 'string' },
1522
+ },
1523
+ },
1524
+ },
1525
+ },
1526
+ security: [], // Safe to call even if the cookie is already expired
1527
+ },
1528
+ }
1529
+ return paths
1530
+ }
1531
+
1532
+ function createOffsetPaginationMeta(): SchemaObject {
1533
+ // We adopt the AdonisJS pagination schema.
1534
+ const properties: Record<string, SchemaObject> = {
1535
+ per_page: {
1536
+ type: 'integer',
1537
+ default: OFFSET_PAGINATION_DEFAULT_LIMIT,
1538
+ minimum: OFFSET_PAGINATION_MIN_LIMIT,
1539
+ maximum: OFFSET_PAGINATION_MAX_LIMIT,
1540
+ description: 'Number of items per page',
1541
+ example: '10',
1542
+ },
1543
+ current_page: {
1544
+ type: 'integer',
1545
+ default: 1,
1546
+ minimum: 1,
1547
+ description: 'The current page number',
1548
+ example: '1',
1549
+ },
1550
+ first_page: {
1551
+ type: 'integer',
1552
+ default: 1,
1553
+ minimum: 1,
1554
+ description: 'The first page number. The first page is always 1.',
1555
+ example: '1',
1556
+ },
1557
+ is_empty: {
1558
+ type: 'boolean',
1559
+ description: 'Whether the collection is empty.',
1560
+ example: 'false',
1561
+ },
1562
+ total: {
1563
+ type: 'integer',
1564
+ minimum: 0,
1565
+ description: 'The total number of items',
1566
+ example: '100',
1567
+ },
1568
+ has_total: {
1569
+ type: 'boolean',
1570
+ description:
1571
+ 'Whether the total number of items is available. This is not same as `isEmpty`.\n\n' +
1572
+ 'The `isEmpty` reports about the current set of results. However `hasTotal` reports ' +
1573
+ 'about the total number of records, regardless of the current.',
1574
+ example: 'true',
1575
+ },
1576
+ last_page: {
1577
+ type: 'integer',
1578
+ minimum: 1,
1579
+ description: 'The last page number.',
1580
+ example: '10',
1581
+ },
1582
+ has_more_pages: {
1583
+ type: 'boolean',
1584
+ description: 'Whether there are more pages.',
1585
+ example: 'true',
1586
+ },
1587
+ has_pages: {
1588
+ type: 'boolean',
1589
+ description: 'Whether the collection has pages.',
1590
+ example: 'true',
1591
+ },
1592
+ }
1593
+
1594
+ const schema: SchemaObject = {
1595
+ type: 'object',
1596
+ description: 'Offset pagination metadata.',
1597
+ properties,
1598
+ required: ['per_page', 'current_page', 'first_page', 'is_empty', 'has_total', 'has_more_pages', 'has_pages'],
1599
+ }
1600
+ return schema
1601
+ }
1602
+
1603
+ function createCursorPaginationMeta(): SchemaObject {
1604
+ const properties: Record<string, SchemaObject> = {
1605
+ next_cursor: {
1606
+ type: 'string',
1607
+ description: 'The cursor to use for pagination.',
1608
+ title: 'Next cursor',
1609
+ example: 'eyJ2IjpbInVzcl85eTIwYTMiXSwicyI6W3siZiI6ImlkIiwiZCI6ImFzYyJ9XX0=',
1610
+ },
1611
+ has_more: {
1612
+ type: 'boolean',
1613
+ description: 'Whether there are more pages.',
1614
+ title: 'Has more',
1615
+ example: 'true',
1616
+ },
1617
+ }
1618
+
1619
+ const schema: SchemaObject = {
1620
+ type: 'object',
1621
+ description: 'Cursor pagination metadata.',
1622
+ properties,
1623
+ required: ['next_cursor', 'has_more'],
1624
+ }
1625
+ return schema
1626
+ }
1627
+
1628
+ function generateSearchRequestBody(pagination: CursorPaginationStrategy | OffsetPaginationStrategy): SchemaObject {
1629
+ const isCursorPagination = pagination.kind === 'cursor'
1630
+ const paginationProperties: Record<string, SchemaObject> = isCursorPagination
1631
+ ? {
1632
+ cursor: {
1633
+ type: 'string',
1634
+ description: 'Pagination cursor retrieved from a previous response meta object.',
1635
+ },
1636
+ }
1637
+ : {
1638
+ page: {
1639
+ type: 'integer',
1640
+ minimum: 1,
1641
+ default: 1,
1642
+ description: 'Page number for offset pagination.',
1643
+ },
1644
+ }
1645
+ return {
1646
+ type: 'object',
1647
+ properties: {
1648
+ where: {
1649
+ type: 'object',
1650
+ description: 'The AST query object for filtering records.',
1651
+ $ref: '#/components/schemas/SearchWhereAST',
1652
+ },
1653
+ sort: {
1654
+ type: 'array',
1655
+ description: 'An array of sorting directives applied in order.',
1656
+ items: {
1657
+ type: 'object',
1658
+ properties: {
1659
+ field: {
1660
+ type: 'string',
1661
+ description: 'The name of the indexable property to sort by.',
1662
+ },
1663
+ direction: {
1664
+ type: 'string',
1665
+ enum: ['asc', 'desc'],
1666
+ description: 'The sort direction (ascending or descending).',
1667
+ },
1668
+ },
1669
+ required: ['field', 'direction'],
1670
+ },
1671
+ },
1672
+ limit: {
1673
+ type: 'integer',
1674
+ minimum: isCursorPagination ? CURSOR_PAGINATION_MIN_LIMIT : OFFSET_PAGINATION_MIN_LIMIT,
1675
+ maximum: isCursorPagination
1676
+ ? (pagination.maxLimit ?? CURSOR_PAGINATION_MAX_LIMIT)
1677
+ : (pagination.maxLimit ?? OFFSET_PAGINATION_MAX_LIMIT),
1678
+ default: isCursorPagination
1679
+ ? (pagination.defaultLimit ?? CURSOR_PAGINATION_DEFAULT_LIMIT)
1680
+ : (pagination.defaultLimit ?? OFFSET_PAGINATION_DEFAULT_LIMIT),
1681
+ },
1682
+ ...paginationProperties,
1683
+ },
1684
+ }
1685
+ }