@rvoh/psychic 3.0.0-alpha.1 → 3.0.0-alpha.3

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.
@@ -2,6 +2,7 @@ import { Dream, DreamApp } from '@rvoh/dream';
2
2
  import { GlobalNameNotSet } from '@rvoh/dream/errors';
3
3
  import { DreamSerializerBuilder, ObjectSerializerBuilder } from '@rvoh/dream/system';
4
4
  import fastJsonStringify from 'fast-json-stringify';
5
+ import { debuglog } from 'node:util';
5
6
  import ParamValidationError from '../error/controller/ParamValidationError.js';
6
7
  import HttpStatusBadGateway from '../error/http/BadGateway.js';
7
8
  import HttpStatusBadRequest from '../error/http/BadRequest.js';
@@ -607,7 +608,9 @@ export default class PsychicController {
607
608
  return;
608
609
  this.ctx.status = statusCode;
609
610
  // Try to use fast-json-stringify if an OpenAPI schema exists for this endpoint
610
- const stringifyFn = this.getFastJsonStringifyFunction(statusCode);
611
+ const stringifyFn = this.currentOpenapiRenderer?.['disableFastJson']
612
+ ? undefined
613
+ : this.getFastJsonStringifyFunction(statusCode);
611
614
  if (stringifyFn) {
612
615
  this.ctx.type = 'application/json';
613
616
  this.ctx.body = stringifyFn(data);
@@ -644,15 +647,23 @@ export default class PsychicController {
644
647
  const schemaWithComponents = validator.getResponseSchemaWithComponents(statusCode);
645
648
  if (!schemaWithComponents)
646
649
  continue;
647
- // Generate cache key
648
650
  const cacheKey = `${controllerClass.globalName}#${this.action}|${openapiName}|${statusCode}`;
649
- // Check cache first
650
651
  const cachedStringify = getCachedStringify(cacheKey);
651
652
  if (cachedStringify)
652
653
  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);
654
+ let stringifyFn;
655
+ if (debuglog('json').enabled) {
656
+ const result = fastJsonStringify(schemaWithComponents, {
657
+ mode: 'debug',
658
+ ajv: { validateFormats: false },
659
+ });
660
+ PsychicApp.log('fast-json-stringify code:', result.code);
661
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
662
+ stringifyFn = fastJsonStringify.restore(result);
663
+ }
664
+ else {
665
+ stringifyFn = fastJsonStringify(schemaWithComponents, { ajv: { validateFormats: false } });
666
+ }
656
667
  cacheStringify(cacheKey, stringifyFn);
657
668
  return stringifyFn;
658
669
  }
@@ -166,8 +166,8 @@ export default class SerializerOpenapiRenderer {
166
166
  // rendersOnes //
167
167
  //////////////////
168
168
  case 'rendersOne': {
169
+ const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
169
170
  try {
170
- const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
171
171
  const { associationOpts, referencedSerializersAndOpenapiSchemaBodyShorthand } = associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDescendantSerializers);
172
172
  const optional = attribute.options.optional ?? associationOpts.optional;
173
173
  newlyReferencedSerializers =
@@ -193,8 +193,14 @@ export default class SerializerOpenapiRenderer {
193
193
  return accumulator;
194
194
  }
195
195
  catch (error) {
196
- if (error instanceof CallingSerializersThrewError)
196
+ if (error instanceof CallingSerializersThrewError) {
197
+ accumulator[outputAttributeName] = {
198
+ type: 'object',
199
+ additionalProperties: true,
200
+ description: `Serializer ${this.serializer['globalName']} includes a rendersOne "${outputAttributeName}" with an OpenAPI shape that cannot be defined. This will break fast-json-stringify. Define the OpenAPI shape or disableFastJson on the endpoint.`,
201
+ };
197
202
  return accumulator;
203
+ }
198
204
  if (error instanceof AttemptedToDeriveDescendentSerializersFromNonSerializer)
199
205
  throw new ExpectedSerializerForRendersOneOrManyOption('rendersOne', this.globalName, attribute);
200
206
  throw error;
@@ -207,8 +213,8 @@ export default class SerializerOpenapiRenderer {
207
213
  // rendersManys //
208
214
  ///////////////////
209
215
  case 'rendersMany': {
216
+ const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
210
217
  try {
211
- const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
212
218
  const { referencedSerializersAndOpenapiSchemaBodyShorthand } = associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDescendantSerializers);
213
219
  newlyReferencedSerializers =
214
220
  referencedSerializersAndOpenapiSchemaBodyShorthand.referencedSerializers;
@@ -219,8 +225,14 @@ export default class SerializerOpenapiRenderer {
219
225
  return accumulator;
220
226
  }
221
227
  catch (error) {
222
- if (error instanceof CallingSerializersThrewError)
228
+ if (error instanceof CallingSerializersThrewError) {
229
+ accumulator[outputAttributeName] = {
230
+ type: 'array',
231
+ items: { type: 'object', additionalProperties: true },
232
+ description: `Serializer ${this.serializer['globalName']} includes a rendersMany "${outputAttributeName}" with an OpenAPI shape that cannot be defined. This will break fast-json-stringify. Define the OpenAPI shape or disableFastJson on the endpoint.`,
233
+ };
223
234
  return accumulator;
235
+ }
224
236
  if (error instanceof AttemptedToDeriveDescendentSerializersFromNonSerializer)
225
237
  throw new ExpectedSerializerForRendersOneOrManyOption('rendersMany', this.globalName, attribute);
226
238
  throw error;
@@ -40,6 +40,7 @@ export default class OpenapiEndpointRenderer {
40
40
  omitDefaultResponses;
41
41
  defaultResponse;
42
42
  validate = undefined;
43
+ disableFastJson = false;
43
44
  /**
44
45
  * instantiates a new OpenapiEndpointRenderer.
45
46
  * This class is used by the `@Openapi` decorator
@@ -54,7 +55,7 @@ export default class OpenapiEndpointRenderer {
54
55
  * const json = JSON.encode(openapiJsonContents, null, 2)
55
56
  * ```
56
57
  */
57
- constructor(dreamsOrSerializers, controllerClass, action, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, } = {}) {
58
+ constructor(dreamsOrSerializers, controllerClass, action, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, disableFastJson, } = {}) {
58
59
  this.dreamsOrSerializers = dreamsOrSerializers;
59
60
  this.controllerClass = controllerClass;
60
61
  this.action = action;
@@ -83,6 +84,7 @@ export default class OpenapiEndpointRenderer {
83
84
  : omitDefaultResponses;
84
85
  this.defaultResponse = defaultResponse;
85
86
  this.validate = validate;
87
+ this.disableFastJson = disableFastJson ?? false;
86
88
  }
87
89
  /**
88
90
  * @internal
@@ -111,12 +111,7 @@ export default class PsychicApp {
111
111
  */
112
112
  buildOpenapiCache() {
113
113
  Object.keys(this.openapi).forEach(openapiName => {
114
- if (this.openapi[openapiName]?.validate) {
115
- this.cacheOpenapiDoc(openapiName);
116
- }
117
- else {
118
- this.ignoreOpenapiDoc(openapiName);
119
- }
114
+ this.cacheOpenapiDoc(openapiName);
120
115
  });
121
116
  }
122
117
  /**
@@ -1,9 +1,9 @@
1
- import { closeAllDbConnections } from '@rvoh/dream/db';
2
1
  import cors from '@koa/cors';
2
+ import etag from '@koa/etag';
3
+ import { closeAllDbConnections } from '@rvoh/dream/db';
3
4
  import Koa from 'koa';
4
5
  import koaBodyparser from 'koa-bodyparser';
5
6
  import conditional from 'koa-conditional-get';
6
- import etag from 'koa-etag';
7
7
  import logIfDevelopment from '../controller/helpers/logIfDevelopment.js';
8
8
  import EnvInternal from '../helpers/EnvInternal.js';
9
9
  import PsychicApp from '../psychic-app/index.js';
@@ -2,6 +2,7 @@ import { Dream, DreamApp } from '@rvoh/dream';
2
2
  import { GlobalNameNotSet } from '@rvoh/dream/errors';
3
3
  import { DreamSerializerBuilder, ObjectSerializerBuilder } from '@rvoh/dream/system';
4
4
  import fastJsonStringify from 'fast-json-stringify';
5
+ import { debuglog } from 'node:util';
5
6
  import ParamValidationError from '../error/controller/ParamValidationError.js';
6
7
  import HttpStatusBadGateway from '../error/http/BadGateway.js';
7
8
  import HttpStatusBadRequest from '../error/http/BadRequest.js';
@@ -607,7 +608,9 @@ export default class PsychicController {
607
608
  return;
608
609
  this.ctx.status = statusCode;
609
610
  // Try to use fast-json-stringify if an OpenAPI schema exists for this endpoint
610
- const stringifyFn = this.getFastJsonStringifyFunction(statusCode);
611
+ const stringifyFn = this.currentOpenapiRenderer?.['disableFastJson']
612
+ ? undefined
613
+ : this.getFastJsonStringifyFunction(statusCode);
611
614
  if (stringifyFn) {
612
615
  this.ctx.type = 'application/json';
613
616
  this.ctx.body = stringifyFn(data);
@@ -644,15 +647,23 @@ export default class PsychicController {
644
647
  const schemaWithComponents = validator.getResponseSchemaWithComponents(statusCode);
645
648
  if (!schemaWithComponents)
646
649
  continue;
647
- // Generate cache key
648
650
  const cacheKey = `${controllerClass.globalName}#${this.action}|${openapiName}|${statusCode}`;
649
- // Check cache first
650
651
  const cachedStringify = getCachedStringify(cacheKey);
651
652
  if (cachedStringify)
652
653
  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);
654
+ let stringifyFn;
655
+ if (debuglog('json').enabled) {
656
+ const result = fastJsonStringify(schemaWithComponents, {
657
+ mode: 'debug',
658
+ ajv: { validateFormats: false },
659
+ });
660
+ PsychicApp.log('fast-json-stringify code:', result.code);
661
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
662
+ stringifyFn = fastJsonStringify.restore(result);
663
+ }
664
+ else {
665
+ stringifyFn = fastJsonStringify(schemaWithComponents, { ajv: { validateFormats: false } });
666
+ }
656
667
  cacheStringify(cacheKey, stringifyFn);
657
668
  return stringifyFn;
658
669
  }
@@ -166,8 +166,8 @@ export default class SerializerOpenapiRenderer {
166
166
  // rendersOnes //
167
167
  //////////////////
168
168
  case 'rendersOne': {
169
+ const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
169
170
  try {
170
- const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
171
171
  const { associationOpts, referencedSerializersAndOpenapiSchemaBodyShorthand } = associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDescendantSerializers);
172
172
  const optional = attribute.options.optional ?? associationOpts.optional;
173
173
  newlyReferencedSerializers =
@@ -193,8 +193,14 @@ export default class SerializerOpenapiRenderer {
193
193
  return accumulator;
194
194
  }
195
195
  catch (error) {
196
- if (error instanceof CallingSerializersThrewError)
196
+ if (error instanceof CallingSerializersThrewError) {
197
+ accumulator[outputAttributeName] = {
198
+ type: 'object',
199
+ additionalProperties: true,
200
+ description: `Serializer ${this.serializer['globalName']} includes a rendersOne "${outputAttributeName}" with an OpenAPI shape that cannot be defined. This will break fast-json-stringify. Define the OpenAPI shape or disableFastJson on the endpoint.`,
201
+ };
197
202
  return accumulator;
203
+ }
198
204
  if (error instanceof AttemptedToDeriveDescendentSerializersFromNonSerializer)
199
205
  throw new ExpectedSerializerForRendersOneOrManyOption('rendersOne', this.globalName, attribute);
200
206
  throw error;
@@ -207,8 +213,8 @@ export default class SerializerOpenapiRenderer {
207
213
  // rendersManys //
208
214
  ///////////////////
209
215
  case 'rendersMany': {
216
+ const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
210
217
  try {
211
- const outputAttributeName = this.setCase(attribute.options.as ?? attribute.name);
212
218
  const { referencedSerializersAndOpenapiSchemaBodyShorthand } = associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDescendantSerializers);
213
219
  newlyReferencedSerializers =
214
220
  referencedSerializersAndOpenapiSchemaBodyShorthand.referencedSerializers;
@@ -219,8 +225,14 @@ export default class SerializerOpenapiRenderer {
219
225
  return accumulator;
220
226
  }
221
227
  catch (error) {
222
- if (error instanceof CallingSerializersThrewError)
228
+ if (error instanceof CallingSerializersThrewError) {
229
+ accumulator[outputAttributeName] = {
230
+ type: 'array',
231
+ items: { type: 'object', additionalProperties: true },
232
+ description: `Serializer ${this.serializer['globalName']} includes a rendersMany "${outputAttributeName}" with an OpenAPI shape that cannot be defined. This will break fast-json-stringify. Define the OpenAPI shape or disableFastJson on the endpoint.`,
233
+ };
223
234
  return accumulator;
235
+ }
224
236
  if (error instanceof AttemptedToDeriveDescendentSerializersFromNonSerializer)
225
237
  throw new ExpectedSerializerForRendersOneOrManyOption('rendersMany', this.globalName, attribute);
226
238
  throw error;
@@ -40,6 +40,7 @@ export default class OpenapiEndpointRenderer {
40
40
  omitDefaultResponses;
41
41
  defaultResponse;
42
42
  validate = undefined;
43
+ disableFastJson = false;
43
44
  /**
44
45
  * instantiates a new OpenapiEndpointRenderer.
45
46
  * This class is used by the `@Openapi` decorator
@@ -54,7 +55,7 @@ export default class OpenapiEndpointRenderer {
54
55
  * const json = JSON.encode(openapiJsonContents, null, 2)
55
56
  * ```
56
57
  */
57
- constructor(dreamsOrSerializers, controllerClass, action, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, } = {}) {
58
+ constructor(dreamsOrSerializers, controllerClass, action, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, disableFastJson, } = {}) {
58
59
  this.dreamsOrSerializers = dreamsOrSerializers;
59
60
  this.controllerClass = controllerClass;
60
61
  this.action = action;
@@ -83,6 +84,7 @@ export default class OpenapiEndpointRenderer {
83
84
  : omitDefaultResponses;
84
85
  this.defaultResponse = defaultResponse;
85
86
  this.validate = validate;
87
+ this.disableFastJson = disableFastJson ?? false;
86
88
  }
87
89
  /**
88
90
  * @internal
@@ -111,12 +111,7 @@ export default class PsychicApp {
111
111
  */
112
112
  buildOpenapiCache() {
113
113
  Object.keys(this.openapi).forEach(openapiName => {
114
- if (this.openapi[openapiName]?.validate) {
115
- this.cacheOpenapiDoc(openapiName);
116
- }
117
- else {
118
- this.ignoreOpenapiDoc(openapiName);
119
- }
114
+ this.cacheOpenapiDoc(openapiName);
120
115
  });
121
116
  }
122
117
  /**
@@ -1,9 +1,9 @@
1
- import { closeAllDbConnections } from '@rvoh/dream/db';
2
1
  import cors from '@koa/cors';
2
+ import etag from '@koa/etag';
3
+ import { closeAllDbConnections } from '@rvoh/dream/db';
3
4
  import Koa from 'koa';
4
5
  import koaBodyparser from 'koa-bodyparser';
5
6
  import conditional from 'koa-conditional-get';
6
- import etag from 'koa-etag';
7
7
  import logIfDevelopment from '../controller/helpers/logIfDevelopment.js';
8
8
  import EnvInternal from '../helpers/EnvInternal.js';
9
9
  import PsychicApp from '../psychic-app/index.js';
@@ -46,6 +46,7 @@ export default class OpenapiEndpointRenderer<DreamsOrSerializersOrViewModels ext
46
46
  private omitDefaultResponses;
47
47
  private defaultResponse;
48
48
  private validate;
49
+ private disableFastJson;
49
50
  /**
50
51
  * instantiates a new OpenapiEndpointRenderer.
51
52
  * This class is used by the `@Openapi` decorator
@@ -60,7 +61,7 @@ export default class OpenapiEndpointRenderer<DreamsOrSerializersOrViewModels ext
60
61
  * const json = JSON.encode(openapiJsonContents, null, 2)
61
62
  * ```
62
63
  */
63
- constructor(dreamsOrSerializers: DreamsOrSerializersOrViewModels | null, controllerClass: typeof PsychicController, action: string, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, }?: OpenapiEndpointRendererOpts<DreamsOrSerializersOrViewModels, ForOption>);
64
+ constructor(dreamsOrSerializers: DreamsOrSerializersOrViewModels | null, controllerClass: typeof PsychicController, action: string, { requestBody, headers, many, paginate, cursorPaginate, scrollPaginate, query, responses, serializerKey, status, tags, security, pathParams, description, summary, omitDefaultHeaders, omitDefaultResponses, defaultResponse, validate, disableFastJson, }?: OpenapiEndpointRendererOpts<DreamsOrSerializersOrViewModels, ForOption>);
64
65
  /**
65
66
  * @internal
66
67
  *
@@ -611,6 +612,11 @@ export interface OpenapiEndpointRendererOpts<I extends DreamSerializable | Dream
611
612
  * ```
612
613
  */
613
614
  validate?: OpenapiValidateOption | undefined;
615
+ /**
616
+ * The OpenAPI response body automatically enables fast-json-stringify.
617
+ * If you want to render via JSON.stringify instead, set `disableFastJson` to `true`.
618
+ */
619
+ disableFastJson?: boolean;
614
620
  }
615
621
  export type OpenapiValidateOption = {
616
622
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.0.0-alpha.1",
5
+ "version": "3.0.0-alpha.3",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",
@@ -66,6 +66,7 @@
66
66
  "prepack": "pnpm build"
67
67
  },
68
68
  "dependencies": {
69
+ "@koa/etag": "^5.0.2",
69
70
  "ajv": "^8.17.1",
70
71
  "ajv-formats": "^3.0.1",
71
72
  "commander": "^12.1.0",
@@ -84,7 +85,6 @@
84
85
  "koa": "^2.15.3",
85
86
  "koa-bodyparser": "^4.4.1",
86
87
  "koa-conditional-get": "^3.0.0",
87
- "koa-etag": "^5.0.0",
88
88
  "openapi-typescript": "^7.8.0"
89
89
  },
90
90
  "devDependencies": {
@@ -94,7 +94,7 @@
94
94
  "@rvoh/dream": "^2.3.1",
95
95
  "@rvoh/dream-spec-helpers": "^2.1.1",
96
96
  "@rvoh/psychic-spec-helpers": "^3.0.0-alpha.1",
97
- "@types/koa": "^2.15.0",
97
+ "@types/koa": "^3.0.1",
98
98
  "@types/koa-bodyparser": "^4.3.12",
99
99
  "@types/koa-conditional-get": "^2.0.3",
100
100
  "@types/koa-etag": "^3.0.3",
@@ -110,10 +110,9 @@
110
110
  "@typescript/analyze-trace": "^0.10.1",
111
111
  "eslint": "^9.39.1",
112
112
  "jsdom": "^26.1.0",
113
- "koa": "^2.15.3",
113
+ "koa": "^3.1.1",
114
114
  "koa-bodyparser": "^4.4.1",
115
115
  "koa-conditional-get": "^3.0.0",
116
- "koa-etag": "^4.0.0",
117
116
  "koa-passport": "^6.0.0",
118
117
  "koa-session": "^7.0.2",
119
118
  "kysely": "^0.28.5",