@rvoh/psychic 2.3.9 → 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/controller/helpers/logIfDevelopment.js +5 -5
- package/dist/cjs/src/controller/index.js +115 -40
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +0 -1
- 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/helpers/OpenapiPayloadValidator.js +75 -9
- 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/session/index.js +9 -12
- package/dist/esm/src/controller/helpers/logIfDevelopment.js +5 -5
- package/dist/esm/src/controller/index.js +115 -40
- package/dist/esm/src/devtools/helpers/launchDevServer.js +0 -1
- 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/helpers/OpenapiPayloadValidator.js +75 -9
- 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/session/index.js +9 -12
- package/dist/types/src/controller/helpers/logIfDevelopment.d.ts +3 -4
- package/dist/types/src/controller/index.d.ts +18 -7
- 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/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/session/index.d.ts +13 -5
- package/package.json +29 -18
- 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
|
@@ -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';
|
|
@@ -173,17 +175,20 @@ export default class PsychicController {
|
|
|
173
175
|
get isPsychicControllerInstance() {
|
|
174
176
|
return true;
|
|
175
177
|
}
|
|
176
|
-
|
|
177
|
-
res;
|
|
178
|
+
ctx;
|
|
178
179
|
session;
|
|
179
180
|
action;
|
|
180
181
|
renderOpts;
|
|
181
182
|
startTime;
|
|
182
|
-
|
|
183
|
+
_responseSent = false;
|
|
184
|
+
constructor(ctx, { action, }) {
|
|
183
185
|
this.startTime = Date.now();
|
|
184
|
-
this.
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
192
|
this.action = action;
|
|
188
193
|
// TODO: read casing from Dream app config
|
|
189
194
|
this.renderOpts = {
|
|
@@ -208,7 +213,7 @@ export default class PsychicController {
|
|
|
208
213
|
* ```
|
|
209
214
|
*/
|
|
210
215
|
get headers() {
|
|
211
|
-
return this.
|
|
216
|
+
return this.ctx.request.headers;
|
|
212
217
|
}
|
|
213
218
|
/**
|
|
214
219
|
* Gets the combined parameters from the HTTP request. This includes URL parameters,
|
|
@@ -234,10 +239,14 @@ export default class PsychicController {
|
|
|
234
239
|
get params() {
|
|
235
240
|
this.validateParams();
|
|
236
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 ?? {};
|
|
237
246
|
const params = {
|
|
238
247
|
...query,
|
|
239
|
-
...
|
|
240
|
-
...
|
|
248
|
+
...body,
|
|
249
|
+
...routeParams,
|
|
241
250
|
};
|
|
242
251
|
return params;
|
|
243
252
|
}
|
|
@@ -249,7 +258,7 @@ export default class PsychicController {
|
|
|
249
258
|
if (this._cachedQuery)
|
|
250
259
|
return this._cachedQuery;
|
|
251
260
|
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
252
|
-
const query = this.
|
|
261
|
+
const query = this.ctx.request.query;
|
|
253
262
|
if (openapiEndpointRenderer) {
|
|
254
263
|
// validateOpenapiQuery will modify the query passed into it to conform
|
|
255
264
|
// to the openapi shape with regards to arrays, since these are notoriously
|
|
@@ -585,26 +594,90 @@ export default class PsychicController {
|
|
|
585
594
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
586
595
|
data) {
|
|
587
596
|
this.validateOpenapiResponseBody(data);
|
|
588
|
-
this.
|
|
597
|
+
this.koaSendJson(data);
|
|
589
598
|
}
|
|
590
|
-
|
|
599
|
+
koaSendJson(
|
|
591
600
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
592
|
-
data, statusCode = this.
|
|
593
|
-
|
|
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;
|
|
594
621
|
this.logIfDevelopment();
|
|
595
622
|
}
|
|
596
|
-
|
|
597
|
-
|
|
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;
|
|
598
667
|
this.logIfDevelopment();
|
|
599
668
|
}
|
|
600
|
-
|
|
601
|
-
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;
|
|
602
675
|
this.logIfDevelopment();
|
|
603
676
|
}
|
|
604
677
|
logIfDevelopment() {
|
|
605
678
|
if (!EnvInternal.isDevelopment)
|
|
606
679
|
return;
|
|
607
|
-
logIfDevelopment({
|
|
680
|
+
logIfDevelopment({ ctx: this.ctx, startTime: this.startTime });
|
|
608
681
|
}
|
|
609
682
|
defaultSerializerPassthrough = {};
|
|
610
683
|
/**
|
|
@@ -662,7 +735,7 @@ export default class PsychicController {
|
|
|
662
735
|
*/
|
|
663
736
|
respond(data = {}, opts = {}) {
|
|
664
737
|
const openapiData = this.constructor.openapi[this.action];
|
|
665
|
-
this.
|
|
738
|
+
this.ctx.status = openapiData?.['status'] || 200;
|
|
666
739
|
this.json(data, opts);
|
|
667
740
|
}
|
|
668
741
|
/**
|
|
@@ -695,7 +768,7 @@ export default class PsychicController {
|
|
|
695
768
|
const realStatus = (typeof HttpStatusCodeMap[status] === 'string'
|
|
696
769
|
? HttpStatusCodeMap[HttpStatusCodeMap[status]]
|
|
697
770
|
: HttpStatusCodeMap[status]);
|
|
698
|
-
this.
|
|
771
|
+
this.ctx.status = realStatus;
|
|
699
772
|
this.json(body);
|
|
700
773
|
}
|
|
701
774
|
/**
|
|
@@ -714,7 +787,7 @@ export default class PsychicController {
|
|
|
714
787
|
* ```
|
|
715
788
|
*/
|
|
716
789
|
redirect(path) {
|
|
717
|
-
this.
|
|
790
|
+
this.ctx.redirect(path);
|
|
718
791
|
}
|
|
719
792
|
// begin: http status codes
|
|
720
793
|
/**
|
|
@@ -756,7 +829,7 @@ export default class PsychicController {
|
|
|
756
829
|
*/
|
|
757
830
|
// 201
|
|
758
831
|
created(data = {}, opts = {}) {
|
|
759
|
-
this.
|
|
832
|
+
this.ctx.status = 201;
|
|
760
833
|
this.json(data, opts);
|
|
761
834
|
}
|
|
762
835
|
/**
|
|
@@ -778,17 +851,17 @@ export default class PsychicController {
|
|
|
778
851
|
*/
|
|
779
852
|
// 202
|
|
780
853
|
accepted(data = {}, opts = {}) {
|
|
781
|
-
this.
|
|
854
|
+
this.ctx.status = 202;
|
|
782
855
|
this.json(data, opts);
|
|
783
856
|
}
|
|
784
857
|
// 203
|
|
785
858
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
786
859
|
nonAuthoritativeInformation(message = undefined) {
|
|
787
860
|
if (message) {
|
|
788
|
-
this.
|
|
861
|
+
this.koaSendJson(message, 203);
|
|
789
862
|
}
|
|
790
863
|
else {
|
|
791
|
-
this.
|
|
864
|
+
this.koaSendStatus(203);
|
|
792
865
|
}
|
|
793
866
|
}
|
|
794
867
|
/**
|
|
@@ -808,39 +881,39 @@ export default class PsychicController {
|
|
|
808
881
|
*/
|
|
809
882
|
// 204
|
|
810
883
|
noContent() {
|
|
811
|
-
this.
|
|
884
|
+
this.koaSendStatus(204);
|
|
812
885
|
}
|
|
813
886
|
// 205
|
|
814
887
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
815
888
|
resetContent(message = undefined) {
|
|
816
|
-
this.
|
|
889
|
+
this.ctx.status = 205;
|
|
817
890
|
this.json(message);
|
|
818
891
|
}
|
|
819
892
|
// 208
|
|
820
893
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
821
894
|
alreadyReported(message = undefined) {
|
|
822
|
-
this.
|
|
895
|
+
this.ctx.status = 208;
|
|
823
896
|
this.json(message);
|
|
824
897
|
}
|
|
825
898
|
// 301
|
|
826
899
|
movedPermanently(newLocation) {
|
|
827
|
-
this.
|
|
900
|
+
this.koaRedirect(301, newLocation);
|
|
828
901
|
}
|
|
829
902
|
// 302
|
|
830
903
|
found(newLocation) {
|
|
831
|
-
this.
|
|
904
|
+
this.koaRedirect(302, newLocation);
|
|
832
905
|
}
|
|
833
906
|
// 303
|
|
834
907
|
seeOther(newLocation) {
|
|
835
|
-
this.
|
|
908
|
+
this.koaRedirect(303, newLocation);
|
|
836
909
|
}
|
|
837
910
|
// 307
|
|
838
911
|
temporaryRedirect(newLocation) {
|
|
839
|
-
this.
|
|
912
|
+
this.koaRedirect(307, newLocation);
|
|
840
913
|
}
|
|
841
914
|
// 308
|
|
842
915
|
permanentRedirect(newLocation) {
|
|
843
|
-
this.
|
|
916
|
+
this.koaRedirect(308, newLocation);
|
|
844
917
|
}
|
|
845
918
|
/**
|
|
846
919
|
* Throws an HTTP 400 Bad Request error. Use this when the client request
|
|
@@ -1077,7 +1150,7 @@ export default class PsychicController {
|
|
|
1077
1150
|
*/
|
|
1078
1151
|
async runAction() {
|
|
1079
1152
|
await this.runBeforeActions();
|
|
1080
|
-
if (this.
|
|
1153
|
+
if (this._responseSent || this.ctx.headerSent)
|
|
1081
1154
|
return;
|
|
1082
1155
|
this.validateParams();
|
|
1083
1156
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
@@ -1104,8 +1177,10 @@ export default class PsychicController {
|
|
|
1104
1177
|
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
1105
1178
|
if (!openapiEndpointRenderer)
|
|
1106
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;
|
|
1107
1182
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1108
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiRequestBody(
|
|
1183
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiRequestBody(body);
|
|
1109
1184
|
});
|
|
1110
1185
|
}
|
|
1111
1186
|
/**
|
|
@@ -1121,7 +1196,7 @@ export default class PsychicController {
|
|
|
1121
1196
|
if (!openapiEndpointRenderer)
|
|
1122
1197
|
return;
|
|
1123
1198
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1124
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiHeaders(this.
|
|
1199
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiHeaders(this.ctx.request.headers);
|
|
1125
1200
|
});
|
|
1126
1201
|
}
|
|
1127
1202
|
/**
|
|
@@ -1137,7 +1212,7 @@ export default class PsychicController {
|
|
|
1137
1212
|
if (!openapiEndpointRenderer)
|
|
1138
1213
|
return;
|
|
1139
1214
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1140
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiQuery(this.
|
|
1215
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiQuery(this.ctx.request.query);
|
|
1141
1216
|
});
|
|
1142
1217
|
}
|
|
1143
1218
|
/**
|
|
@@ -1156,7 +1231,7 @@ export default class PsychicController {
|
|
|
1156
1231
|
if (!openapiEndpointRenderer)
|
|
1157
1232
|
return;
|
|
1158
1233
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1159
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiResponseBody(data, this.
|
|
1234
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiResponseBody(data, this.ctx.status);
|
|
1160
1235
|
});
|
|
1161
1236
|
}
|
|
1162
1237
|
/**
|
|
@@ -1175,7 +1250,7 @@ export default class PsychicController {
|
|
|
1175
1250
|
async runBeforeActions() {
|
|
1176
1251
|
const beforeActions = this.constructor.controllerHooks.filter(hook => hook.shouldFireForAction(this.action));
|
|
1177
1252
|
for (const hook of beforeActions) {
|
|
1178
|
-
if (this.
|
|
1253
|
+
if (this._responseSent || this.ctx.headerSent)
|
|
1179
1254
|
return;
|
|
1180
1255
|
if (hook.isStatic) {
|
|
1181
1256
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
@@ -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,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import OpenapiRequestValidationFailure from '../../error/openapi/OpenapiRequestValidationFailure.js';
|
|
2
2
|
import OpenapiResponseValidationFailure from '../../error/openapi/OpenapResponseValidationFailure.js';
|
|
3
|
-
import
|
|
3
|
+
import { createValidator, formatAjvErrors, } from '../../helpers/validateOpenApiSchema.js';
|
|
4
4
|
import PsychicApp from '../../psychic-app/index.js';
|
|
5
5
|
import suppressResponseEnumsConfig from './suppressResponseEnumsConfig.js';
|
|
6
|
+
import { cacheValidator, getCachedValidator } from './validator-cache.js';
|
|
6
7
|
/**
|
|
7
8
|
* @internal
|
|
8
9
|
*
|
|
@@ -157,16 +158,46 @@ export default class OpenapiPayloadValidator {
|
|
|
157
158
|
const openapiEndpointRenderer = this.openapiEndpointRenderer;
|
|
158
159
|
const openapiName = this.openapiName;
|
|
159
160
|
if (openapiEndpointRenderer.shouldValidateResponseBody(openapiName)) {
|
|
160
|
-
const
|
|
161
|
-
openapiName,
|
|
162
|
-
renderOpts: this.renderOpts,
|
|
163
|
-
}).openapi?.[statusCode.toString()];
|
|
164
|
-
const schema = openapiResponseBody?.['content']?.['application/json']?.['schema'];
|
|
161
|
+
const schema = this.getResponseSchema(statusCode);
|
|
165
162
|
if (schema) {
|
|
166
163
|
this.validateOrFail(data, schema, 'responseBody');
|
|
167
164
|
}
|
|
168
165
|
}
|
|
169
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* @internal
|
|
169
|
+
*
|
|
170
|
+
* Retrieves the OpenAPI response schema for a given status code.
|
|
171
|
+
* Returns undefined if no schema is defined for this endpoint/status code.
|
|
172
|
+
*
|
|
173
|
+
* @param statusCode - the HTTP status code
|
|
174
|
+
* @returns The response schema, or undefined if not found
|
|
175
|
+
*/
|
|
176
|
+
getResponseSchema(statusCode) {
|
|
177
|
+
const openapiEndpointRenderer = this.openapiEndpointRenderer;
|
|
178
|
+
const openapiName = this.openapiName;
|
|
179
|
+
const openapiResponseBody = openapiEndpointRenderer['parseResponses']({
|
|
180
|
+
openapiName,
|
|
181
|
+
renderOpts: this.renderOpts,
|
|
182
|
+
}).openapi?.[statusCode.toString()];
|
|
183
|
+
return openapiResponseBody?.['content']?.['application/json']?.['schema'];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* @internal
|
|
187
|
+
*
|
|
188
|
+
* Retrieves the OpenAPI response schema for a given status code with
|
|
189
|
+
* all components merged in. This is the schema format needed by
|
|
190
|
+
* fast-json-stringify and AJV validators.
|
|
191
|
+
*
|
|
192
|
+
* @param statusCode - the HTTP status code
|
|
193
|
+
* @returns The response schema with components, or undefined if not found
|
|
194
|
+
*/
|
|
195
|
+
getResponseSchemaWithComponents(statusCode) {
|
|
196
|
+
const schema = this.getResponseSchema(statusCode);
|
|
197
|
+
if (!schema)
|
|
198
|
+
return undefined;
|
|
199
|
+
return this.addComponentsToSchema(schema);
|
|
200
|
+
}
|
|
170
201
|
/**
|
|
171
202
|
* @internal
|
|
172
203
|
*
|
|
@@ -241,12 +272,47 @@ export default class OpenapiPayloadValidator {
|
|
|
241
272
|
validateOrFail(
|
|
242
273
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
243
274
|
data, openapiSchema, target) {
|
|
244
|
-
const
|
|
245
|
-
|
|
275
|
+
const cacheKey = this.getCacheKey(target);
|
|
276
|
+
const schemaWithComponents = this.addComponentsToSchema(openapiSchema);
|
|
277
|
+
const ajvOptions = this.getAjvOptions(target);
|
|
278
|
+
const validator = getCachedValidator(cacheKey) ||
|
|
279
|
+
this.compileAndCacheValidator(cacheKey, schemaWithComponents, ajvOptions);
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
281
|
+
const clonedData = structuredClone(data || {});
|
|
282
|
+
const isValid = validator(clonedData);
|
|
283
|
+
if (!isValid) {
|
|
246
284
|
const errorClass = target === 'responseBody' ? OpenapiResponseValidationFailure : OpenapiRequestValidationFailure;
|
|
247
|
-
throw new errorClass(
|
|
285
|
+
throw new errorClass(formatAjvErrors(validator.errors || []), target);
|
|
248
286
|
}
|
|
249
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* @internal
|
|
290
|
+
*
|
|
291
|
+
* Generates a cache key for the validator based on controller, action, openapiName, and target.
|
|
292
|
+
*
|
|
293
|
+
* @param target - the validation target (one of: 'requestBody', 'query', 'headers', 'responseBody')
|
|
294
|
+
* @returns cache key string
|
|
295
|
+
*/
|
|
296
|
+
getCacheKey(target) {
|
|
297
|
+
const controllerClass = this.openapiEndpointRenderer['controllerClass'];
|
|
298
|
+
const actionName = this.openapiEndpointRenderer['action'];
|
|
299
|
+
return `${controllerClass.globalName}#${actionName}|${this.openapiName}|${target}`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* @internal
|
|
303
|
+
*
|
|
304
|
+
* Compiles a validator using AJV and caches it for future use.
|
|
305
|
+
*
|
|
306
|
+
* @param cacheKey - the cache key for this validator
|
|
307
|
+
* @param schema - the schema to compile
|
|
308
|
+
* @param options - AJV options
|
|
309
|
+
* @returns compiled validator function
|
|
310
|
+
*/
|
|
311
|
+
compileAndCacheValidator(cacheKey, schema, options) {
|
|
312
|
+
const validator = createValidator(schema, options);
|
|
313
|
+
cacheValidator(cacheKey, validator);
|
|
314
|
+
return validator;
|
|
315
|
+
}
|
|
250
316
|
/**
|
|
251
317
|
* @internal
|
|
252
318
|
*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal
|
|
3
|
+
*
|
|
4
|
+
* Cache for compiled fast-json-stringify functions.
|
|
5
|
+
* Eliminates redundant schema compilation on every request by storing
|
|
6
|
+
* stringify functions keyed by controller, action, openapiName, and status code.
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
const _stringifyCache = {};
|
|
10
|
+
/**
|
|
11
|
+
* @internal
|
|
12
|
+
*
|
|
13
|
+
* Retrieves a cached stringify function if it exists.
|
|
14
|
+
*
|
|
15
|
+
* @param cacheKey - The cache key identifying the stringify function
|
|
16
|
+
* @returns The cached stringify function, or undefined if not found
|
|
17
|
+
*/
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
export function getCachedStringify(cacheKey) {
|
|
20
|
+
return _stringifyCache[cacheKey];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* @internal
|
|
24
|
+
*
|
|
25
|
+
* Stores a compiled stringify function in the cache.
|
|
26
|
+
*
|
|
27
|
+
* @param cacheKey - The cache key identifying the stringify function
|
|
28
|
+
* @param stringifyFn - The compiled fast-json-stringify function to cache
|
|
29
|
+
*/
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
export function cacheStringify(cacheKey, stringifyFn) {
|
|
32
|
+
_stringifyCache[cacheKey] = stringifyFn;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* @internal
|
|
36
|
+
*
|
|
37
|
+
* Clears a specific stringify function from the cache.
|
|
38
|
+
* Used in test environments to ensure test isolation.
|
|
39
|
+
*
|
|
40
|
+
* @param cacheKey - The cache key identifying the stringify function to clear
|
|
41
|
+
*/
|
|
42
|
+
export function _testOnlyClearStringify(cacheKey) {
|
|
43
|
+
delete _stringifyCache[cacheKey];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @internal
|
|
47
|
+
*
|
|
48
|
+
* Clears all stringify functions from the cache.
|
|
49
|
+
* Used in test environments to ensure test isolation.
|
|
50
|
+
*/
|
|
51
|
+
export function _testOnlyClearStringifyCache() {
|
|
52
|
+
Object.keys(_stringifyCache).forEach(key => {
|
|
53
|
+
delete _stringifyCache[key];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal
|
|
3
|
+
*
|
|
4
|
+
* Cache for compiled AJV validator functions.
|
|
5
|
+
* Eliminates redundant schema compilation on every request by storing
|
|
6
|
+
* validators keyed by controller, action, openapiName, and validation target.
|
|
7
|
+
*/
|
|
8
|
+
const _validatorCache = {};
|
|
9
|
+
/**
|
|
10
|
+
* @internal
|
|
11
|
+
*
|
|
12
|
+
* Retrieves a cached validator function if it exists.
|
|
13
|
+
*
|
|
14
|
+
* @param cacheKey - The cache key identifying the validator
|
|
15
|
+
* @returns The cached validator function, or undefined if not found
|
|
16
|
+
*/
|
|
17
|
+
export function getCachedValidator(cacheKey) {
|
|
18
|
+
return _validatorCache[cacheKey];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @internal
|
|
22
|
+
*
|
|
23
|
+
* Stores a compiled validator function in the cache.
|
|
24
|
+
*
|
|
25
|
+
* @param cacheKey - The cache key identifying the validator
|
|
26
|
+
* @param validator - The compiled AJV validator function to cache
|
|
27
|
+
*/
|
|
28
|
+
export function cacheValidator(cacheKey, validator) {
|
|
29
|
+
_validatorCache[cacheKey] = validator;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* @internal
|
|
33
|
+
*
|
|
34
|
+
* Clears a specific validator from the cache.
|
|
35
|
+
* Used in test environments to ensure test isolation.
|
|
36
|
+
*
|
|
37
|
+
* @param cacheKey - The cache key identifying the validator to clear
|
|
38
|
+
*/
|
|
39
|
+
export function _testOnlyClearValidator(cacheKey) {
|
|
40
|
+
delete _validatorCache[cacheKey];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* @internal
|
|
44
|
+
*
|
|
45
|
+
* Clears all validators from the cache.
|
|
46
|
+
* Used in test environments to ensure test isolation.
|
|
47
|
+
*/
|
|
48
|
+
export function _testOnlyClearValidatorCache() {
|
|
49
|
+
Object.keys(_validatorCache).forEach(key => {
|
|
50
|
+
delete _validatorCache[key];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -32,7 +32,7 @@ importCb) {
|
|
|
32
32
|
* at decoration time such that the class of a property being decorated is only avilable during instance instantiation. In order
|
|
33
33
|
* to only apply static values once, on boot, `globallyInitializingDecorators` is set to true on Dream, and all Dream models are instantiated.
|
|
34
34
|
*/
|
|
35
|
-
new controllerClass({}, {
|
|
35
|
+
new controllerClass({}, { action: 'a' });
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
PsychicController['globallyInitializingDecorators'] = false;
|