@rvoh/psychic 3.0.0-alpha.7 → 3.0.0-alpha.8

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 (37) hide show
  1. package/dist/cjs/src/cli/index.js +22 -0
  2. package/dist/cjs/src/generate/controller.js +21 -11
  3. package/dist/cjs/src/generate/helpers/addResourceToRoutes.js +6 -0
  4. package/dist/cjs/src/generate/helpers/generateControllerContent.js +17 -13
  5. package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +16 -14
  6. package/dist/cjs/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +36 -0
  7. package/dist/cjs/src/generate/helpers/zustandBindings/promptForOptions.js +55 -0
  8. package/dist/cjs/src/generate/helpers/zustandBindings/writeApiClientFile.js +50 -0
  9. package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
  10. package/dist/cjs/src/generate/openapi/zustandBindings.js +27 -0
  11. package/dist/cjs/src/generate/resource.js +3 -0
  12. package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
  13. package/dist/cjs/src/openapi-renderer/body-segment.js +5 -1
  14. package/dist/esm/src/cli/index.js +22 -0
  15. package/dist/esm/src/generate/controller.js +21 -11
  16. package/dist/esm/src/generate/helpers/addResourceToRoutes.js +6 -0
  17. package/dist/esm/src/generate/helpers/generateControllerContent.js +17 -13
  18. package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +16 -14
  19. package/dist/esm/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +36 -0
  20. package/dist/esm/src/generate/helpers/zustandBindings/promptForOptions.js +55 -0
  21. package/dist/esm/src/generate/helpers/zustandBindings/writeApiClientFile.js +50 -0
  22. package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
  23. package/dist/esm/src/generate/openapi/zustandBindings.js +27 -0
  24. package/dist/esm/src/generate/resource.js +3 -0
  25. package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
  26. package/dist/esm/src/openapi-renderer/body-segment.js +5 -1
  27. package/dist/types/src/bin/index.d.ts +1 -0
  28. package/dist/types/src/cli/index.d.ts +1 -0
  29. package/dist/types/src/generate/helpers/generateControllerContent.d.ts +2 -1
  30. package/dist/types/src/generate/helpers/generateResourceControllerSpecContent.d.ts +1 -0
  31. package/dist/types/src/generate/helpers/zustandBindings/printFinalStepsMessage.d.ts +2 -0
  32. package/dist/types/src/generate/helpers/zustandBindings/promptForOptions.d.ts +2 -0
  33. package/dist/types/src/generate/helpers/zustandBindings/writeApiClientFile.d.ts +5 -0
  34. package/dist/types/src/generate/helpers/zustandBindings/writeInitializer.d.ts +5 -0
  35. package/dist/types/src/generate/openapi/zustandBindings.d.ts +21 -0
  36. package/dist/types/src/generate/resource.d.ts +1 -0
  37. package/package.json +27 -31
@@ -4,6 +4,7 @@ import generateController from '../generate/controller.js';
4
4
  import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
5
5
  import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
6
6
  import generateOpenapiReduxBindings from '../generate/openapi/reduxBindings.js';
7
+ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.js';
7
8
  import generateResource from '../generate/resource.js';
8
9
  import Watcher from '../watcher/Watcher.js';
9
10
  const INDENT = ' ';
@@ -85,6 +86,7 @@ export default class PsychicCLI {
85
86
  .option('--sti-base-serializer', 'omits the serializer from the dream model, but does create the serializer so it can be extended by STI children')
86
87
  .option('--owning-model <modelName>', 'the model class of the object that `associationQuery`/`createAssociation` will be performed on in the created controller and spec (e.g., "Host", "Guest", "Ticketing/Ticket") (simply to save time making changes to the generated code). Defaults to User')
87
88
  .option('--connection-name <connectionName>', 'the name of the db connection you would like to use for your model. Defaults to "default"', 'default')
89
+ .option('--model-name <modelName>', 'explicit model class name to use instead of the auto-generated one (e.g. --model-name=Kitchen for Room/Kitchen)')
88
90
  .argument('<path>', 'URL path from root domain. Specify nesting resource with `{}`, e.g.: `tickets/{}/comments`')
89
91
  .argument('<modelName>', 'the name of the model to create, e.g. Post or Settings/CommunicationPreferences')
90
92
  .argument('[columnsWithTypes...]', columnsWithTypesDescription)
@@ -142,6 +144,26 @@ export default class PsychicCLI {
142
144
  });
143
145
  process.exit();
144
146
  });
147
+ program
148
+ .command('setup:sync:openapi-zustand')
149
+ .description('Generates openapi zustand bindings using openapi-fetch to connect one of your openapi files to one of your clients.')
150
+ .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
151
+ .option('--export-name <exportName>', 'the camelCased name to use for your exported api client, i.e. myBackendApi')
152
+ .option('--output-dir <outputDir>', 'the path to the directory where the generated api client and types files will be written, i.e. ../client/src/api')
153
+ .option('--types-file <typesFile>', 'the path to the file that will contain your generated openapi TypeScript type definitions, i.e. ../client/src/api/myBackendApi.types.d.ts')
154
+ .action(async ({ schemaFile, exportName, outputDir, typesFile, }) => {
155
+ await initializePsychicApp({
156
+ bypassDreamIntegrityChecks: true,
157
+ bypassDbConnectionsDuringInit: true,
158
+ });
159
+ await generateOpenapiZustandBindings({
160
+ exportName,
161
+ schemaFile,
162
+ outputDir,
163
+ typesFile,
164
+ });
165
+ process.exit();
166
+ });
145
167
  program
146
168
  .command('setup:sync:openapi-typescript')
147
169
  .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
@@ -19,11 +19,12 @@ export default async function generateController({ fullyQualifiedControllerName,
19
19
  fullyQualifiedControllerName = fullyQualifiedControllerName.replace(pathParamRegexp, '/');
20
20
  const allControllerNameParts = fullyQualifiedControllerName.split('/');
21
21
  const forAdmin = allControllerNameParts[0] === 'Admin';
22
- const controllerNameParts = forAdmin ? [allControllerNameParts.shift()] : [];
22
+ const forInternal = allControllerNameParts[0] === 'Internal';
23
+ const controllerNameParts = (forAdmin || forInternal) ? [allControllerNameParts.shift()] : [];
23
24
  for (let index = 0; index < allControllerNameParts.length; index++) {
24
- if (controllerNameParts.length > (forAdmin ? 1 : 0)) {
25
+ if (controllerNameParts.length > ((forAdmin || forInternal) ? 1 : 0)) {
25
26
  // Write the ancestor controller
26
- const [baseAncestorName, baseAncestorImportStatement] = baseAncestorNameAndImport(controllerNameParts, forAdmin, { forBaseController: true });
27
+ const [baseAncestorName, baseAncestorImportStatement] = baseAncestorNameAndImport(controllerNameParts, forAdmin, forInternal, { forBaseController: true });
27
28
  if (baseAncestorName === undefined)
28
29
  throw new UnexpectedUndefined();
29
30
  if (baseAncestorImportStatement === undefined)
@@ -38,6 +39,7 @@ export default async function generateController({ fullyQualifiedControllerName,
38
39
  fullyQualifiedControllerName: baseControllerName,
39
40
  omitOpenApi: true,
40
41
  forAdmin,
42
+ forInternal,
41
43
  singular,
42
44
  }));
43
45
  }
@@ -47,7 +49,7 @@ export default async function generateController({ fullyQualifiedControllerName,
47
49
  controllerNameParts.push(namedPart);
48
50
  }
49
51
  // Write the controller
50
- const [ancestorName, ancestorImportStatement] = baseAncestorNameAndImport(controllerNameParts, forAdmin, {
52
+ const [ancestorName, ancestorImportStatement] = baseAncestorNameAndImport(controllerNameParts, forAdmin, forInternal, {
51
53
  forBaseController: false,
52
54
  });
53
55
  if (ancestorName === undefined)
@@ -66,6 +68,7 @@ export default async function generateController({ fullyQualifiedControllerName,
66
68
  actions,
67
69
  owningModel,
68
70
  forAdmin,
71
+ forInternal,
69
72
  singular,
70
73
  }));
71
74
  }
@@ -86,29 +89,35 @@ export default async function generateController({ fullyQualifiedControllerName,
86
89
  resourceSpecs,
87
90
  owningModel,
88
91
  forAdmin,
92
+ forInternal,
89
93
  singular,
90
94
  actions,
91
95
  });
92
96
  }
93
- function baseAncestorNameAndImport(controllerNameParts, forAdmin, { forBaseController }) {
97
+ function baseAncestorNameAndImport(controllerNameParts, forAdmin, forInternal, { forBaseController }) {
94
98
  const maybeAncestorNameForBase = `${controllerNameParts.slice(0, controllerNameParts.length - 1).join('')}BaseController`;
95
99
  const dotFiles = forBaseController ? '..' : '.';
96
- return controllerNameParts.length === (forAdmin ? 2 : 1)
100
+ return controllerNameParts.length === ((forAdmin || forInternal) ? 2 : 1)
97
101
  ? forAdmin
98
102
  ? [
99
103
  `AdminAuthedController`,
100
104
  `import AdminAuthedController from '${dotFiles}/${addImportSuffix('AuthedController.js')}'`,
101
105
  ]
102
- : [
103
- `AuthedController`,
104
- `import AuthedController from '${dotFiles}/${addImportSuffix('AuthedController.js')}'`,
105
- ]
106
+ : forInternal
107
+ ? [
108
+ `InternalAuthedController`,
109
+ `import InternalAuthedController from '${dotFiles}/${addImportSuffix('AuthedController.js')}'`,
110
+ ]
111
+ : [
112
+ `AuthedController`,
113
+ `import AuthedController from '${dotFiles}/${addImportSuffix('AuthedController.js')}'`,
114
+ ]
106
115
  : [
107
116
  maybeAncestorNameForBase,
108
117
  `import ${maybeAncestorNameForBase} from '${dotFiles}/${addImportSuffix('BaseController.js')}'`,
109
118
  ];
110
119
  }
111
- async function generateControllerSpec({ fullyQualifiedControllerName, route, fullyQualifiedModelName, columnsWithTypes, resourceSpecs, owningModel, forAdmin, singular, actions, }) {
120
+ async function generateControllerSpec({ fullyQualifiedControllerName, route, fullyQualifiedModelName, columnsWithTypes, resourceSpecs, owningModel, forAdmin, forInternal, singular, actions, }) {
112
121
  const { relFilePath, absDirPath, absFilePath } = psychicFileAndDirPaths(psychicPath('controllerSpecs'), fullyQualifiedControllerName + `.spec.ts`);
113
122
  try {
114
123
  console.log(`generating controller spec: ${relFilePath}`);
@@ -121,6 +130,7 @@ async function generateControllerSpec({ fullyQualifiedControllerName, route, ful
121
130
  columnsWithTypes,
122
131
  owningModel,
123
132
  forAdmin,
133
+ forInternal,
124
134
  singular,
125
135
  actions,
126
136
  })
@@ -13,6 +13,12 @@ export default async function addResourceToRoutes(route, options) {
13
13
  if (fsSync.existsSync(adminRoutesFilePath))
14
14
  routesFilePath = adminRoutesFilePath;
15
15
  }
16
+ const internalRouteRegexp = /^\/?internal/;
17
+ if (internalRouteRegexp.test(route)) {
18
+ const internalRoutesFilePath = routesFilePath.replace(/\.ts$/, '.internal.ts');
19
+ if (fsSync.existsSync(internalRoutesFilePath))
20
+ routesFilePath = internalRoutesFilePath;
21
+ }
16
22
  let routes = (await fs.readFile(routesFilePath)).toString();
17
23
  const results = addResourceToRoutes_routeToRegexAndReplacements(routes, route, options);
18
24
  routes = results.routes;
@@ -1,7 +1,7 @@
1
1
  import { DreamApp } from '@rvoh/dream';
2
2
  import { camelize, hyphenize } from '@rvoh/dream/utils';
3
3
  import pluralize from 'pluralize-esm';
4
- export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, singular, }) {
4
+ export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, }) {
5
5
  fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
6
6
  const additionalImports = [];
7
7
  const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
@@ -22,8 +22,12 @@ export default function generateControllerContent({ ancestorName, ancestorImport
22
22
  const defaultOpenapiSerializerKeyProperty = forAdmin
23
23
  ? `
24
24
  serializerKey: 'admin',`
25
- : '';
26
- const loadQueryBase = forAdmin
25
+ : forInternal
26
+ ? `
27
+ serializerKey: 'internal',`
28
+ : '';
29
+ const useDirectModelAccess = (forAdmin || forInternal) && !owningModel;
30
+ const loadQueryBase = useDirectModelAccess
27
31
  ? (modelClassName ?? 'no-class-name')
28
32
  : `this.${owningModelProperty}.associationQuery('${pluralizedModelAttributeName}')`;
29
33
  const methodDefs = actions.map(methodName => {
@@ -38,8 +42,8 @@ export default function generateControllerContent({ ancestorName, ancestorImport
38
42
  fastJsonStringify: true,
39
43
  })
40
44
  public async create() {
41
- // let ${modelAttributeName} = await ${forAdmin ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}this.paramsFor(${modelClassName}))
42
- // if (${modelAttributeName}.isPersisted) ${modelAttributeName} = await ${modelAttributeName}.loadFor('${forAdmin ? 'admin' : 'default'}').execute()
45
+ // let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}this.paramsFor(${modelClassName}))
46
+ // if (${modelAttributeName}.isPersisted) ${modelAttributeName} = await ${modelAttributeName}.loadFor('${forAdmin ? 'admin' : forInternal ? 'internal' : 'default'}').execute()
43
47
  // this.created(${modelAttributeName})
44
48
  }`;
45
49
  else
@@ -60,12 +64,12 @@ export default function generateControllerContent({ ancestorName, ancestorImport
60
64
  tags: openApiTags,
61
65
  description: 'Paginated index of ${pluralize(modelClassName)}',
62
66
  cursorPaginate: true,
63
- serializerKey: '${forAdmin ? 'adminSummary' : 'summary'}',
67
+ serializerKey: '${forAdmin ? 'adminSummary' : forInternal ? 'internalSummary' : 'summary'}',
64
68
  fastJsonStringify: true,
65
69
  })
66
70
  public async index() {
67
71
  // const ${pluralizedModelAttributeName} = await ${loadQueryBase}
68
- // .preloadFor('${forAdmin ? 'adminSummary' : 'summary'}')
72
+ // .preloadFor('${forAdmin ? 'adminSummary' : forInternal ? 'internalSummary' : 'summary'}')
69
73
  // .cursorPaginate({ cursor: this.castParam('cursor', 'string', { allowNull: true }) })
70
74
  // this.ok(${pluralizedModelAttributeName})
71
75
  }`;
@@ -76,7 +80,7 @@ export default function generateControllerContent({ ancestorName, ancestorImport
76
80
  // tags: openApiTags,
77
81
  // description: '<tbd>',
78
82
  // many: true,
79
- // serializerKey: '${forAdmin ? 'adminSummary' : 'summary'}',
83
+ // serializerKey: '${forAdmin ? 'adminSummary' : forInternal ? 'internalSummary' : 'summary'}',
80
84
  // fastJsonStringify: true,
81
85
  // })
82
86
  public async index() {
@@ -183,22 +187,22 @@ export default function generateControllerContent({ ancestorName, ancestorImport
183
187
  ${omitOpenApi ? '' : openApiImport + '\n'}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}
184
188
 
185
189
  export default class ${controllerClassName} extends ${ancestorName} {
186
- ${methodDefs.join('\n\n')}${modelClassName ? privateMethods(forAdmin, modelClassName, actions, loadQueryBase, singular) : ''}
190
+ ${methodDefs.join('\n\n')}${modelClassName ? privateMethods(forAdmin, forInternal, modelClassName, actions, loadQueryBase, singular) : ''}
187
191
  }
188
192
  `;
189
193
  }
190
- function privateMethods(forAdmin, modelClassName, methods, loadQueryBase, singular) {
194
+ function privateMethods(forAdmin, forInternal, modelClassName, methods, loadQueryBase, singular) {
191
195
  const privateMethods = [];
192
196
  if (methods.find(methodName => ['show', 'update', 'destroy'].includes(methodName)))
193
- privateMethods.push(loadModelStatement(forAdmin, modelClassName, loadQueryBase, singular));
197
+ privateMethods.push(loadModelStatement(forAdmin, forInternal, modelClassName, loadQueryBase, singular));
194
198
  if (!privateMethods.length)
195
199
  return '';
196
200
  return `\n\n${privateMethods.join('\n\n')}`;
197
201
  }
198
- function loadModelStatement(forAdmin, modelClassName, loadQueryBase, singular) {
202
+ function loadModelStatement(forAdmin, forInternal, modelClassName, loadQueryBase, singular) {
199
203
  return ` private async ${camelize(modelClassName)}() {
200
204
  // return await ${loadQueryBase}
201
- // .preloadFor('${forAdmin ? 'admin' : 'default'}')
205
+ // .preloadFor('${forAdmin ? 'admin' : forInternal ? 'internal' : 'default'}')
202
206
  // ${singular ? '.firstOrFail()' : ".findOrFail(this.castParam('id', 'string'))"}
203
207
  }`;
204
208
  }
@@ -22,15 +22,16 @@ function createModelConfiguration(options) {
22
22
  const fullyQualifiedModelName = DreamApp.system.standardizeFullyQualifiedModelName(options.fullyQualifiedModelName);
23
23
  const modelClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedModelName);
24
24
  const modelVariableName = camelize(modelClassName);
25
- const userModelName = options.forAdmin ? 'AdminUser' : 'User';
26
- const userVariableName = options.forAdmin ? 'adminUser' : 'user';
25
+ const userModelName = options.forInternal ? 'InternalUser' : options.forAdmin ? 'AdminUser' : 'User';
26
+ const userVariableName = options.forInternal ? 'internalUser' : options.forAdmin ? 'adminUser' : 'user';
27
27
  const owningModelName = options.owningModel
28
28
  ? DreamApp.system.globalClassNameFromFullyQualifiedModelName(options.owningModel)
29
29
  : userModelName;
30
30
  const owningModelVariableName = options.owningModel
31
31
  ? camelize(DreamApp.system.standardizeFullyQualifiedModelName(options.owningModel).split('/').pop())
32
32
  : userVariableName;
33
- const simpleCreationCommand = `const ${modelVariableName} = await create${modelClassName}(${options.forAdmin ? '' : `{ ${owningModelVariableName} }`})`;
33
+ const useDirectModelAccess = (options.forAdmin || options.forInternal) && !options.owningModel;
34
+ const simpleCreationCommand = `const ${modelVariableName} = await create${modelClassName}(${useDirectModelAccess ? '' : `{ ${owningModelVariableName} }`})`;
34
35
  return {
35
36
  fullyQualifiedModelName,
36
37
  modelClassName,
@@ -40,6 +41,7 @@ function createModelConfiguration(options) {
40
41
  owningModelName,
41
42
  owningModelVariableName,
42
43
  simpleCreationCommand,
44
+ useDirectModelAccess,
43
45
  };
44
46
  }
45
47
  function createActionConfiguration(options) {
@@ -264,7 +266,7 @@ describe('${fullyQualifiedControllerName}', () => {
264
266
  function generateIndexActionSpec(options) {
265
267
  if (options.actionConfig.omitIndex)
266
268
  return '';
267
- const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, forAdmin } = options;
269
+ const { path, pathParams, modelConfig, fullyQualifiedModelName, singular } = options;
268
270
  const subjectFunctionName = `index${pluralize(modelConfig.modelClassName)}`;
269
271
  return `
270
272
 
@@ -283,7 +285,7 @@ function generateIndexActionSpec(options) {
283
285
  id: ${modelConfig.modelVariableName}.id,
284
286
  }),
285
287
  ])
286
- })${singular || forAdmin
288
+ })${singular || modelConfig.useDirectModelAccess
287
289
  ? ''
288
290
  : `
289
291
 
@@ -301,7 +303,7 @@ function generateIndexActionSpec(options) {
301
303
  function generateShowActionSpec(options) {
302
304
  if (options.actionConfig.omitShow)
303
305
  return '';
304
- const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, forAdmin, attributeData } = options;
306
+ const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, attributeData } = options;
305
307
  const subjectFunctionName = `show${modelConfig.modelClassName}`;
306
308
  const subjectFunction = singular
307
309
  ? `
@@ -326,7 +328,7 @@ function generateShowActionSpec(options) {
326
328
  id: ${modelConfig.modelVariableName}.id,${attributeData.comparableOriginalAttributeKeyValues.length ? '\n ' + attributeData.comparableOriginalAttributeKeyValues.join('\n ') : ''}
327
329
  }),
328
330
  )
329
- })${singular || forAdmin
331
+ })${singular || modelConfig.useDirectModelAccess
330
332
  ? ''
331
333
  : `
332
334
 
@@ -342,7 +344,7 @@ function generateShowActionSpec(options) {
342
344
  function generateCreateActionSpec(options) {
343
345
  if (options.actionConfig.omitCreate)
344
346
  return '';
345
- const { path, pathParams, modelConfig, fullyQualifiedModelName, forAdmin, singular, attributeData } = options;
347
+ const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, attributeData } = options;
346
348
  const subjectFunctionName = `create${modelConfig.modelClassName}`;
347
349
  const uuidSetup = attributeData.uuidAttributes
348
350
  .map(attrName => {
@@ -360,7 +362,7 @@ function generateCreateActionSpec(options) {
360
362
  ? `
361
363
  const now = DateTime.now()`
362
364
  : ''}${uuidSetup || attributeData.dateAttributeIncluded || attributeData.datetimeAttributeIncluded ? '\n' : ''}`;
363
- const modelQuery = forAdmin
365
+ const modelQuery = modelConfig.useDirectModelAccess
364
366
  ? `${modelConfig.modelClassName}.firstOrFail()`
365
367
  : `${modelConfig.owningModelVariableName}.associationQuery('${singular ? modelConfig.modelVariableName : pluralize(modelConfig.modelVariableName)}').firstOrFail()`;
366
368
  return `
@@ -375,7 +377,7 @@ function generateCreateActionSpec(options) {
375
377
  })
376
378
  }
377
379
 
378
- it('creates a ${fullyQualifiedModelName}${forAdmin ? '' : ` for this ${modelConfig.owningModelName}`}', async () => {${dateTimeSetup}
380
+ it('creates a ${fullyQualifiedModelName}${modelConfig.useDirectModelAccess ? '' : ` for this ${modelConfig.owningModelName}`}', async () => {${dateTimeSetup}
379
381
  const { body } = await ${subjectFunctionName}({
380
382
  ${attributeData.attributeCreationKeyValues.join('\n ')}
381
383
  }, 201)
@@ -393,7 +395,7 @@ function generateCreateActionSpec(options) {
393
395
  function generateUpdateActionSpec(options) {
394
396
  if (options.actionConfig.omitUpdate)
395
397
  return '';
396
- const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, forAdmin, attributeData } = options;
398
+ const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, attributeData } = options;
397
399
  const subjectFunctionName = `update${modelConfig.modelClassName}`;
398
400
  const uuidSetup = attributeData.uuidAttributes
399
401
  .map(attrName => {
@@ -434,7 +436,7 @@ function generateUpdateActionSpec(options) {
434
436
  data,
435
437
  })
436
438
  }`;
437
- const updateContextSpec = singular || forAdmin
439
+ const updateContextSpec = singular || modelConfig.useDirectModelAccess
438
440
  ? ''
439
441
  : `
440
442
 
@@ -488,7 +490,7 @@ function generateUpdateActionSpec(options) {
488
490
  function generateDestroyActionSpec(options) {
489
491
  if (options.actionConfig.omitDestroy)
490
492
  return '';
491
- const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, forAdmin } = options;
493
+ const { path, pathParams, modelConfig, fullyQualifiedModelName, singular } = options;
492
494
  const subjectFunctionName = `destroy${modelConfig.modelClassName}`;
493
495
  const subjectFunction = singular
494
496
  ? `
@@ -499,7 +501,7 @@ function generateDestroyActionSpec(options) {
499
501
  const ${subjectFunctionName} = async <StatusCode extends 204 | 400 | 404>(${modelConfig.modelVariableName}: ${modelConfig.modelClassName}, expectedStatus: StatusCode) => {
500
502
  return request.delete('/${path}/{id}', expectedStatus, ${formatPathParamsWithId(pathParams, modelConfig.modelVariableName)})
501
503
  }`;
502
- const destroyContextSpec = singular || forAdmin
504
+ const destroyContextSpec = singular || modelConfig.useDirectModelAccess
503
505
  ? ''
504
506
  : `
505
507
 
@@ -0,0 +1,36 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import { camelize } from '@rvoh/dream/utils';
3
+ import colorize from '../../../cli/helpers/colorize.js';
4
+ export default function printFinalStepsMessage(opts) {
5
+ const clientFile = `${opts.outputDir}/${camelize(opts.exportName)}.ts`;
6
+ const importLine = colorize(`+ import { ${opts.exportName} } from '${clientFile}'`, { color: 'green' });
7
+ DreamCLI.logger.log(`
8
+ Finished generating zustand + openapi-fetch bindings for your application,
9
+ but to wire them into your app, we're going to need your help.
10
+
11
+ First, you will need to be sure to sync, so that the new openapi
12
+ types are sent over to your client application.
13
+
14
+ Next, you can use the generated api client in your zustand stores, i.e.
15
+
16
+ ${importLine}
17
+
18
+ ${colorize(`+ import { create } from 'zustand'`, { color: 'green' })}
19
+
20
+ interface MyState {
21
+ items: Item[]
22
+ loading: boolean
23
+ fetchItems: () => Promise<void>
24
+ }
25
+
26
+ export const useMyStore = create<MyState>((set) => ({
27
+ items: [],
28
+ loading: false,
29
+ fetchItems: async () => {
30
+ set({ loading: true })
31
+ const { data } = await ${opts.exportName}.GET('/items')
32
+ set({ items: data ?? [], loading: false })
33
+ },
34
+ }))
35
+ `, { logPrefix: '' });
36
+ }
@@ -0,0 +1,55 @@
1
+ import { camelize } from '@rvoh/dream/utils';
2
+ import cliPrompt from '../../../cli/helpers/cli-prompt.js';
3
+ import PsychicApp from '../../../psychic-app/index.js';
4
+ export default async function promptForOptions(options) {
5
+ if (!options.schemaFile) {
6
+ const defaultVal = './src/openapi/openapi.json';
7
+ const answer = await cliPrompt(`\
8
+ What would you like the schemaFile to be?
9
+
10
+ The schemaFile is the openapi file that openapi-typescript will read to produce
11
+ all of its type definitions. If not provided, it will default to
12
+
13
+ ${defaultVal}
14
+ `);
15
+ options.schemaFile = answer || defaultVal;
16
+ }
17
+ if (!options.exportName) {
18
+ const defaultVal = `${camelize(PsychicApp.getOrFail().appName)}Api`;
19
+ const answer = await cliPrompt(`\
20
+ What would you like the exportName to be?
21
+
22
+ The exportName is used to name the typed openapi-fetch client instance that will
23
+ be generated for use in your zustand stores. We recommend naming it something like
24
+ the name of your app, i.e.
25
+
26
+ ${defaultVal}
27
+ `);
28
+ options.exportName = answer || defaultVal;
29
+ }
30
+ if (!options.outputDir) {
31
+ const defaultVal = '../client/src/api';
32
+ const answer = await cliPrompt(`\
33
+ What would you like the outputDir to be?
34
+
35
+ The outputDir is the directory where the generated api client and types files
36
+ will be written. If not provided, it will default to:
37
+
38
+ ${defaultVal}
39
+ `);
40
+ options.outputDir = answer || defaultVal;
41
+ }
42
+ if (!options.typesFile) {
43
+ const defaultVal = `${options.outputDir}/${camelize(options.exportName)}.types.d.ts`;
44
+ const answer = await cliPrompt(`\
45
+ What would you like the typesFile to be?
46
+
47
+ The typesFile is the path to the generated openapi TypeScript type definitions
48
+ that will be used by the api client. If not provided, it will default to:
49
+
50
+ ${defaultVal}
51
+ `);
52
+ options.typesFile = answer || defaultVal;
53
+ }
54
+ return options;
55
+ }
@@ -0,0 +1,50 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { camelize } from '@rvoh/dream/utils';
4
+ export default async function writeApiClientFile({ exportName, outputDir, typesFile, }) {
5
+ const destDir = outputDir;
6
+ const destPath = path.join(destDir, `${camelize(exportName)}.ts`);
7
+ try {
8
+ await fs.access(destPath);
9
+ return; // early return if the file already exists
10
+ }
11
+ catch {
12
+ // noop
13
+ }
14
+ try {
15
+ await fs.access(destDir);
16
+ }
17
+ catch {
18
+ await fs.mkdir(destDir, { recursive: true });
19
+ }
20
+ // compute the relative import path from the output file to the types file
21
+ const typesRelative = path.relative(destDir, typesFile).replace(/\.d\.ts$/, '.js');
22
+ const typesImportPath = typesRelative.startsWith('.') ? typesRelative : `./${typesRelative}`;
23
+ const contents = `\
24
+ import createClient from 'openapi-fetch'
25
+ import type { paths } from '${typesImportPath}'
26
+
27
+ function baseUrl() {
28
+ // add custom code here for determining your application's baseUrl
29
+ // this would generally be something different, depending on if you
30
+ // are in dev/test/production environments. For dev, you might want
31
+ // http://localhost:7777, while test may be http://localhost:7778, or
32
+ // some other port, depending on how you have your spec hooks configured.
33
+ // for production, it should be the real host for your application, i.e.
34
+ // https://myapi.com
35
+
36
+ return 'http://localhost:7777'
37
+ }
38
+
39
+ export const ${exportName} = createClient<paths>({
40
+ baseUrl: baseUrl(),
41
+ credentials: 'include',
42
+
43
+ // you may customize headers here, for example to add auth tokens:
44
+ // headers: {
45
+ // Authorization: \`Bearer \${getAuthToken()}\`,
46
+ // },
47
+ })
48
+ `;
49
+ await fs.writeFile(destPath, contents);
50
+ }
@@ -0,0 +1,46 @@
1
+ import { camelize, pascalize } from '@rvoh/dream/utils';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import psychicPath from '../../../helpers/path/psychicPath.js';
5
+ export default async function writeInitializer({ exportName, schemaFile, typesFile, }) {
6
+ const pascalized = pascalize(exportName);
7
+ const camelized = camelize(exportName);
8
+ const destDir = path.join(psychicPath('conf'), 'initializers', 'openapi');
9
+ const initializerFilename = `${camelized}.ts`;
10
+ const initializerPath = path.join(destDir, initializerFilename);
11
+ try {
12
+ await fs.access(initializerPath);
13
+ return; // early return if the file already exists
14
+ }
15
+ catch {
16
+ // noop
17
+ }
18
+ try {
19
+ await fs.access(destDir);
20
+ }
21
+ catch {
22
+ await fs.mkdir(destDir, { recursive: true });
23
+ }
24
+ const contents = `\
25
+ import { DreamCLI } from '@rvoh/dream/system'
26
+ import { PsychicApp } from '@rvoh/psychic'
27
+ import AppEnv from '../../AppEnv.js'
28
+
29
+ export default function initialize${pascalized}(psy: PsychicApp) {
30
+ psy.on('cli:sync', async () => {
31
+ if (AppEnv.isDevelopmentOrTest) {
32
+ DreamCLI.logger.logStartProgress(\`[${camelized}] syncing openapi types...\`)
33
+ await DreamCLI.spawn('npx openapi-typescript ${schemaFile} -o ${typesFile}', {
34
+ onStdout: message => {
35
+ DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
36
+ logPrefixColor: 'green',
37
+ })
38
+ },
39
+ })
40
+ DreamCLI.logger.logEndProgress()
41
+ }
42
+ })
43
+ }\
44
+ `;
45
+ await fs.writeFile(initializerPath, contents);
46
+ }
@@ -0,0 +1,27 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import PackageManager from '../../cli/helpers/PackageManager.js';
3
+ import printFinalStepsMessage from '../helpers/zustandBindings/printFinalStepsMessage.js';
4
+ import promptForOptions from '../helpers/zustandBindings/promptForOptions.js';
5
+ import writeApiClientFile from '../helpers/zustandBindings/writeApiClientFile.js';
6
+ import writeInitializer from '../helpers/zustandBindings/writeInitializer.js';
7
+ /**
8
+ * @internal
9
+ *
10
+ * used by the psychic CLI to generate boilerplate
11
+ * that can be used to integrate a specific openapi.json
12
+ * file with a client using zustand + openapi-fetch.
13
+ *
14
+ * * generates a typed openapi-fetch client file
15
+ * * generates an initializer, which taps into the sync hooks
16
+ * to automatically run openapi-typescript to keep types in sync
17
+ * * prints a helpful message, instructing devs on the final
18
+ * steps for hooking into the newly-generated api mechanisms
19
+ * within their client application's zustand stores.
20
+ */
21
+ export default async function generateOpenapiZustandBindings(options = {}) {
22
+ const opts = await promptForOptions(options);
23
+ await writeApiClientFile(opts);
24
+ await writeInitializer(opts);
25
+ await DreamCLI.spawn(PackageManager.add(['openapi-fetch', 'openapi-typescript'], { dev: true }));
26
+ printFinalStepsMessage(opts);
27
+ }
@@ -17,6 +17,7 @@ export default async function generateResource({ route, fullyQualifiedModelName,
17
17
  const resourcefulActions = options.singular ? [...SINGULAR_RESOURCE_ACTIONS] : [...RESOURCE_ACTIONS];
18
18
  const onlyActions = options.only?.split(',');
19
19
  const forAdmin = /^Admin\//.test(fullyQualifiedControllerName);
20
+ const forInternal = /^Internal\//.test(fullyQualifiedControllerName);
20
21
  await DreamCLI.generateDream({
21
22
  fullyQualifiedModelName,
22
23
  columnsWithTypes,
@@ -24,7 +25,9 @@ export default async function generateResource({ route, fullyQualifiedModelName,
24
25
  serializer: true,
25
26
  stiBaseSerializer: options.stiBaseSerializer,
26
27
  includeAdminSerializers: forAdmin,
28
+ includeInternalSerializers: forInternal,
27
29
  connectionName: options.connectionName,
30
+ modelName: options.modelName,
28
31
  },
29
32
  });
30
33
  await generateController({
@@ -98,6 +98,7 @@ export default class SerializerOpenapiRenderer {
98
98
  type: 'object',
99
99
  required: sort(uniq(requiredProperties.map(property => this.setCase(property)))),
100
100
  properties: sortObjectByKey(referencedSerializersAndAttributes.attributes),
101
+ additionalProperties: false,
101
102
  },
102
103
  };
103
104
  }
@@ -309,7 +309,11 @@ export default class OpenapiSegmentExpander {
309
309
  data.minProperties = objectBodySegment.minProperties;
310
310
  }
311
311
  let referencedSerializers = [];
312
- if (objectBodySegment.additionalProperties) {
312
+ if (objectBodySegment.additionalProperties === false) {
313
+ ;
314
+ data.additionalProperties = false;
315
+ }
316
+ else if (objectBodySegment.additionalProperties) {
313
317
  const results = this.recursivelyParseBody(objectBodySegment.additionalProperties);
314
318
  referencedSerializers = [...referencedSerializers, ...results.referencedSerializers];
315
319
  data.additionalProperties = results.openapi;