@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
|
@@ -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,
|
|
@@ -14,7 +14,7 @@ import NoSerializerFoundForRendersOneAndMany from '../error/openapi/NoSerializer
|
|
|
14
14
|
import ObjectSerializerRendersOneAndManyRequireClassType from '../error/openapi/ObjectSerializerRendersOneAndManyRequireClassType.js';
|
|
15
15
|
import allSerializersFromHandWrittenOpenapi from './helpers/allSerializersFromHandWrittenOpenapi.js';
|
|
16
16
|
import allSerializersToRefsInOpenapi from './helpers/allSerializersToRefsInOpenapi.js';
|
|
17
|
-
import { dreamColumnOpenapiShape } from './helpers/
|
|
17
|
+
import { dreamColumnOpenapiShape } from './helpers/dreamColumnOpenapiShape.js';
|
|
18
18
|
import openapiShorthandToOpenapi from './helpers/openapiShorthandToOpenapi.js';
|
|
19
19
|
const NULL_OBJECT_OPENAPI = { type: 'null' };
|
|
20
20
|
export default class SerializerOpenapiRenderer {
|
|
@@ -153,7 +153,7 @@ export default class SerializerOpenapiRenderer {
|
|
|
153
153
|
target = DataTypeForOpenapi;
|
|
154
154
|
}
|
|
155
155
|
accumulator[outputAttributeName] = allSerializersToRefsInOpenapi(target?.isDream
|
|
156
|
-
? dreamColumnOpenapiShape(target, attribute.name, openapi, {
|
|
156
|
+
? dreamColumnOpenapiShape(this.serializer.globalName, target, attribute.name, openapi, {
|
|
157
157
|
suppressResponseEnums: this.suppressResponseEnums,
|
|
158
158
|
})
|
|
159
159
|
: openapiShorthandToOpenapi(openapi));
|
|
@@ -10,7 +10,7 @@ import openapiParamNamesForDreamClass from '../server/helpers/openapiParamNamesF
|
|
|
10
10
|
import OpenapiSegmentExpander from './body-segment.js';
|
|
11
11
|
import { DEFAULT_OPENAPI_RESPONSES } from './defaults.js';
|
|
12
12
|
import cursorPaginationParamOpenapiProperty from './helpers/cursorPaginationParamOpenapiProperty.js';
|
|
13
|
-
import { dreamColumnOpenapiShape } from './helpers/
|
|
13
|
+
import { dreamColumnOpenapiShape } from './helpers/dreamColumnOpenapiShape.js';
|
|
14
14
|
import openapiOpts from './helpers/openapiOpts.js';
|
|
15
15
|
import openapiRoute from './helpers/openapiRoute.js';
|
|
16
16
|
import paginationPageParamOpenapiProperty from './helpers/paginationPageParamOpenapiProperty.js';
|
|
@@ -545,7 +545,7 @@ export default class OpenapiEndpointRenderer {
|
|
|
545
545
|
paramsShape.required = required;
|
|
546
546
|
}
|
|
547
547
|
paramsShape.properties = paramSafeColumns.reduce((acc, columnName) => {
|
|
548
|
-
acc[columnName] = dreamColumnOpenapiShape(dreamClass, columnName, undefined, {
|
|
548
|
+
acc[columnName] = dreamColumnOpenapiShape(this.controllerClass.controllerActionPath(this.action), dreamClass, columnName, undefined, {
|
|
549
549
|
allowGenericJson: true,
|
|
550
550
|
});
|
|
551
551
|
return acc;
|
|
@@ -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
|
*
|