@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.
Files changed (49) hide show
  1. package/dist/cjs/src/controller/helpers/logIfDevelopment.js +5 -5
  2. package/dist/cjs/src/controller/index.js +115 -40
  3. package/dist/cjs/src/devtools/helpers/launchDevServer.js +0 -1
  4. package/dist/cjs/src/error/router/cannot-commit-routes-without-koa-app.js +12 -0
  5. package/dist/cjs/src/helpers/toJson.js +2 -8
  6. package/dist/cjs/src/helpers/validateOpenApiSchema.js +1 -1
  7. package/dist/cjs/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +75 -9
  8. package/dist/cjs/src/openapi-renderer/helpers/stringify-cache.js +55 -0
  9. package/dist/cjs/src/openapi-renderer/helpers/validator-cache.js +52 -0
  10. package/dist/cjs/src/psychic-app/helpers/import/importControllers.js +1 -1
  11. package/dist/cjs/src/psychic-app/index.js +3 -10
  12. package/dist/cjs/src/router/index.js +31 -25
  13. package/dist/cjs/src/server/helpers/startPsychicServer.js +6 -2
  14. package/dist/cjs/src/server/index.js +32 -35
  15. package/dist/cjs/src/session/index.js +9 -12
  16. package/dist/esm/src/controller/helpers/logIfDevelopment.js +5 -5
  17. package/dist/esm/src/controller/index.js +115 -40
  18. package/dist/esm/src/devtools/helpers/launchDevServer.js +0 -1
  19. package/dist/esm/src/error/router/cannot-commit-routes-without-koa-app.js +12 -0
  20. package/dist/esm/src/helpers/toJson.js +2 -8
  21. package/dist/esm/src/helpers/validateOpenApiSchema.js +1 -1
  22. package/dist/esm/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +75 -9
  23. package/dist/esm/src/openapi-renderer/helpers/stringify-cache.js +55 -0
  24. package/dist/esm/src/openapi-renderer/helpers/validator-cache.js +52 -0
  25. package/dist/esm/src/psychic-app/helpers/import/importControllers.js +1 -1
  26. package/dist/esm/src/psychic-app/index.js +3 -10
  27. package/dist/esm/src/router/index.js +31 -25
  28. package/dist/esm/src/server/helpers/startPsychicServer.js +6 -2
  29. package/dist/esm/src/server/index.js +32 -35
  30. package/dist/esm/src/session/index.js +9 -12
  31. package/dist/types/src/controller/helpers/logIfDevelopment.d.ts +3 -4
  32. package/dist/types/src/controller/index.d.ts +18 -7
  33. package/dist/types/src/error/router/cannot-commit-routes-without-koa-app.d.ts +3 -0
  34. package/dist/types/src/helpers/cookieMaxAgeFromCookieOpts.d.ts +1 -1
  35. package/dist/types/src/helpers/toJson.d.ts +1 -1
  36. package/dist/types/src/helpers/validateOpenApiSchema.d.ts +5 -1
  37. package/dist/types/src/openapi-renderer/helpers/OpenapiPayloadValidator.d.ts +41 -0
  38. package/dist/types/src/openapi-renderer/helpers/stringify-cache.d.ts +34 -0
  39. package/dist/types/src/openapi-renderer/helpers/validator-cache.d.ts +35 -0
  40. package/dist/types/src/psychic-app/index.d.ts +11 -14
  41. package/dist/types/src/router/index.d.ts +17 -17
  42. package/dist/types/src/router/route-manager.d.ts +4 -3
  43. package/dist/types/src/server/helpers/startPsychicServer.d.ts +3 -3
  44. package/dist/types/src/server/index.d.ts +3 -3
  45. package/dist/types/src/session/index.d.ts +13 -5
  46. package/package.json +29 -18
  47. package/dist/cjs/src/error/router/cannot-commit-routes-without-express-app.js +0 -12
  48. package/dist/esm/src/error/router/cannot-commit-routes-without-express-app.js +0 -12
  49. 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({ req, res, startTime, fallbackStatusCode = 200, }) {
6
+ export default function logIfDevelopment({ ctx, startTime, fallbackStatusCode = 200, }) {
7
7
  if (!EnvInternal.isDevelopment)
8
8
  return;
9
- const method = colorize(` ${req.method.toUpperCase()} `, {
9
+ const method = colorize(` ${ctx.method.toUpperCase()} `, {
10
10
  color: 'black',
11
- bgColor: httpMethodBgColor(req.method.toLowerCase()),
11
+ bgColor: httpMethodBgColor(ctx.method.toLowerCase()),
12
12
  });
13
- const computedStatus = res.statusCode || fallbackStatusCode;
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(req.url, { color: 'green' });
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
- req;
177
- res;
178
+ ctx;
178
179
  session;
179
180
  action;
180
181
  renderOpts;
181
182
  startTime;
182
- constructor(req, res, { action, }) {
183
+ _responseSent = false;
184
+ constructor(ctx, { action, }) {
183
185
  this.startTime = Date.now();
184
- this.req = req;
185
- this.res = res;
186
- this.session = new Session(req, res);
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.req.headers;
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
- ...this.req.body,
240
- ...this.req.params,
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.req.query;
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.expressSendJson(data);
597
+ this.koaSendJson(data);
589
598
  }
590
- expressSendJson(
599
+ koaSendJson(
591
600
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
592
- data, statusCode = this.res.statusCode) {
593
- this.res.type('json').status(statusCode).send(toJson(data, PsychicApp.getOrFail().sanitizeResponseJson));
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
- expressSendStatus(statusCode) {
597
- this.res.sendStatus(statusCode);
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
- expressRedirect(statusCode, newLocation) {
601
- this.res.redirect(statusCode, newLocation);
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({ req: this.req, res: this.res, startTime: this.startTime });
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.res.status(openapiData?.['status'] || 200);
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.res.status(realStatus);
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.res.redirect(path);
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.res.status(201);
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.res.status(202);
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.expressSendJson(message, 203);
861
+ this.koaSendJson(message, 203);
789
862
  }
790
863
  else {
791
- this.expressSendStatus(203);
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.expressSendStatus(204);
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.res.status(205);
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.res.status(208);
895
+ this.ctx.status = 208;
823
896
  this.json(message);
824
897
  }
825
898
  // 301
826
899
  movedPermanently(newLocation) {
827
- this.expressRedirect(301, newLocation);
900
+ this.koaRedirect(301, newLocation);
828
901
  }
829
902
  // 302
830
903
  found(newLocation) {
831
- this.expressRedirect(302, newLocation);
904
+ this.koaRedirect(302, newLocation);
832
905
  }
833
906
  // 303
834
907
  seeOther(newLocation) {
835
- this.expressRedirect(303, newLocation);
908
+ this.koaRedirect(303, newLocation);
836
909
  }
837
910
  // 307
838
911
  temporaryRedirect(newLocation) {
839
- this.expressRedirect(307, newLocation);
912
+ this.koaRedirect(307, newLocation);
840
913
  }
841
914
  // 308
842
915
  permanentRedirect(newLocation) {
843
- this.expressRedirect(308, newLocation);
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.res.headersSent)
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(this.req.body);
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.req.headers);
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.req.query);
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.res.statusCode);
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.res.headersSent)
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
@@ -31,7 +31,6 @@ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', t
31
31
  onStdOut(txt);
32
32
  }
33
33
  else {
34
- // eslint-disable-next-line no-console
35
34
  console.log(txt);
36
35
  }
37
36
  });
@@ -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
- import { sanitizeString } from '@rvoh/dream/utils';
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
- if (sanitize) {
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 validateOpenApiSchema from '../../helpers/validateOpenApiSchema.js';
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 openapiResponseBody = openapiEndpointRenderer['parseResponses']({
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 validationResults = validateOpenApiSchema(data || {}, this.addComponentsToSchema(openapiSchema), this.getAjvOptions(target));
245
- if (!validationResults.isValid) {
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(validationResults.errors || [], target);
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({}, {}, { action: 'a' });
35
+ new controllerClass({}, { action: 'a' });
36
36
  }
37
37
  }
38
38
  PsychicController['globallyInitializingDecorators'] = false;