@rvoh/psychic 2.3.8 → 3.0.0-alpha.1
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/dist/cjs/src/cli/index.js +4 -0
- package/dist/cjs/src/controller/helpers/logIfDevelopment.js +5 -5
- package/dist/cjs/src/controller/index.js +119 -40
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +15 -1
- package/dist/cjs/src/error/openapi/UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute.js +44 -0
- package/dist/cjs/src/error/router/cannot-commit-routes-without-koa-app.js +12 -0
- package/dist/cjs/src/helpers/toJson.js +2 -8
- package/dist/cjs/src/helpers/validateOpenApiSchema.js +1 -1
- package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +2 -2
- package/dist/cjs/src/openapi-renderer/endpoint.js +2 -2
- package/dist/cjs/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +75 -9
- package/dist/cjs/src/openapi-renderer/helpers/{dreamAttributeOpenapiShape.js → dreamColumnOpenapiShape.js} +19 -6
- package/dist/cjs/src/openapi-renderer/helpers/stringify-cache.js +55 -0
- package/dist/cjs/src/openapi-renderer/helpers/validator-cache.js +52 -0
- package/dist/cjs/src/psychic-app/helpers/import/importControllers.js +1 -1
- package/dist/cjs/src/psychic-app/index.js +3 -10
- package/dist/cjs/src/router/index.js +31 -25
- package/dist/cjs/src/server/helpers/startPsychicServer.js +6 -2
- package/dist/cjs/src/server/index.js +32 -35
- package/dist/cjs/src/server/params.js +56 -3
- package/dist/cjs/src/session/index.js +9 -12
- package/dist/esm/src/cli/index.js +4 -0
- package/dist/esm/src/controller/helpers/logIfDevelopment.js +5 -5
- package/dist/esm/src/controller/index.js +119 -40
- package/dist/esm/src/devtools/helpers/launchDevServer.js +15 -1
- package/dist/esm/src/error/openapi/UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute.js +44 -0
- package/dist/esm/src/error/router/cannot-commit-routes-without-koa-app.js +12 -0
- package/dist/esm/src/helpers/toJson.js +2 -8
- package/dist/esm/src/helpers/validateOpenApiSchema.js +1 -1
- package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +2 -2
- package/dist/esm/src/openapi-renderer/endpoint.js +2 -2
- package/dist/esm/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +75 -9
- package/dist/esm/src/openapi-renderer/helpers/{dreamAttributeOpenapiShape.js → dreamColumnOpenapiShape.js} +19 -6
- package/dist/esm/src/openapi-renderer/helpers/stringify-cache.js +55 -0
- package/dist/esm/src/openapi-renderer/helpers/validator-cache.js +52 -0
- package/dist/esm/src/psychic-app/helpers/import/importControllers.js +1 -1
- package/dist/esm/src/psychic-app/index.js +3 -10
- package/dist/esm/src/router/index.js +31 -25
- package/dist/esm/src/server/helpers/startPsychicServer.js +6 -2
- package/dist/esm/src/server/index.js +32 -35
- package/dist/esm/src/server/params.js +56 -3
- package/dist/esm/src/session/index.js +9 -12
- package/dist/types/src/controller/helpers/logIfDevelopment.d.ts +3 -4
- package/dist/types/src/controller/index.d.ts +19 -8
- package/dist/types/src/devtools/helpers/launchDevServer.d.ts +2 -1
- package/dist/types/src/error/openapi/UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute.d.ts +7 -0
- package/dist/types/src/error/router/cannot-commit-routes-without-koa-app.d.ts +3 -0
- package/dist/types/src/helpers/cookieMaxAgeFromCookieOpts.d.ts +1 -1
- package/dist/types/src/helpers/toJson.d.ts +1 -1
- package/dist/types/src/helpers/validateOpenApiSchema.d.ts +5 -1
- package/dist/types/src/openapi-renderer/helpers/OpenapiPayloadValidator.d.ts +41 -0
- package/dist/types/src/openapi-renderer/helpers/{dreamAttributeOpenapiShape.d.ts → dreamColumnOpenapiShape.d.ts} +1 -1
- package/dist/types/src/openapi-renderer/helpers/stringify-cache.d.ts +34 -0
- package/dist/types/src/openapi-renderer/helpers/validator-cache.d.ts +35 -0
- package/dist/types/src/psychic-app/index.d.ts +11 -14
- package/dist/types/src/router/index.d.ts +17 -17
- package/dist/types/src/router/route-manager.d.ts +4 -3
- package/dist/types/src/server/helpers/startPsychicServer.d.ts +3 -3
- package/dist/types/src/server/index.d.ts +3 -3
- package/dist/types/src/server/params.d.ts +2 -2
- package/dist/types/src/session/index.d.ts +13 -5
- package/package.json +30 -19
- package/dist/cjs/src/error/router/cannot-commit-routes-without-express-app.js +0 -12
- package/dist/esm/src/error/router/cannot-commit-routes-without-express-app.js +0 -12
- package/dist/types/src/error/router/cannot-commit-routes-without-express-app.d.ts +0 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CalendarDate, DateTime } from '@rvoh/dream';
|
|
1
|
+
import { CalendarDate, ClockTime, ClockTimeTz, DateTime } from '@rvoh/dream';
|
|
2
2
|
import { camelize, compact, snakeify } from '@rvoh/dream/utils';
|
|
3
3
|
import ParamValidationError from '../error/controller/ParamValidationError.js';
|
|
4
4
|
import ParamValidationErrors from '../error/controller/ParamValidationErrors.js';
|
|
@@ -85,6 +85,22 @@ export default class Params {
|
|
|
85
85
|
case 'timestamp without time zone[]':
|
|
86
86
|
returnObj[columnName] = this.cast(params, columnName.toString(), 'datetime[]', { allowNull: columnMetadata.allowNull });
|
|
87
87
|
break;
|
|
88
|
+
case 'time':
|
|
89
|
+
case 'time without time zone':
|
|
90
|
+
returnObj[columnName] = this.cast(params, columnName.toString(), 'time', { allowNull: columnMetadata.allowNull });
|
|
91
|
+
break;
|
|
92
|
+
case 'time[]':
|
|
93
|
+
case 'time without time zone[]':
|
|
94
|
+
returnObj[columnName] = this.cast(params, columnName.toString(), 'time[]', { allowNull: columnMetadata.allowNull });
|
|
95
|
+
break;
|
|
96
|
+
case 'timetz':
|
|
97
|
+
case 'time with time zone':
|
|
98
|
+
returnObj[columnName] = this.cast(params, columnName.toString(), 'timetz', { allowNull: columnMetadata.allowNull });
|
|
99
|
+
break;
|
|
100
|
+
case 'timetz[]':
|
|
101
|
+
case 'time with time zone[]':
|
|
102
|
+
returnObj[columnName] = this.cast(params, columnName.toString(), 'timetz[]', { allowNull: columnMetadata.allowNull });
|
|
103
|
+
break;
|
|
88
104
|
case 'jsonb':
|
|
89
105
|
returnObj[columnName] = this.cast(params, columnName.toString(), 'json', { allowNull: columnMetadata.allowNull });
|
|
90
106
|
break;
|
|
@@ -203,7 +219,6 @@ export default class Params {
|
|
|
203
219
|
}
|
|
204
220
|
return paramValue;
|
|
205
221
|
}
|
|
206
|
-
let dateClass;
|
|
207
222
|
const integerRegexp = /^-?\d+$/;
|
|
208
223
|
switch (expectedType) {
|
|
209
224
|
case 'string':
|
|
@@ -227,7 +242,8 @@ export default class Params {
|
|
|
227
242
|
return false;
|
|
228
243
|
throw new ParamValidationError(paramName, [typeToError(expectedType)]);
|
|
229
244
|
case 'datetime':
|
|
230
|
-
case 'date':
|
|
245
|
+
case 'date': {
|
|
246
|
+
let dateClass;
|
|
231
247
|
switch (expectedType) {
|
|
232
248
|
case 'datetime':
|
|
233
249
|
dateClass = DateTime;
|
|
@@ -252,6 +268,35 @@ export default class Params {
|
|
|
252
268
|
}
|
|
253
269
|
}
|
|
254
270
|
throw new ParamValidationError(paramName, [typeToError(expectedType)]);
|
|
271
|
+
}
|
|
272
|
+
case 'time':
|
|
273
|
+
case 'timetz': {
|
|
274
|
+
let timeClass;
|
|
275
|
+
switch (expectedType) {
|
|
276
|
+
case 'time':
|
|
277
|
+
timeClass = ClockTime;
|
|
278
|
+
break;
|
|
279
|
+
case 'timetz':
|
|
280
|
+
timeClass = ClockTimeTz;
|
|
281
|
+
break;
|
|
282
|
+
default:
|
|
283
|
+
if (typeof expectedType === 'string')
|
|
284
|
+
throw Error(`${expectedType} must be "time" or "timetz"`);
|
|
285
|
+
else
|
|
286
|
+
throw Error(`expectedType is not a string`);
|
|
287
|
+
}
|
|
288
|
+
if (paramValue instanceof ClockTime || paramValue instanceof ClockTimeTz)
|
|
289
|
+
return paramValue;
|
|
290
|
+
if (typeof paramValue === 'string') {
|
|
291
|
+
try {
|
|
292
|
+
return timeClass.fromISO(paramValue);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw new ParamValidationError(paramName, [typeToError(expectedType)]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
throw new ParamValidationError(paramName, [typeToError(expectedType)]);
|
|
299
|
+
}
|
|
255
300
|
case 'integer':
|
|
256
301
|
if (typeof paramValue !== 'string' && typeof paramValue !== 'number')
|
|
257
302
|
throw new ParamValidationError(paramName, [typeToError(expectedType)]);
|
|
@@ -287,6 +332,8 @@ export default class Params {
|
|
|
287
332
|
case 'json[]':
|
|
288
333
|
case 'number[]':
|
|
289
334
|
case 'string[]':
|
|
335
|
+
case 'time[]':
|
|
336
|
+
case 'timetz[]':
|
|
290
337
|
case 'uuid[]':
|
|
291
338
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
292
339
|
if (!Array.isArray(paramValue))
|
|
@@ -365,6 +412,8 @@ const typeToErrorMap = {
|
|
|
365
412
|
null: 'expecting null',
|
|
366
413
|
number: 'expected number or string number',
|
|
367
414
|
string: 'expected string',
|
|
415
|
+
time: 'expected ISO time string',
|
|
416
|
+
timetz: 'expected ISO timetz string',
|
|
368
417
|
uuid: 'expected uuid',
|
|
369
418
|
'bigint[]': 'expected bigint array',
|
|
370
419
|
'boolean[]': 'expected boolean array',
|
|
@@ -375,6 +424,8 @@ const typeToErrorMap = {
|
|
|
375
424
|
'null[]': 'expecting null array',
|
|
376
425
|
'number[]': 'expected number or string number array',
|
|
377
426
|
'string[]': 'expected string array',
|
|
427
|
+
'time[]': 'expected ISO time string array',
|
|
428
|
+
'timetz[]': 'expected ISO timetz string array',
|
|
378
429
|
'uuid[]': 'expected uuid array',
|
|
379
430
|
};
|
|
380
431
|
function typeToError(param) {
|
|
@@ -393,6 +444,8 @@ const arrayTypeToNonArrayTypeMap = {
|
|
|
393
444
|
'null[]': 'null',
|
|
394
445
|
'number[]': 'number',
|
|
395
446
|
'string[]': 'string',
|
|
447
|
+
'time[]': 'time',
|
|
448
|
+
'timetz[]': 'timetz',
|
|
396
449
|
'uuid[]': 'uuid',
|
|
397
450
|
};
|
|
398
451
|
function arrayTypeToNonArrayType(param) {
|
|
@@ -3,31 +3,28 @@ import cookieMaxAgeFromCookieOpts from '../helpers/cookieMaxAgeFromCookieOpts.js
|
|
|
3
3
|
import EnvInternal from '../helpers/EnvInternal.js';
|
|
4
4
|
import PsychicApp from '../psychic-app/index.js';
|
|
5
5
|
export default class Session {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
this.req = req;
|
|
10
|
-
this.res = res;
|
|
6
|
+
ctx;
|
|
7
|
+
constructor(ctx) {
|
|
8
|
+
this.ctx = ctx;
|
|
11
9
|
}
|
|
12
10
|
getCookie(name) {
|
|
13
|
-
const
|
|
14
|
-
const value = cookies[name];
|
|
11
|
+
const value = this.ctx.cookies.get(name);
|
|
15
12
|
if (value)
|
|
16
13
|
return InternalEncrypt.decryptCookie(value);
|
|
17
14
|
return null;
|
|
18
15
|
}
|
|
19
16
|
setCookie(name, data, opts = {}) {
|
|
20
|
-
this.
|
|
21
|
-
secure: EnvInternal.isProduction,
|
|
22
|
-
httpOnly: true,
|
|
17
|
+
this.ctx.cookies.set(name, InternalEncrypt.encryptCookie(data), {
|
|
23
18
|
...opts,
|
|
19
|
+
secure: opts.secure ?? EnvInternal.isProduction,
|
|
20
|
+
httpOnly: opts.httpOnly ?? true,
|
|
24
21
|
maxAge: opts.maxAge
|
|
25
22
|
? cookieMaxAgeFromCookieOpts(opts.maxAge)
|
|
26
|
-
: PsychicApp.getOrFail().cookieOptions?.maxAge,
|
|
23
|
+
: (PsychicApp.getOrFail().cookieOptions?.maxAge ?? cookieMaxAgeFromCookieOpts()),
|
|
27
24
|
});
|
|
28
25
|
}
|
|
29
26
|
clearCookie(name) {
|
|
30
|
-
this.
|
|
27
|
+
this.ctx.cookies.set(name, '', { maxAge: 0 });
|
|
31
28
|
}
|
|
32
29
|
daysToMilliseconds(numDays) {
|
|
33
30
|
return numDays * 60 * 60 * 24 * 1000;
|
|
@@ -3,20 +3,20 @@ import colorize from '../../cli/helpers/colorize.js';
|
|
|
3
3
|
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
4
4
|
import { httpMethodBgColor } from './httpMethodColor.js';
|
|
5
5
|
import { statusCodeBgColor } from './statusCodeColor.js';
|
|
6
|
-
export default function logIfDevelopment({
|
|
6
|
+
export default function logIfDevelopment({ ctx, startTime, fallbackStatusCode = 200, }) {
|
|
7
7
|
if (!EnvInternal.isDevelopment)
|
|
8
8
|
return;
|
|
9
|
-
const method = colorize(` ${
|
|
9
|
+
const method = colorize(` ${ctx.method.toUpperCase()} `, {
|
|
10
10
|
color: 'black',
|
|
11
|
-
bgColor: httpMethodBgColor(
|
|
11
|
+
bgColor: httpMethodBgColor(ctx.method.toLowerCase()),
|
|
12
12
|
});
|
|
13
|
-
const computedStatus =
|
|
13
|
+
const computedStatus = ctx.status || fallbackStatusCode;
|
|
14
14
|
const statusBgColor = statusCodeBgColor(computedStatus);
|
|
15
15
|
const status = colorize(` ${computedStatus} `, {
|
|
16
16
|
color: 'black',
|
|
17
17
|
bgColor: statusBgColor,
|
|
18
18
|
});
|
|
19
|
-
const url = colorize(
|
|
19
|
+
const url = colorize(ctx.url, { color: 'green' });
|
|
20
20
|
const benchmark = colorize(`${Date.now() - startTime}ms`, { color: 'gray' });
|
|
21
21
|
DreamCLI.logger.log(`${method} ${url} ${status} ${benchmark}`, { logPrefix: '' });
|
|
22
22
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Dream, DreamApp } from '@rvoh/dream';
|
|
2
2
|
import { GlobalNameNotSet } from '@rvoh/dream/errors';
|
|
3
3
|
import { DreamSerializerBuilder, ObjectSerializerBuilder } from '@rvoh/dream/system';
|
|
4
|
+
import fastJsonStringify from 'fast-json-stringify';
|
|
4
5
|
import ParamValidationError from '../error/controller/ParamValidationError.js';
|
|
5
6
|
import HttpStatusBadGateway from '../error/http/BadGateway.js';
|
|
6
7
|
import HttpStatusBadRequest from '../error/http/BadRequest.js';
|
|
@@ -36,6 +37,7 @@ import HttpStatusUnsupportedMediaType from '../error/http/UnsupportedMediaType.j
|
|
|
36
37
|
import EnvInternal from '../helpers/EnvInternal.js';
|
|
37
38
|
import toJson from '../helpers/toJson.js';
|
|
38
39
|
import OpenapiPayloadValidator from '../openapi-renderer/helpers/OpenapiPayloadValidator.js';
|
|
40
|
+
import { cacheStringify, getCachedStringify } from '../openapi-renderer/helpers/stringify-cache.js';
|
|
39
41
|
import PsychicApp from '../psychic-app/index.js';
|
|
40
42
|
import Params from '../server/params.js';
|
|
41
43
|
import Session from '../session/index.js';
|
|
@@ -60,6 +62,10 @@ export const PsychicParamsPrimitiveLiterals = [
|
|
|
60
62
|
'number[]',
|
|
61
63
|
'string',
|
|
62
64
|
'string[]',
|
|
65
|
+
'time',
|
|
66
|
+
'time[]',
|
|
67
|
+
'timetz',
|
|
68
|
+
'timetz[]',
|
|
63
69
|
'uuid',
|
|
64
70
|
'uuid[]',
|
|
65
71
|
];
|
|
@@ -169,17 +175,20 @@ export default class PsychicController {
|
|
|
169
175
|
get isPsychicControllerInstance() {
|
|
170
176
|
return true;
|
|
171
177
|
}
|
|
172
|
-
|
|
173
|
-
res;
|
|
178
|
+
ctx;
|
|
174
179
|
session;
|
|
175
180
|
action;
|
|
176
181
|
renderOpts;
|
|
177
182
|
startTime;
|
|
178
|
-
|
|
183
|
+
_responseSent = false;
|
|
184
|
+
constructor(ctx, { action, }) {
|
|
179
185
|
this.startTime = Date.now();
|
|
180
|
-
this.
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
this.ctx = ctx;
|
|
187
|
+
// Koa defaults ctx.status to 404, but Express defaulted res.statusCode to 200.
|
|
188
|
+
// Set 200 here so controller response methods (ok, json, etc.) and OpenAPI
|
|
189
|
+
// response-body validation see the correct default status.
|
|
190
|
+
this.ctx.status = 200;
|
|
191
|
+
this.session = new Session(ctx);
|
|
183
192
|
this.action = action;
|
|
184
193
|
// TODO: read casing from Dream app config
|
|
185
194
|
this.renderOpts = {
|
|
@@ -204,7 +213,7 @@ export default class PsychicController {
|
|
|
204
213
|
* ```
|
|
205
214
|
*/
|
|
206
215
|
get headers() {
|
|
207
|
-
return this.
|
|
216
|
+
return this.ctx.request.headers;
|
|
208
217
|
}
|
|
209
218
|
/**
|
|
210
219
|
* Gets the combined parameters from the HTTP request. This includes URL parameters,
|
|
@@ -230,10 +239,14 @@ export default class PsychicController {
|
|
|
230
239
|
get params() {
|
|
231
240
|
this.validateParams();
|
|
232
241
|
const query = this.getCachedQuery();
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
243
|
+
const body = this.ctx.request.body ?? {};
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
245
|
+
const routeParams = this.ctx.params ?? {};
|
|
233
246
|
const params = {
|
|
234
247
|
...query,
|
|
235
|
-
...
|
|
236
|
-
...
|
|
248
|
+
...body,
|
|
249
|
+
...routeParams,
|
|
237
250
|
};
|
|
238
251
|
return params;
|
|
239
252
|
}
|
|
@@ -245,7 +258,7 @@ export default class PsychicController {
|
|
|
245
258
|
if (this._cachedQuery)
|
|
246
259
|
return this._cachedQuery;
|
|
247
260
|
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
248
|
-
const query = this.
|
|
261
|
+
const query = this.ctx.request.query;
|
|
249
262
|
if (openapiEndpointRenderer) {
|
|
250
263
|
// validateOpenapiQuery will modify the query passed into it to conform
|
|
251
264
|
// to the openapi shape with regards to arrays, since these are notoriously
|
|
@@ -581,26 +594,90 @@ export default class PsychicController {
|
|
|
581
594
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
582
595
|
data) {
|
|
583
596
|
this.validateOpenapiResponseBody(data);
|
|
584
|
-
this.
|
|
597
|
+
this.koaSendJson(data);
|
|
585
598
|
}
|
|
586
|
-
|
|
599
|
+
koaSendJson(
|
|
587
600
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
588
|
-
data, statusCode = this.
|
|
589
|
-
|
|
601
|
+
data, statusCode = this.ctx.status || 200) {
|
|
602
|
+
// In Express, calling res.json() after a response was already sent was a no-op.
|
|
603
|
+
// In Koa, ctx.body/ctx.status are plain assignments, so the last write wins.
|
|
604
|
+
// Guard here to preserve Express-like semantics for user code that falls through
|
|
605
|
+
// after sending a response (e.g. this.noContent() without a return).
|
|
606
|
+
if (this._responseSent)
|
|
607
|
+
return;
|
|
608
|
+
this.ctx.status = statusCode;
|
|
609
|
+
// Try to use fast-json-stringify if an OpenAPI schema exists for this endpoint
|
|
610
|
+
const stringifyFn = this.getFastJsonStringifyFunction(statusCode);
|
|
611
|
+
if (stringifyFn) {
|
|
612
|
+
this.ctx.type = 'application/json';
|
|
613
|
+
this.ctx.body = stringifyFn(data);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
// Fall back to toJson() if no OpenAPI schema exists
|
|
617
|
+
this.ctx.type = 'json';
|
|
618
|
+
this.ctx.body = toJson(data);
|
|
619
|
+
}
|
|
620
|
+
this._responseSent = true;
|
|
590
621
|
this.logIfDevelopment();
|
|
591
622
|
}
|
|
592
|
-
|
|
593
|
-
|
|
623
|
+
/**
|
|
624
|
+
* @internal
|
|
625
|
+
*
|
|
626
|
+
* Attempts to retrieve or create a cached fast-json-stringify function
|
|
627
|
+
* for the current endpoint and status code. Returns undefined if no
|
|
628
|
+
* OpenAPI schema exists or if the controller's globalName is not set.
|
|
629
|
+
*
|
|
630
|
+
* @param statusCode - the HTTP status code
|
|
631
|
+
* @returns A stringify function, or undefined if no schema exists
|
|
632
|
+
*/
|
|
633
|
+
getFastJsonStringifyFunction(statusCode) {
|
|
634
|
+
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
635
|
+
if (!openapiEndpointRenderer)
|
|
636
|
+
return undefined;
|
|
637
|
+
const controllerClass = this.constructor;
|
|
638
|
+
// If globalName is not set (e.g., in some unit tests), fall back to toJson()
|
|
639
|
+
if (!controllerClass._globalName)
|
|
640
|
+
return undefined;
|
|
641
|
+
// Try each openapiName until we find a schema
|
|
642
|
+
for (const openapiName of this.computedOpenapiNames) {
|
|
643
|
+
const validator = new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer);
|
|
644
|
+
const schemaWithComponents = validator.getResponseSchemaWithComponents(statusCode);
|
|
645
|
+
if (!schemaWithComponents)
|
|
646
|
+
continue;
|
|
647
|
+
// Generate cache key
|
|
648
|
+
const cacheKey = `${controllerClass.globalName}#${this.action}|${openapiName}|${statusCode}`;
|
|
649
|
+
// Check cache first
|
|
650
|
+
const cachedStringify = getCachedStringify(cacheKey);
|
|
651
|
+
if (cachedStringify)
|
|
652
|
+
return cachedStringify;
|
|
653
|
+
// Compile and cache the stringify function
|
|
654
|
+
// If compilation fails, let the error propagate (dead programs tell no lies)
|
|
655
|
+
const stringifyFn = fastJsonStringify(schemaWithComponents);
|
|
656
|
+
cacheStringify(cacheKey, stringifyFn);
|
|
657
|
+
return stringifyFn;
|
|
658
|
+
}
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
koaSendStatus(statusCode) {
|
|
662
|
+
if (this._responseSent)
|
|
663
|
+
return;
|
|
664
|
+
this.ctx.status = statusCode;
|
|
665
|
+
this.ctx.body = '';
|
|
666
|
+
this._responseSent = true;
|
|
594
667
|
this.logIfDevelopment();
|
|
595
668
|
}
|
|
596
|
-
|
|
597
|
-
this.
|
|
669
|
+
koaRedirect(statusCode, newLocation) {
|
|
670
|
+
if (this._responseSent)
|
|
671
|
+
return;
|
|
672
|
+
this.ctx.status = statusCode;
|
|
673
|
+
this.ctx.redirect(newLocation);
|
|
674
|
+
this._responseSent = true;
|
|
598
675
|
this.logIfDevelopment();
|
|
599
676
|
}
|
|
600
677
|
logIfDevelopment() {
|
|
601
678
|
if (!EnvInternal.isDevelopment)
|
|
602
679
|
return;
|
|
603
|
-
logIfDevelopment({
|
|
680
|
+
logIfDevelopment({ ctx: this.ctx, startTime: this.startTime });
|
|
604
681
|
}
|
|
605
682
|
defaultSerializerPassthrough = {};
|
|
606
683
|
/**
|
|
@@ -658,7 +735,7 @@ export default class PsychicController {
|
|
|
658
735
|
*/
|
|
659
736
|
respond(data = {}, opts = {}) {
|
|
660
737
|
const openapiData = this.constructor.openapi[this.action];
|
|
661
|
-
this.
|
|
738
|
+
this.ctx.status = openapiData?.['status'] || 200;
|
|
662
739
|
this.json(data, opts);
|
|
663
740
|
}
|
|
664
741
|
/**
|
|
@@ -691,7 +768,7 @@ export default class PsychicController {
|
|
|
691
768
|
const realStatus = (typeof HttpStatusCodeMap[status] === 'string'
|
|
692
769
|
? HttpStatusCodeMap[HttpStatusCodeMap[status]]
|
|
693
770
|
: HttpStatusCodeMap[status]);
|
|
694
|
-
this.
|
|
771
|
+
this.ctx.status = realStatus;
|
|
695
772
|
this.json(body);
|
|
696
773
|
}
|
|
697
774
|
/**
|
|
@@ -710,7 +787,7 @@ export default class PsychicController {
|
|
|
710
787
|
* ```
|
|
711
788
|
*/
|
|
712
789
|
redirect(path) {
|
|
713
|
-
this.
|
|
790
|
+
this.ctx.redirect(path);
|
|
714
791
|
}
|
|
715
792
|
// begin: http status codes
|
|
716
793
|
/**
|
|
@@ -752,7 +829,7 @@ export default class PsychicController {
|
|
|
752
829
|
*/
|
|
753
830
|
// 201
|
|
754
831
|
created(data = {}, opts = {}) {
|
|
755
|
-
this.
|
|
832
|
+
this.ctx.status = 201;
|
|
756
833
|
this.json(data, opts);
|
|
757
834
|
}
|
|
758
835
|
/**
|
|
@@ -774,17 +851,17 @@ export default class PsychicController {
|
|
|
774
851
|
*/
|
|
775
852
|
// 202
|
|
776
853
|
accepted(data = {}, opts = {}) {
|
|
777
|
-
this.
|
|
854
|
+
this.ctx.status = 202;
|
|
778
855
|
this.json(data, opts);
|
|
779
856
|
}
|
|
780
857
|
// 203
|
|
781
858
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
782
859
|
nonAuthoritativeInformation(message = undefined) {
|
|
783
860
|
if (message) {
|
|
784
|
-
this.
|
|
861
|
+
this.koaSendJson(message, 203);
|
|
785
862
|
}
|
|
786
863
|
else {
|
|
787
|
-
this.
|
|
864
|
+
this.koaSendStatus(203);
|
|
788
865
|
}
|
|
789
866
|
}
|
|
790
867
|
/**
|
|
@@ -804,39 +881,39 @@ export default class PsychicController {
|
|
|
804
881
|
*/
|
|
805
882
|
// 204
|
|
806
883
|
noContent() {
|
|
807
|
-
this.
|
|
884
|
+
this.koaSendStatus(204);
|
|
808
885
|
}
|
|
809
886
|
// 205
|
|
810
887
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
811
888
|
resetContent(message = undefined) {
|
|
812
|
-
this.
|
|
889
|
+
this.ctx.status = 205;
|
|
813
890
|
this.json(message);
|
|
814
891
|
}
|
|
815
892
|
// 208
|
|
816
893
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
817
894
|
alreadyReported(message = undefined) {
|
|
818
|
-
this.
|
|
895
|
+
this.ctx.status = 208;
|
|
819
896
|
this.json(message);
|
|
820
897
|
}
|
|
821
898
|
// 301
|
|
822
899
|
movedPermanently(newLocation) {
|
|
823
|
-
this.
|
|
900
|
+
this.koaRedirect(301, newLocation);
|
|
824
901
|
}
|
|
825
902
|
// 302
|
|
826
903
|
found(newLocation) {
|
|
827
|
-
this.
|
|
904
|
+
this.koaRedirect(302, newLocation);
|
|
828
905
|
}
|
|
829
906
|
// 303
|
|
830
907
|
seeOther(newLocation) {
|
|
831
|
-
this.
|
|
908
|
+
this.koaRedirect(303, newLocation);
|
|
832
909
|
}
|
|
833
910
|
// 307
|
|
834
911
|
temporaryRedirect(newLocation) {
|
|
835
|
-
this.
|
|
912
|
+
this.koaRedirect(307, newLocation);
|
|
836
913
|
}
|
|
837
914
|
// 308
|
|
838
915
|
permanentRedirect(newLocation) {
|
|
839
|
-
this.
|
|
916
|
+
this.koaRedirect(308, newLocation);
|
|
840
917
|
}
|
|
841
918
|
/**
|
|
842
919
|
* Throws an HTTP 400 Bad Request error. Use this when the client request
|
|
@@ -1073,7 +1150,7 @@ export default class PsychicController {
|
|
|
1073
1150
|
*/
|
|
1074
1151
|
async runAction() {
|
|
1075
1152
|
await this.runBeforeActions();
|
|
1076
|
-
if (this.
|
|
1153
|
+
if (this._responseSent || this.ctx.headerSent)
|
|
1077
1154
|
return;
|
|
1078
1155
|
this.validateParams();
|
|
1079
1156
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
@@ -1100,8 +1177,10 @@ export default class PsychicController {
|
|
|
1100
1177
|
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
1101
1178
|
if (!openapiEndpointRenderer)
|
|
1102
1179
|
return;
|
|
1180
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
1181
|
+
const body = this.ctx.request.body;
|
|
1103
1182
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1104
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiRequestBody(
|
|
1183
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiRequestBody(body);
|
|
1105
1184
|
});
|
|
1106
1185
|
}
|
|
1107
1186
|
/**
|
|
@@ -1117,7 +1196,7 @@ export default class PsychicController {
|
|
|
1117
1196
|
if (!openapiEndpointRenderer)
|
|
1118
1197
|
return;
|
|
1119
1198
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1120
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiHeaders(this.
|
|
1199
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiHeaders(this.ctx.request.headers);
|
|
1121
1200
|
});
|
|
1122
1201
|
}
|
|
1123
1202
|
/**
|
|
@@ -1133,7 +1212,7 @@ export default class PsychicController {
|
|
|
1133
1212
|
if (!openapiEndpointRenderer)
|
|
1134
1213
|
return;
|
|
1135
1214
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1136
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiQuery(this.
|
|
1215
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiQuery(this.ctx.request.query);
|
|
1137
1216
|
});
|
|
1138
1217
|
}
|
|
1139
1218
|
/**
|
|
@@ -1152,7 +1231,7 @@ export default class PsychicController {
|
|
|
1152
1231
|
if (!openapiEndpointRenderer)
|
|
1153
1232
|
return;
|
|
1154
1233
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1155
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiResponseBody(data, this.
|
|
1234
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiResponseBody(data, this.ctx.status);
|
|
1156
1235
|
});
|
|
1157
1236
|
}
|
|
1158
1237
|
/**
|
|
@@ -1171,7 +1250,7 @@ export default class PsychicController {
|
|
|
1171
1250
|
async runBeforeActions() {
|
|
1172
1251
|
const beforeActions = this.constructor.controllerHooks.filter(hook => hook.shouldFireForAction(this.action));
|
|
1173
1252
|
for (const hook of beforeActions) {
|
|
1174
|
-
if (this.
|
|
1253
|
+
if (this._responseSent || this.ctx.headerSent)
|
|
1175
1254
|
return;
|
|
1176
1255
|
if (hook.isStatic) {
|
|
1177
1256
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
@@ -6,7 +6,7 @@ import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
|
|
|
6
6
|
import PsychicApp from '../../psychic-app/index.js';
|
|
7
7
|
const devServerProcesses = {};
|
|
8
8
|
const debugEnabled = debuglog('psychic').enabled;
|
|
9
|
-
export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000 } = {}) {
|
|
9
|
+
export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000, onStdOut } = {}) {
|
|
10
10
|
if (devServerProcesses[key])
|
|
11
11
|
return;
|
|
12
12
|
if (debugEnabled)
|
|
@@ -20,6 +20,20 @@ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', t
|
|
|
20
20
|
...process.env,
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
|
+
// NOTE: adding this stdout spy so that
|
|
24
|
+
// when this cli utility runs node commands,
|
|
25
|
+
// it can properly hijack the stdout from the command
|
|
26
|
+
proc.stdout?.on('data', chunk => {
|
|
27
|
+
const txt = chunk?.toString()?.trim();
|
|
28
|
+
if (typeof txt !== 'string' || !txt)
|
|
29
|
+
return;
|
|
30
|
+
if (onStdOut) {
|
|
31
|
+
onStdOut(txt);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.log(txt);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
23
37
|
devServerProcesses[key] = proc;
|
|
24
38
|
await waitForPort(key, port, timeout);
|
|
25
39
|
proc.on('error', err => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export default class UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute extends Error {
|
|
2
|
+
source;
|
|
3
|
+
attributeName;
|
|
4
|
+
dbType;
|
|
5
|
+
constructor(source, attributeName, dbType) {
|
|
6
|
+
super();
|
|
7
|
+
this.source = source;
|
|
8
|
+
this.attributeName = attributeName;
|
|
9
|
+
this.dbType = dbType;
|
|
10
|
+
}
|
|
11
|
+
get message() {
|
|
12
|
+
return `
|
|
13
|
+
While trying to compute the openapi shape for either a serializer or a controller's
|
|
14
|
+
request body, we ran into a db type that we didn't know how to automatically infer the
|
|
15
|
+
openapi shape for. In these cases, we recommend that you provide an explicit openapi
|
|
16
|
+
shape for these attributes, so that the openapi shape can be properly generated.
|
|
17
|
+
|
|
18
|
+
source: ${this.source}
|
|
19
|
+
attribute: ${this.attributeName}
|
|
20
|
+
unexpected db type: ${this.dbType}
|
|
21
|
+
|
|
22
|
+
If the culprit is a serializer attribute, you should provide an explicit openapi definition
|
|
23
|
+
to the attribute causing your problems, like so:
|
|
24
|
+
|
|
25
|
+
.attribute('${this.attributeName}', { openapi: { type: 'string' }})
|
|
26
|
+
|
|
27
|
+
If, instead, it is a controller's request body causing your problems, identify the controller
|
|
28
|
+
method responsible for this exception, and ensure that the openapi request body shape is explicitly
|
|
29
|
+
defined, so that you do not force psychic to autocompute the openapi body shape for this endpoint.
|
|
30
|
+
|
|
31
|
+
@OpenAPI(MyModel, {
|
|
32
|
+
requestBody: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
${this.attributeName}: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default class CannotCommitRoutesWithoutKoaApp extends Error {
|
|
2
|
+
get message() {
|
|
3
|
+
return `
|
|
4
|
+
When instantiating a PsychicRouter, if no Koa app is provided as the
|
|
5
|
+
first argument, you are not able to commit your routes. Make sure
|
|
6
|
+
to provide an actual Koa app before commiting, like so:
|
|
7
|
+
|
|
8
|
+
const app = new Koa()
|
|
9
|
+
new PsychicRouter(app, ...)
|
|
10
|
+
`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
export default function toJson(data, sanitize) {
|
|
1
|
+
export default function toJson(data) {
|
|
3
2
|
/**
|
|
4
3
|
* 'undefined' is invalid json, and `JSON.stringify(undefined)` returns `undefined`
|
|
5
4
|
* so follow the pattern established by Express and return '{}' for `undefined`
|
|
6
5
|
*/
|
|
7
6
|
if (data === undefined)
|
|
8
7
|
return '{}';
|
|
9
|
-
|
|
10
|
-
return JSON.stringify(data, (_, x) => (typeof x !== 'string' ? x : sanitizeString(x))).replace(/\\\\/g, '\\');
|
|
11
|
-
}
|
|
12
|
-
else {
|
|
13
|
-
return JSON.stringify(data);
|
|
14
|
-
}
|
|
8
|
+
return JSON.stringify(data);
|
|
15
9
|
}
|
|
@@ -126,7 +126,7 @@ const BIGINT_REGEX = /^-?\d+(\.0*)?$/;
|
|
|
126
126
|
/**
|
|
127
127
|
* Formats AJV errors into a more readable format
|
|
128
128
|
*/
|
|
129
|
-
function formatAjvErrors(ajvErrors) {
|
|
129
|
+
export function formatAjvErrors(ajvErrors) {
|
|
130
130
|
return ajvErrors.map(error => ({
|
|
131
131
|
instancePath: error.instancePath,
|
|
132
132
|
schemaPath: error.schemaPath,
|