@rvoh/psychic 2.3.9 → 3.0.0-alpha.2
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 +117 -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/endpoint.js +3 -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 +4 -16
- 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 +117 -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/endpoint.js +3 -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 +4 -16
- 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/endpoint.d.ts +7 -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,92 @@ 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.currentOpenapiRenderer?.['disableFastJson']
|
|
611
|
+
? undefined
|
|
612
|
+
: this.getFastJsonStringifyFunction(statusCode);
|
|
613
|
+
if (stringifyFn) {
|
|
614
|
+
this.ctx.type = 'application/json';
|
|
615
|
+
this.ctx.body = stringifyFn(data);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
// Fall back to toJson() if no OpenAPI schema exists
|
|
619
|
+
this.ctx.type = 'json';
|
|
620
|
+
this.ctx.body = toJson(data);
|
|
621
|
+
}
|
|
622
|
+
this._responseSent = true;
|
|
594
623
|
this.logIfDevelopment();
|
|
595
624
|
}
|
|
596
|
-
|
|
597
|
-
|
|
625
|
+
/**
|
|
626
|
+
* @internal
|
|
627
|
+
*
|
|
628
|
+
* Attempts to retrieve or create a cached fast-json-stringify function
|
|
629
|
+
* for the current endpoint and status code. Returns undefined if no
|
|
630
|
+
* OpenAPI schema exists or if the controller's globalName is not set.
|
|
631
|
+
*
|
|
632
|
+
* @param statusCode - the HTTP status code
|
|
633
|
+
* @returns A stringify function, or undefined if no schema exists
|
|
634
|
+
*/
|
|
635
|
+
getFastJsonStringifyFunction(statusCode) {
|
|
636
|
+
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
637
|
+
if (!openapiEndpointRenderer)
|
|
638
|
+
return undefined;
|
|
639
|
+
const controllerClass = this.constructor;
|
|
640
|
+
// If globalName is not set (e.g., in some unit tests), fall back to toJson()
|
|
641
|
+
if (!controllerClass._globalName)
|
|
642
|
+
return undefined;
|
|
643
|
+
// Try each openapiName until we find a schema
|
|
644
|
+
for (const openapiName of this.computedOpenapiNames) {
|
|
645
|
+
const validator = new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer);
|
|
646
|
+
const schemaWithComponents = validator.getResponseSchemaWithComponents(statusCode);
|
|
647
|
+
if (!schemaWithComponents)
|
|
648
|
+
continue;
|
|
649
|
+
// Generate cache key
|
|
650
|
+
const cacheKey = `${controllerClass.globalName}#${this.action}|${openapiName}|${statusCode}`;
|
|
651
|
+
// Check cache first
|
|
652
|
+
const cachedStringify = getCachedStringify(cacheKey);
|
|
653
|
+
if (cachedStringify)
|
|
654
|
+
return cachedStringify;
|
|
655
|
+
// Compile and cache the stringify function
|
|
656
|
+
// If compilation fails, let the error propagate (dead programs tell no lies)
|
|
657
|
+
const stringifyFn = fastJsonStringify(schemaWithComponents);
|
|
658
|
+
cacheStringify(cacheKey, stringifyFn);
|
|
659
|
+
return stringifyFn;
|
|
660
|
+
}
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
koaSendStatus(statusCode) {
|
|
664
|
+
if (this._responseSent)
|
|
665
|
+
return;
|
|
666
|
+
this.ctx.status = statusCode;
|
|
667
|
+
this.ctx.body = '';
|
|
668
|
+
this._responseSent = true;
|
|
598
669
|
this.logIfDevelopment();
|
|
599
670
|
}
|
|
600
|
-
|
|
601
|
-
this.
|
|
671
|
+
koaRedirect(statusCode, newLocation) {
|
|
672
|
+
if (this._responseSent)
|
|
673
|
+
return;
|
|
674
|
+
this.ctx.status = statusCode;
|
|
675
|
+
this.ctx.redirect(newLocation);
|
|
676
|
+
this._responseSent = true;
|
|
602
677
|
this.logIfDevelopment();
|
|
603
678
|
}
|
|
604
679
|
logIfDevelopment() {
|
|
605
680
|
if (!EnvInternal.isDevelopment)
|
|
606
681
|
return;
|
|
607
|
-
logIfDevelopment({
|
|
682
|
+
logIfDevelopment({ ctx: this.ctx, startTime: this.startTime });
|
|
608
683
|
}
|
|
609
684
|
defaultSerializerPassthrough = {};
|
|
610
685
|
/**
|
|
@@ -662,7 +737,7 @@ export default class PsychicController {
|
|
|
662
737
|
*/
|
|
663
738
|
respond(data = {}, opts = {}) {
|
|
664
739
|
const openapiData = this.constructor.openapi[this.action];
|
|
665
|
-
this.
|
|
740
|
+
this.ctx.status = openapiData?.['status'] || 200;
|
|
666
741
|
this.json(data, opts);
|
|
667
742
|
}
|
|
668
743
|
/**
|
|
@@ -695,7 +770,7 @@ export default class PsychicController {
|
|
|
695
770
|
const realStatus = (typeof HttpStatusCodeMap[status] === 'string'
|
|
696
771
|
? HttpStatusCodeMap[HttpStatusCodeMap[status]]
|
|
697
772
|
: HttpStatusCodeMap[status]);
|
|
698
|
-
this.
|
|
773
|
+
this.ctx.status = realStatus;
|
|
699
774
|
this.json(body);
|
|
700
775
|
}
|
|
701
776
|
/**
|
|
@@ -714,7 +789,7 @@ export default class PsychicController {
|
|
|
714
789
|
* ```
|
|
715
790
|
*/
|
|
716
791
|
redirect(path) {
|
|
717
|
-
this.
|
|
792
|
+
this.ctx.redirect(path);
|
|
718
793
|
}
|
|
719
794
|
// begin: http status codes
|
|
720
795
|
/**
|
|
@@ -756,7 +831,7 @@ export default class PsychicController {
|
|
|
756
831
|
*/
|
|
757
832
|
// 201
|
|
758
833
|
created(data = {}, opts = {}) {
|
|
759
|
-
this.
|
|
834
|
+
this.ctx.status = 201;
|
|
760
835
|
this.json(data, opts);
|
|
761
836
|
}
|
|
762
837
|
/**
|
|
@@ -778,17 +853,17 @@ export default class PsychicController {
|
|
|
778
853
|
*/
|
|
779
854
|
// 202
|
|
780
855
|
accepted(data = {}, opts = {}) {
|
|
781
|
-
this.
|
|
856
|
+
this.ctx.status = 202;
|
|
782
857
|
this.json(data, opts);
|
|
783
858
|
}
|
|
784
859
|
// 203
|
|
785
860
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
786
861
|
nonAuthoritativeInformation(message = undefined) {
|
|
787
862
|
if (message) {
|
|
788
|
-
this.
|
|
863
|
+
this.koaSendJson(message, 203);
|
|
789
864
|
}
|
|
790
865
|
else {
|
|
791
|
-
this.
|
|
866
|
+
this.koaSendStatus(203);
|
|
792
867
|
}
|
|
793
868
|
}
|
|
794
869
|
/**
|
|
@@ -808,39 +883,39 @@ export default class PsychicController {
|
|
|
808
883
|
*/
|
|
809
884
|
// 204
|
|
810
885
|
noContent() {
|
|
811
|
-
this.
|
|
886
|
+
this.koaSendStatus(204);
|
|
812
887
|
}
|
|
813
888
|
// 205
|
|
814
889
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
815
890
|
resetContent(message = undefined) {
|
|
816
|
-
this.
|
|
891
|
+
this.ctx.status = 205;
|
|
817
892
|
this.json(message);
|
|
818
893
|
}
|
|
819
894
|
// 208
|
|
820
895
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
821
896
|
alreadyReported(message = undefined) {
|
|
822
|
-
this.
|
|
897
|
+
this.ctx.status = 208;
|
|
823
898
|
this.json(message);
|
|
824
899
|
}
|
|
825
900
|
// 301
|
|
826
901
|
movedPermanently(newLocation) {
|
|
827
|
-
this.
|
|
902
|
+
this.koaRedirect(301, newLocation);
|
|
828
903
|
}
|
|
829
904
|
// 302
|
|
830
905
|
found(newLocation) {
|
|
831
|
-
this.
|
|
906
|
+
this.koaRedirect(302, newLocation);
|
|
832
907
|
}
|
|
833
908
|
// 303
|
|
834
909
|
seeOther(newLocation) {
|
|
835
|
-
this.
|
|
910
|
+
this.koaRedirect(303, newLocation);
|
|
836
911
|
}
|
|
837
912
|
// 307
|
|
838
913
|
temporaryRedirect(newLocation) {
|
|
839
|
-
this.
|
|
914
|
+
this.koaRedirect(307, newLocation);
|
|
840
915
|
}
|
|
841
916
|
// 308
|
|
842
917
|
permanentRedirect(newLocation) {
|
|
843
|
-
this.
|
|
918
|
+
this.koaRedirect(308, newLocation);
|
|
844
919
|
}
|
|
845
920
|
/**
|
|
846
921
|
* Throws an HTTP 400 Bad Request error. Use this when the client request
|
|
@@ -1077,7 +1152,7 @@ export default class PsychicController {
|
|
|
1077
1152
|
*/
|
|
1078
1153
|
async runAction() {
|
|
1079
1154
|
await this.runBeforeActions();
|
|
1080
|
-
if (this.
|
|
1155
|
+
if (this._responseSent || this.ctx.headerSent)
|
|
1081
1156
|
return;
|
|
1082
1157
|
this.validateParams();
|
|
1083
1158
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
@@ -1104,8 +1179,10 @@ export default class PsychicController {
|
|
|
1104
1179
|
const openapiEndpointRenderer = this.currentOpenapiRenderer;
|
|
1105
1180
|
if (!openapiEndpointRenderer)
|
|
1106
1181
|
return;
|
|
1182
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
1183
|
+
const body = this.ctx.request.body;
|
|
1107
1184
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1108
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiRequestBody(
|
|
1185
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiRequestBody(body);
|
|
1109
1186
|
});
|
|
1110
1187
|
}
|
|
1111
1188
|
/**
|
|
@@ -1121,7 +1198,7 @@ export default class PsychicController {
|
|
|
1121
1198
|
if (!openapiEndpointRenderer)
|
|
1122
1199
|
return;
|
|
1123
1200
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1124
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiHeaders(this.
|
|
1201
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiHeaders(this.ctx.request.headers);
|
|
1125
1202
|
});
|
|
1126
1203
|
}
|
|
1127
1204
|
/**
|
|
@@ -1137,7 +1214,7 @@ export default class PsychicController {
|
|
|
1137
1214
|
if (!openapiEndpointRenderer)
|
|
1138
1215
|
return;
|
|
1139
1216
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1140
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiQuery(this.
|
|
1217
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiQuery(this.ctx.request.query);
|
|
1141
1218
|
});
|
|
1142
1219
|
}
|
|
1143
1220
|
/**
|
|
@@ -1156,7 +1233,7 @@ export default class PsychicController {
|
|
|
1156
1233
|
if (!openapiEndpointRenderer)
|
|
1157
1234
|
return;
|
|
1158
1235
|
this.computedOpenapiNames.forEach(openapiName => {
|
|
1159
|
-
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiResponseBody(data, this.
|
|
1236
|
+
new OpenapiPayloadValidator(openapiName, openapiEndpointRenderer).validateOpenapiResponseBody(data, this.ctx.status);
|
|
1160
1237
|
});
|
|
1161
1238
|
}
|
|
1162
1239
|
/**
|
|
@@ -1175,7 +1252,7 @@ export default class PsychicController {
|
|
|
1175
1252
|
async runBeforeActions() {
|
|
1176
1253
|
const beforeActions = this.constructor.controllerHooks.filter(hook => hook.shouldFireForAction(this.action));
|
|
1177
1254
|
for (const hook of beforeActions) {
|
|
1178
|
-
if (this.
|
|
1255
|
+
if (this._responseSent || this.ctx.headerSent)
|
|
1179
1256
|
return;
|
|
1180
1257
|
if (hook.isStatic) {
|
|
1181
1258
|
// 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,
|
|
@@ -40,6 +40,7 @@ export default class OpenapiEndpointRenderer {
|
|
|
40
40
|
omitDefaultResponses;
|
|
41
41
|
defaultResponse;
|
|
42
42
|
validate = undefined;
|
|
43
|
+
disableFastJson = false;
|
|
43
44
|
/**
|
|
44
45
|
* instantiates a new OpenapiEndpointRenderer.
|
|
45
46
|
* This class is used by the `@Openapi` decorator
|
|
@@ -54,7 +55,7 @@ export default class OpenapiEndpointRenderer {
|
|
|
54
55
|
* const json = JSON.encode(openapiJsonContents, null, 2)
|
|
55
56
|
* ```
|
|
56
57
|
*/
|
|
57
|
-
constructor(dreamsOrSerializers, controllerClass, action, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, } = {}) {
|
|
58
|
+
constructor(dreamsOrSerializers, controllerClass, action, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, disableFastJson, } = {}) {
|
|
58
59
|
this.dreamsOrSerializers = dreamsOrSerializers;
|
|
59
60
|
this.controllerClass = controllerClass;
|
|
60
61
|
this.action = action;
|
|
@@ -83,6 +84,7 @@ export default class OpenapiEndpointRenderer {
|
|
|
83
84
|
: omitDefaultResponses;
|
|
84
85
|
this.defaultResponse = defaultResponse;
|
|
85
86
|
this.validate = validate;
|
|
87
|
+
this.disableFastJson = disableFastJson ?? false;
|
|
86
88
|
}
|
|
87
89
|
/**
|
|
88
90
|
* @internal
|
|
@@ -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;
|