@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.
Files changed (65) hide show
  1. package/dist/cjs/src/cli/index.js +4 -0
  2. package/dist/cjs/src/controller/helpers/logIfDevelopment.js +5 -5
  3. package/dist/cjs/src/controller/index.js +119 -40
  4. package/dist/cjs/src/devtools/helpers/launchDevServer.js +15 -1
  5. package/dist/cjs/src/error/openapi/UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute.js +44 -0
  6. package/dist/cjs/src/error/router/cannot-commit-routes-without-koa-app.js +12 -0
  7. package/dist/cjs/src/helpers/toJson.js +2 -8
  8. package/dist/cjs/src/helpers/validateOpenApiSchema.js +1 -1
  9. package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +2 -2
  10. package/dist/cjs/src/openapi-renderer/endpoint.js +2 -2
  11. package/dist/cjs/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +75 -9
  12. package/dist/cjs/src/openapi-renderer/helpers/{dreamAttributeOpenapiShape.js → dreamColumnOpenapiShape.js} +19 -6
  13. package/dist/cjs/src/openapi-renderer/helpers/stringify-cache.js +55 -0
  14. package/dist/cjs/src/openapi-renderer/helpers/validator-cache.js +52 -0
  15. package/dist/cjs/src/psychic-app/helpers/import/importControllers.js +1 -1
  16. package/dist/cjs/src/psychic-app/index.js +3 -10
  17. package/dist/cjs/src/router/index.js +31 -25
  18. package/dist/cjs/src/server/helpers/startPsychicServer.js +6 -2
  19. package/dist/cjs/src/server/index.js +32 -35
  20. package/dist/cjs/src/server/params.js +56 -3
  21. package/dist/cjs/src/session/index.js +9 -12
  22. package/dist/esm/src/cli/index.js +4 -0
  23. package/dist/esm/src/controller/helpers/logIfDevelopment.js +5 -5
  24. package/dist/esm/src/controller/index.js +119 -40
  25. package/dist/esm/src/devtools/helpers/launchDevServer.js +15 -1
  26. package/dist/esm/src/error/openapi/UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute.js +44 -0
  27. package/dist/esm/src/error/router/cannot-commit-routes-without-koa-app.js +12 -0
  28. package/dist/esm/src/helpers/toJson.js +2 -8
  29. package/dist/esm/src/helpers/validateOpenApiSchema.js +1 -1
  30. package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +2 -2
  31. package/dist/esm/src/openapi-renderer/endpoint.js +2 -2
  32. package/dist/esm/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +75 -9
  33. package/dist/esm/src/openapi-renderer/helpers/{dreamAttributeOpenapiShape.js → dreamColumnOpenapiShape.js} +19 -6
  34. package/dist/esm/src/openapi-renderer/helpers/stringify-cache.js +55 -0
  35. package/dist/esm/src/openapi-renderer/helpers/validator-cache.js +52 -0
  36. package/dist/esm/src/psychic-app/helpers/import/importControllers.js +1 -1
  37. package/dist/esm/src/psychic-app/index.js +3 -10
  38. package/dist/esm/src/router/index.js +31 -25
  39. package/dist/esm/src/server/helpers/startPsychicServer.js +6 -2
  40. package/dist/esm/src/server/index.js +32 -35
  41. package/dist/esm/src/server/params.js +56 -3
  42. package/dist/esm/src/session/index.js +9 -12
  43. package/dist/types/src/controller/helpers/logIfDevelopment.d.ts +3 -4
  44. package/dist/types/src/controller/index.d.ts +19 -8
  45. package/dist/types/src/devtools/helpers/launchDevServer.d.ts +2 -1
  46. package/dist/types/src/error/openapi/UnrecognizedDbTypeFoundWhileComputingOpenapiAttribute.d.ts +7 -0
  47. package/dist/types/src/error/router/cannot-commit-routes-without-koa-app.d.ts +3 -0
  48. package/dist/types/src/helpers/cookieMaxAgeFromCookieOpts.d.ts +1 -1
  49. package/dist/types/src/helpers/toJson.d.ts +1 -1
  50. package/dist/types/src/helpers/validateOpenApiSchema.d.ts +5 -1
  51. package/dist/types/src/openapi-renderer/helpers/OpenapiPayloadValidator.d.ts +41 -0
  52. package/dist/types/src/openapi-renderer/helpers/{dreamAttributeOpenapiShape.d.ts → dreamColumnOpenapiShape.d.ts} +1 -1
  53. package/dist/types/src/openapi-renderer/helpers/stringify-cache.d.ts +34 -0
  54. package/dist/types/src/openapi-renderer/helpers/validator-cache.d.ts +35 -0
  55. package/dist/types/src/psychic-app/index.d.ts +11 -14
  56. package/dist/types/src/router/index.d.ts +17 -17
  57. package/dist/types/src/router/route-manager.d.ts +4 -3
  58. package/dist/types/src/server/helpers/startPsychicServer.d.ts +3 -3
  59. package/dist/types/src/server/index.d.ts +3 -3
  60. package/dist/types/src/server/params.d.ts +2 -2
  61. package/dist/types/src/session/index.d.ts +13 -5
  62. package/package.json +30 -19
  63. package/dist/cjs/src/error/router/cannot-commit-routes-without-express-app.js +0 -12
  64. package/dist/esm/src/error/router/cannot-commit-routes-without-express-app.js +0 -12
  65. package/dist/types/src/error/router/cannot-commit-routes-without-express-app.d.ts +0 -3
@@ -32,6 +32,10 @@ ${INDENT} - date
32
32
  ${INDENT} - date[]
33
33
  ${INDENT} - datetime
34
34
  ${INDENT} - datetime[]
35
+ ${INDENT} - time
36
+ ${INDENT} - time[]
37
+ ${INDENT} - timetz
38
+ ${INDENT} - timetz[]
35
39
  ${INDENT} - integer
36
40
  ${INDENT} - integer[]
37
41
  ${INDENT}
@@ -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';
@@ -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
- req;
173
- res;
178
+ ctx;
174
179
  session;
175
180
  action;
176
181
  renderOpts;
177
182
  startTime;
178
- constructor(req, res, { action, }) {
183
+ _responseSent = false;
184
+ constructor(ctx, { action, }) {
179
185
  this.startTime = Date.now();
180
- this.req = req;
181
- this.res = res;
182
- 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);
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.req.headers;
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
- ...this.req.body,
236
- ...this.req.params,
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.req.query;
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.expressSendJson(data);
597
+ this.koaSendJson(data);
585
598
  }
586
- expressSendJson(
599
+ koaSendJson(
587
600
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
588
- data, statusCode = this.res.statusCode) {
589
- 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;
590
621
  this.logIfDevelopment();
591
622
  }
592
- expressSendStatus(statusCode) {
593
- 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;
594
667
  this.logIfDevelopment();
595
668
  }
596
- expressRedirect(statusCode, newLocation) {
597
- 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;
598
675
  this.logIfDevelopment();
599
676
  }
600
677
  logIfDevelopment() {
601
678
  if (!EnvInternal.isDevelopment)
602
679
  return;
603
- logIfDevelopment({ req: this.req, res: this.res, startTime: this.startTime });
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.res.status(openapiData?.['status'] || 200);
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.res.status(realStatus);
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.res.redirect(path);
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.res.status(201);
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.res.status(202);
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.expressSendJson(message, 203);
861
+ this.koaSendJson(message, 203);
785
862
  }
786
863
  else {
787
- this.expressSendStatus(203);
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.expressSendStatus(204);
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.res.status(205);
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.res.status(208);
895
+ this.ctx.status = 208;
819
896
  this.json(message);
820
897
  }
821
898
  // 301
822
899
  movedPermanently(newLocation) {
823
- this.expressRedirect(301, newLocation);
900
+ this.koaRedirect(301, newLocation);
824
901
  }
825
902
  // 302
826
903
  found(newLocation) {
827
- this.expressRedirect(302, newLocation);
904
+ this.koaRedirect(302, newLocation);
828
905
  }
829
906
  // 303
830
907
  seeOther(newLocation) {
831
- this.expressRedirect(303, newLocation);
908
+ this.koaRedirect(303, newLocation);
832
909
  }
833
910
  // 307
834
911
  temporaryRedirect(newLocation) {
835
- this.expressRedirect(307, newLocation);
912
+ this.koaRedirect(307, newLocation);
836
913
  }
837
914
  // 308
838
915
  permanentRedirect(newLocation) {
839
- this.expressRedirect(308, newLocation);
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.res.headersSent)
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(this.req.body);
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.req.headers);
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.req.query);
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.res.statusCode);
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.res.headersSent)
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
- 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,
@@ -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/dreamAttributeOpenapiShape.js';
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/dreamAttributeOpenapiShape.js';
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 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
  *