@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.
- package/dist/cjs/src/cli/index.js +22 -0
- package/dist/cjs/src/generate/controller.js +21 -11
- package/dist/cjs/src/generate/helpers/addResourceToRoutes.js +6 -0
- package/dist/cjs/src/generate/helpers/generateControllerContent.js +17 -13
- package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +16 -14
- package/dist/cjs/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +36 -0
- package/dist/cjs/src/generate/helpers/zustandBindings/promptForOptions.js +55 -0
- package/dist/cjs/src/generate/helpers/zustandBindings/writeApiClientFile.js +50 -0
- package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
- package/dist/cjs/src/generate/openapi/zustandBindings.js +27 -0
- package/dist/cjs/src/generate/resource.js +3 -0
- package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
- package/dist/cjs/src/openapi-renderer/body-segment.js +5 -1
- package/dist/esm/src/cli/index.js +22 -0
- package/dist/esm/src/generate/controller.js +21 -11
- package/dist/esm/src/generate/helpers/addResourceToRoutes.js +6 -0
- package/dist/esm/src/generate/helpers/generateControllerContent.js +17 -13
- package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +16 -14
- package/dist/esm/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +36 -0
- package/dist/esm/src/generate/helpers/zustandBindings/promptForOptions.js +55 -0
- package/dist/esm/src/generate/helpers/zustandBindings/writeApiClientFile.js +50 -0
- package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
- package/dist/esm/src/generate/openapi/zustandBindings.js +27 -0
- package/dist/esm/src/generate/resource.js +3 -0
- package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
- package/dist/esm/src/openapi-renderer/body-segment.js +5 -1
- package/dist/types/src/bin/index.d.ts +1 -0
- package/dist/types/src/cli/index.d.ts +1 -0
- package/dist/types/src/generate/helpers/generateControllerContent.d.ts +2 -1
- package/dist/types/src/generate/helpers/generateResourceControllerSpecContent.d.ts +1 -0
- package/dist/types/src/generate/helpers/zustandBindings/printFinalStepsMessage.d.ts +2 -0
- package/dist/types/src/generate/helpers/zustandBindings/promptForOptions.d.ts +2 -0
- package/dist/types/src/generate/helpers/zustandBindings/writeApiClientFile.d.ts +5 -0
- package/dist/types/src/generate/helpers/zustandBindings/writeInitializer.d.ts +5 -0
- package/dist/types/src/generate/openapi/zustandBindings.d.ts +21 -0
- package/dist/types/src/generate/resource.d.ts +1 -0
- 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
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
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 ||
|
|
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,
|
|
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 ||
|
|
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,
|
|
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 =
|
|
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}${
|
|
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,
|
|
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 ||
|
|
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
|
|
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 ||
|
|
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;
|