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