@amerilux/netsuite-api 0.1.0 → 0.2.0
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/README.md +42 -22
- package/dist/client/apiClient.d.ts +33 -6
- package/dist/client/apiClient.js +60 -30
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +2 -2
- package/dist-tooling/cli/main.js +9 -7
- package/dist-tooling/config.d.ts +15 -10
- package/dist-tooling/config.js +6 -6
- package/dist-tooling/controllerReader.d.ts +30 -9
- package/dist-tooling/controllerReader.js +29 -14
- package/dist-tooling/emit.d.ts +24 -7
- package/dist-tooling/emit.js +54 -24
- package/dist-tooling/file-system.d.ts +2 -0
- package/dist-tooling/file-system.js +4 -0
- package/dist-tooling/generate.d.ts +4 -1
- package/dist-tooling/generate.js +68 -40
- package/dist-tooling/index.d.ts +6 -4
- package/dist-tooling/index.js +3 -2
- package/dist-tooling/typesFileReader.d.ts +31 -0
- package/dist-tooling/typesFileReader.js +97 -0
- package/package.json +1 -1
- package/dist-tooling/scriptsReader.d.ts +0 -17
- package/dist-tooling/scriptsReader.js +0 -57
|
@@ -3,8 +3,12 @@ import ts from 'typescript';
|
|
|
3
3
|
import { toPosixPath } from './file-system.js';
|
|
4
4
|
/** The return type a handler writes, exactly, to answer with a document instead of the envelope. */
|
|
5
5
|
export const RAW_RESPONSE_TYPE_NAME = 'RawResponse';
|
|
6
|
+
/** The type every generated controller module declares for its endpoint signatures: `user.Endpoints`. */
|
|
7
|
+
export const GENERATED_ENDPOINTS_TYPE_NAME = 'Endpoints';
|
|
8
|
+
/** The client every generated browser-facing controller module exports: `user.api`. */
|
|
9
|
+
export const GENERATED_CLIENT_NAME = 'api';
|
|
6
10
|
const controllerFileNamePattern = /^([a-z][A-Za-z0-9]*)Controller\.ts$/;
|
|
7
|
-
const siblingControllerSpecifierPattern = /^\.\/[a-z][A-Za-z0-9]*Controller(?:\.js|\.ts)?$/;
|
|
11
|
+
const siblingControllerSpecifierPattern = /^\.\/([a-z][A-Za-z0-9]*)Controller(?:\.js|\.ts)?$/;
|
|
8
12
|
const entryPointByFunction = {
|
|
9
13
|
defineRestlet: { kind: 'restlet', exportName: 'post', header: 'Restlet' },
|
|
10
14
|
defineSuitelet: { kind: 'suitelet', exportName: 'onRequest', header: 'Suitelet' },
|
|
@@ -12,9 +16,6 @@ const entryPointByFunction = {
|
|
|
12
16
|
export function isControllerFileName(fileName) {
|
|
13
17
|
return controllerFileNamePattern.test(fileName);
|
|
14
18
|
}
|
|
15
|
-
export function toPascalCase(name) {
|
|
16
|
-
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
17
|
-
}
|
|
18
19
|
function hasExportModifier(node) {
|
|
19
20
|
return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
20
21
|
}
|
|
@@ -51,7 +52,7 @@ function readTypeImport(statement, filePath, options) {
|
|
|
51
52
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
52
53
|
for (const element of bindings.elements) {
|
|
53
54
|
if (clause.isTypeOnly || element.isTypeOnly)
|
|
54
|
-
names.push(element.propertyName ?
|
|
55
|
+
names.push(element.propertyName ? { name: element.propertyName.text, alias: element.name.text } : { name: element.name.text });
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
if (clause.name && clause.isTypeOnly) {
|
|
@@ -59,13 +60,17 @@ function readTypeImport(statement, filePath, options) {
|
|
|
59
60
|
}
|
|
60
61
|
if (names.length === 0)
|
|
61
62
|
return { problems };
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const sibling = siblingControllerSpecifierPattern.exec(moduleSpecifier);
|
|
64
|
+
if (sibling)
|
|
65
|
+
return { read: { kind: 'controller', typeImport: { controllerName: sibling[1], names } }, problems };
|
|
66
|
+
if (options.inlineTypes[moduleSpecifier] !== undefined)
|
|
67
|
+
return { read: { kind: 'inlined', typeImport: { specifier: moduleSpecifier, names } }, problems };
|
|
64
68
|
const clientSpecifier = options.typeImports[moduleSpecifier];
|
|
65
69
|
if (clientSpecifier !== undefined)
|
|
66
|
-
return { typeImport: { moduleSpecifier: clientSpecifier, names }, problems };
|
|
67
|
-
const allowed = Object.keys(options.typeImports).map((specifier) => `'${specifier}'`).join(', ');
|
|
68
|
-
|
|
70
|
+
return { read: { kind: 'carried', typeImport: { moduleSpecifier: clientSpecifier, names } }, problems };
|
|
71
|
+
const allowed = [...Object.keys(options.inlineTypes), ...Object.keys(options.typeImports)].map((specifier) => `'${specifier}'`).join(', ');
|
|
72
|
+
const written = names.map((imported) => (imported.alias ? `${imported.name} as ${imported.alias}` : imported.name)).join(', ');
|
|
73
|
+
problems.push({ filePath, message: `type ${written} is imported from '${moduleSpecifier}'; a controller's wire shapes may only take types from ${allowed || 'the configured inlineTypes and typeImports'} or from another controller.` });
|
|
69
74
|
return { problems };
|
|
70
75
|
}
|
|
71
76
|
function isTypeQueryAlias(statement) {
|
|
@@ -190,7 +195,9 @@ export function readControllerContract(filePath, source, options) {
|
|
|
190
195
|
const name = nameMatch[1];
|
|
191
196
|
const endpointsConstantName = `${name}Endpoints`;
|
|
192
197
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
193
|
-
const
|
|
198
|
+
const carriedTypeImports = [];
|
|
199
|
+
const inlinedTypeImports = [];
|
|
200
|
+
const controllerTypeImports = [];
|
|
194
201
|
const typeDeclarations = [];
|
|
195
202
|
const endpoints = [];
|
|
196
203
|
let endpointsFound = false;
|
|
@@ -200,8 +207,12 @@ export function readControllerContract(filePath, source, options) {
|
|
|
200
207
|
if (ts.isImportDeclaration(statement)) {
|
|
201
208
|
const result = readTypeImport(statement, filePath, options);
|
|
202
209
|
problems.push(...result.problems);
|
|
203
|
-
if (result.
|
|
204
|
-
|
|
210
|
+
if (result.read?.kind === 'carried')
|
|
211
|
+
carriedTypeImports.push(result.read.typeImport);
|
|
212
|
+
else if (result.read?.kind === 'inlined')
|
|
213
|
+
inlinedTypeImports.push(result.read.typeImport);
|
|
214
|
+
else if (result.read?.kind === 'controller')
|
|
215
|
+
controllerTypeImports.push(result.read.typeImport);
|
|
205
216
|
}
|
|
206
217
|
else if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) {
|
|
207
218
|
if (isTypeQueryAlias(statement)) {
|
|
@@ -215,6 +226,10 @@ export function readControllerContract(filePath, source, options) {
|
|
|
215
226
|
problems.push({ filePath, message: `'${statement.name.text}' is not exported; every type in a controller is a wire shape, so export it (or move it below the controller).` });
|
|
216
227
|
continue;
|
|
217
228
|
}
|
|
229
|
+
if (statement.name.text === GENERATED_ENDPOINTS_TYPE_NAME) {
|
|
230
|
+
problems.push({ filePath, message: `type '${GENERATED_ENDPOINTS_TYPE_NAME}' is the name the generated module gives the endpoint signatures; call the wire shape something else.` });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
218
233
|
const jsDoc = readLeadingJsDoc(statement, sourceFile);
|
|
219
234
|
typeDeclarations.push({ name: statement.name.text, text: `${jsDoc ? `${jsDoc}\n` : ''}${statement.getText(sourceFile)}` });
|
|
220
235
|
}
|
|
@@ -261,7 +276,7 @@ export function readControllerContract(filePath, source, options) {
|
|
|
261
276
|
if (problems.length > 0 || !script)
|
|
262
277
|
return { problems };
|
|
263
278
|
return {
|
|
264
|
-
contract: { name, filePath,
|
|
279
|
+
contract: { name, filePath, script, carriedTypeImports, inlinedTypeImports, controllerTypeImports, typeDeclarations, endpoints },
|
|
265
280
|
problems,
|
|
266
281
|
};
|
|
267
282
|
}
|
package/dist-tooling/emit.d.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import type { ControllerContract } from './controllerReader.js';
|
|
1
|
+
import type { ControllerContract, TypeDeclaration } from './controllerReader.js';
|
|
2
2
|
/**
|
|
3
|
-
* Writes the generated modules.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Writes the generated modules. Each controller gets its own client module, `<name>.gen.ts`: the
|
|
4
|
+
* entity types it names (copied in, so the module stands on its own), its wire shapes, its endpoint
|
|
5
|
+
* type and, when the browser calls it, its client. The index module re-exports each one as a
|
|
6
|
+
* namespace, so a hook writes `user.api.roles()` and names a shape as `user.RolesResponse`. The
|
|
7
|
+
* scripts module is the server-side map a repository passes to createSuiteletClient.
|
|
7
8
|
*/
|
|
9
|
+
/** The declarations copied from one inlined type file. */
|
|
10
|
+
export interface InlinedTypeSection {
|
|
11
|
+
/** Where the declarations come from: the file's path relative to the project. */
|
|
12
|
+
sourceLabel: string;
|
|
13
|
+
declarations: TypeDeclaration[];
|
|
14
|
+
}
|
|
8
15
|
export interface EmittedController {
|
|
9
16
|
contract: ControllerContract;
|
|
10
17
|
/** Where the file's header points a reader: the controller's path relative to the project. */
|
|
11
18
|
sourceLabel: string;
|
|
19
|
+
inlinedTypes: InlinedTypeSection[];
|
|
12
20
|
}
|
|
13
|
-
export interface
|
|
21
|
+
export interface EmitControllerModuleOptions {
|
|
14
22
|
clientModule: string;
|
|
23
|
+
}
|
|
24
|
+
export interface EmitClientIndexModuleOptions {
|
|
15
25
|
/** How the header names the sources: `api/src/controllers`. */
|
|
16
26
|
controllersLabel: string;
|
|
17
27
|
}
|
|
@@ -23,7 +33,14 @@ export interface EmitAppModuleOptions {
|
|
|
23
33
|
/** How the header names the app file: `netsuite.ts`. */
|
|
24
34
|
appLabel: string;
|
|
25
35
|
}
|
|
26
|
-
|
|
36
|
+
/** The index module of the client, next to the controller modules: what a hook imports. */
|
|
37
|
+
export declare const CLIENT_INDEX_FILE_NAME = "index.gen.ts";
|
|
38
|
+
/** The client module of a controller: `user.gen.ts` for the user controller. */
|
|
39
|
+
export declare function controllerModuleFileName(controllerName: string): string;
|
|
40
|
+
export declare function emitControllerModule({ contract, sourceLabel, inlinedTypes }: EmittedController, options: EmitControllerModuleOptions): string;
|
|
41
|
+
export declare function sortControllers(controllers: EmittedController[]): EmittedController[];
|
|
42
|
+
/** The index module: every controller module re-exported under the controller's name. */
|
|
43
|
+
export declare function emitClientIndexModule(controllers: EmittedController[], options: EmitClientIndexModuleOptions): string;
|
|
27
44
|
/** The app file as the client sees it: its exported declarations, verbatim. */
|
|
28
45
|
export declare function emitAppModule(appDeclarations: string[], options: EmitAppModuleOptions): string;
|
|
29
46
|
export declare function emitScriptsModule(controllers: EmittedController[], options: EmitScriptsModuleOptions): string;
|
package/dist-tooling/emit.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME } from './controllerReader.js';
|
|
2
|
+
/** The index module of the client, next to the controller modules: what a hook imports. */
|
|
3
|
+
export const CLIENT_INDEX_FILE_NAME = 'index.gen.ts';
|
|
4
|
+
/** The client module of a controller: `user.gen.ts` for the user controller. */
|
|
5
|
+
export function controllerModuleFileName(controllerName) {
|
|
6
|
+
return `${controllerName}.gen.ts`;
|
|
7
|
+
}
|
|
1
8
|
const INDENT = ' ';
|
|
2
9
|
const EDIT_NOTICE = 'Do not edit: change the controller and run `npm run generate`.';
|
|
10
|
+
const ESLINT_DISABLE = '/* eslint-disable */';
|
|
3
11
|
function indentJsDoc(jsDoc) {
|
|
4
12
|
return jsDoc
|
|
5
13
|
.split('\n')
|
|
@@ -11,54 +19,76 @@ function emitEndpointMember(endpoint) {
|
|
|
11
19
|
const member = `${INDENT}${endpoint.name}: (${parameter}) => ${endpoint.responseType};`;
|
|
12
20
|
return endpoint.jsDoc ? `${indentJsDoc(endpoint.jsDoc)}\n${member}` : member;
|
|
13
21
|
}
|
|
14
|
-
function
|
|
22
|
+
function formatImportName(imported) {
|
|
23
|
+
return imported.alias ? `${imported.name} as ${imported.alias}` : imported.name;
|
|
24
|
+
}
|
|
25
|
+
/** One `import type` per module, names merged and sorted, modules sorted. */
|
|
26
|
+
function emitTypeImports(imports) {
|
|
15
27
|
const namesByModule = new Map();
|
|
16
|
-
for (const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
namesByModule.set(typeImport.moduleSpecifier, names);
|
|
21
|
-
}
|
|
28
|
+
for (const typeImport of imports) {
|
|
29
|
+
const names = namesByModule.get(typeImport.moduleSpecifier) ?? new Set();
|
|
30
|
+
typeImport.names.forEach((imported) => names.add(formatImportName(imported)));
|
|
31
|
+
namesByModule.set(typeImport.moduleSpecifier, names);
|
|
22
32
|
}
|
|
23
33
|
return Array.from(namesByModule.keys())
|
|
24
34
|
.sort()
|
|
25
35
|
.map((moduleSpecifier) => `import type { ${Array.from(namesByModule.get(moduleSpecifier) ?? []).sort().join(', ')} } from '${moduleSpecifier}';`);
|
|
26
36
|
}
|
|
27
|
-
/** The script reference as a literal, as the
|
|
37
|
+
/** The script reference as a literal, as the controller module and the scripts module write it. */
|
|
28
38
|
function emitScriptRef(script) {
|
|
29
39
|
const browser = script.browser ? '' : ', browser: false';
|
|
30
40
|
return `{ kind: '${script.kind}', scriptId: '${script.scriptId}', deployId: '${script.deployId}'${browser} }`;
|
|
31
41
|
}
|
|
32
|
-
function
|
|
42
|
+
export function emitControllerModule({ contract, sourceLabel, inlinedTypes }, options) {
|
|
33
43
|
const browser = contract.script.browser;
|
|
34
|
-
const
|
|
44
|
+
const header = [
|
|
45
|
+
`// Generated by netsuite-api generate from ${sourceLabel}. ${EDIT_NOTICE}`,
|
|
46
|
+
...(browser ? [] : ['// Called by server code only: its types, and no client.']),
|
|
47
|
+
ESLINT_DISABLE,
|
|
48
|
+
];
|
|
49
|
+
const imports = [
|
|
50
|
+
...(browser ? [`import { createApiClient } from '${options.clientModule}';`] : []),
|
|
51
|
+
...emitTypeImports([
|
|
52
|
+
...contract.carriedTypeImports,
|
|
53
|
+
...contract.controllerTypeImports.map((typeImport) => ({ moduleSpecifier: `./${controllerModuleFileName(typeImport.controllerName).replace(/\.ts$/, '')}`, names: typeImport.names })),
|
|
54
|
+
]),
|
|
55
|
+
];
|
|
56
|
+
const sections = [header.join('\n'), imports.join('\n')];
|
|
57
|
+
for (const section of inlinedTypes) {
|
|
58
|
+
sections.push(`// Entity types from ${section.sourceLabel}, copied so this module stands on its own.`);
|
|
59
|
+
for (const declaration of section.declarations)
|
|
60
|
+
sections.push(declaration.text);
|
|
61
|
+
}
|
|
35
62
|
for (const declaration of contract.typeDeclarations)
|
|
36
63
|
sections.push(declaration.text);
|
|
37
64
|
const members = contract.endpoints.map(emitEndpointMember);
|
|
38
65
|
// A type alias, not an interface: only an object type literal satisfies the Endpoints index signature.
|
|
39
|
-
sections.push(
|
|
66
|
+
sections.push(`/** The endpoint signatures of the ${contract.name} controller, as its handlers declare them. */\nexport type ${GENERATED_ENDPOINTS_TYPE_NAME} = {\n${members.join('\n')}\n};`);
|
|
40
67
|
if (browser) {
|
|
41
68
|
const rawEndpoints = contract.endpoints.filter((endpoint) => endpoint.raw).map((endpoint) => `'${endpoint.name}'`);
|
|
42
69
|
const clientOptions = rawEndpoints.length > 0 ? `, { rawEndpoints: [${rawEndpoints.join(', ')}] }` : '';
|
|
43
|
-
sections.push(`/** One typed function per endpoint of the ${contract.name} controller: \`${contract.
|
|
44
|
-
`export const ${
|
|
70
|
+
sections.push(`/** One typed function per endpoint of the ${contract.name} controller: \`${contract.name}.${GENERATED_CLIENT_NAME}.${contract.endpoints[0]?.name ?? 'endpoint'}(...)\`. */\n` +
|
|
71
|
+
`export const ${GENERATED_CLIENT_NAME} = createApiClient<${GENERATED_ENDPOINTS_TYPE_NAME}>(${emitScriptRef({ ...contract.script, browser: true })}${clientOptions});`);
|
|
45
72
|
}
|
|
46
|
-
return sections.join('\n\n')
|
|
73
|
+
return `${sections.filter((section) => section !== '').join('\n\n')}\n`;
|
|
47
74
|
}
|
|
48
|
-
function sortControllers(controllers) {
|
|
75
|
+
export function sortControllers(controllers) {
|
|
49
76
|
return [...controllers].sort((left, right) => left.contract.name.localeCompare(right.contract.name));
|
|
50
77
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
/** The index module: every controller module re-exported under the controller's name. */
|
|
79
|
+
export function emitClientIndexModule(controllers, options) {
|
|
80
|
+
const lines = [`// Generated by netsuite-api generate from ${options.controllersLabel}. Do not edit: change the controllers and run \`npm run generate\`.`, ESLINT_DISABLE, ''];
|
|
81
|
+
for (const { contract } of sortControllers(controllers)) {
|
|
82
|
+
const summary = contract.script.browser
|
|
83
|
+
? `The ${contract.name} controller: its request and response types, and \`${contract.name}.${GENERATED_CLIENT_NAME}\`, one typed function per endpoint.`
|
|
84
|
+
: `The ${contract.name} controller: its request and response types only; server code calls it, the browser does not.`;
|
|
85
|
+
lines.push(`/** ${summary} */`, `export * as ${contract.name} from './${controllerModuleFileName(contract.name).replace(/\.ts$/, '')}';`);
|
|
86
|
+
}
|
|
87
|
+
return `${lines.join('\n')}\n`;
|
|
58
88
|
}
|
|
59
89
|
/** The app file as the client sees it: its exported declarations, verbatim. */
|
|
60
90
|
export function emitAppModule(appDeclarations, options) {
|
|
61
|
-
const header = [`// Generated by netsuite-api generate from ${options.appLabel}. Do not edit: change ${options.appLabel} and run \`npm run generate\`.`,
|
|
91
|
+
const header = [`// Generated by netsuite-api generate from ${options.appLabel}. Do not edit: change ${options.appLabel} and run \`npm run generate\`.`, ESLINT_DISABLE];
|
|
62
92
|
return `${[header.join('\n'), ...appDeclarations].join('\n\n')}\n`;
|
|
63
93
|
}
|
|
64
94
|
export function emitScriptsModule(controllers, options) {
|
|
@@ -66,7 +96,7 @@ export function emitScriptsModule(controllers, options) {
|
|
|
66
96
|
const entries = ordered.map(({ contract }) => `${INDENT}${contract.name}: ${emitScriptRef(contract.script)},`);
|
|
67
97
|
return [
|
|
68
98
|
`// Generated by netsuite-api generate from ${options.controllersLabel}. ${EDIT_NOTICE}`,
|
|
69
|
-
|
|
99
|
+
ESLINT_DISABLE,
|
|
70
100
|
'',
|
|
71
101
|
`import type { ScriptRef } from '${options.wireModule}';`,
|
|
72
102
|
'',
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export interface FileSystemAdapter {
|
|
3
3
|
readTextFile(filePath: string): string;
|
|
4
4
|
writeTextFile(filePath: string, content: string): void;
|
|
5
|
+
/** Removes a file; a file that does not exist is nothing to do. */
|
|
6
|
+
deleteFile(filePath: string): void;
|
|
5
7
|
fileExists(filePath: string): boolean;
|
|
6
8
|
ensureDirectory(directoryPath: string): void;
|
|
7
9
|
/** Lists the absolute paths of the files directly inside a directory. Returns [] when it does not exist. */
|
|
@@ -7,6 +7,7 @@ export function createNodeFileSystemAdapter() {
|
|
|
7
7
|
return {
|
|
8
8
|
readTextFile: (filePath) => nodeFileSystem.readFileSync(filePath, 'utf8'),
|
|
9
9
|
writeTextFile: (filePath, content) => nodeFileSystem.writeFileSync(filePath, content, 'utf8'),
|
|
10
|
+
deleteFile: (filePath) => nodeFileSystem.rmSync(filePath, { force: true }),
|
|
10
11
|
fileExists: (filePath) => nodeFileSystem.existsSync(filePath),
|
|
11
12
|
ensureDirectory: (directoryPath) => nodeFileSystem.mkdirSync(directoryPath, { recursive: true }),
|
|
12
13
|
listFiles: (directoryPath) => {
|
|
@@ -39,6 +40,9 @@ export function createInMemoryFileSystemAdapter(initialFiles = {}) {
|
|
|
39
40
|
writeTextFile: (filePath, content) => {
|
|
40
41
|
files.set(normalizeKey(filePath), content);
|
|
41
42
|
},
|
|
43
|
+
deleteFile: (filePath) => {
|
|
44
|
+
files.delete(normalizeKey(filePath));
|
|
45
|
+
},
|
|
42
46
|
fileExists: (filePath) => files.has(normalizeKey(filePath)) || directories.has(normalizeKey(filePath)),
|
|
43
47
|
ensureDirectory: (directoryPath) => {
|
|
44
48
|
directories.add(normalizeKey(directoryPath));
|
|
@@ -18,14 +18,17 @@ export interface PlannedFile {
|
|
|
18
18
|
content: string;
|
|
19
19
|
}
|
|
20
20
|
export interface ClientGenerationPlan {
|
|
21
|
-
/** The modules to write
|
|
21
|
+
/** The modules to write: one per controller, the client index, the app module, the scripts map; empty when there are problems. */
|
|
22
22
|
files: PlannedFile[];
|
|
23
|
+
/** Generated files in the client's output directory the plan does not write: a removed controller's module, or the copy of the entity types an earlier version made. Absolute paths. */
|
|
24
|
+
leftoverFiles: string[];
|
|
23
25
|
controllers: PlannedController[];
|
|
24
26
|
problems: ControllerProblem[];
|
|
25
27
|
}
|
|
26
28
|
export interface ClientGenerationResult extends ClientGenerationPlan {
|
|
27
29
|
writtenFiles: string[];
|
|
28
30
|
unchangedFiles: string[];
|
|
31
|
+
deletedFiles: string[];
|
|
29
32
|
}
|
|
30
33
|
export interface ClientGenerationCheck extends ClientGenerationPlan {
|
|
31
34
|
missingFiles: string[];
|
package/dist-tooling/generate.js
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
1
1
|
import * as nodePath from 'node:path';
|
|
2
2
|
import { readAppDeclarations } from './appReader.js';
|
|
3
3
|
import { isControllerFileName, readControllerContract } from './controllerReader.js';
|
|
4
|
-
import { emitAppModule,
|
|
4
|
+
import { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule, sortControllers } from './emit.js';
|
|
5
5
|
import { toPosixPath } from './file-system.js';
|
|
6
|
+
import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
|
|
7
|
+
const generatedFileNamePattern = /\.gen\.ts$/;
|
|
6
8
|
function relativeLabel(rootDirectory, filePath) {
|
|
7
9
|
return toPosixPath(nodePath.relative(rootDirectory, filePath));
|
|
8
10
|
}
|
|
9
|
-
function findDuplicateNames(controllers) {
|
|
10
|
-
const owners = new Map();
|
|
11
|
-
const problems = [];
|
|
12
|
-
const claim = (name, filePath, what) => {
|
|
13
|
-
const owner = owners.get(name);
|
|
14
|
-
if (owner !== undefined && owner !== filePath) {
|
|
15
|
-
problems.push({ filePath, message: `${what} '${name}' is also declared by ${owner}; the generated module holds every controller, so names are unique across them.` });
|
|
16
|
-
}
|
|
17
|
-
else {
|
|
18
|
-
owners.set(name, filePath);
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
for (const { contract } of controllers) {
|
|
22
|
-
for (const declaration of contract.typeDeclarations)
|
|
23
|
-
claim(declaration.name, contract.filePath, 'type');
|
|
24
|
-
claim(contract.endpointsTypeName, contract.filePath, 'type');
|
|
25
|
-
claim(contract.clientName, contract.filePath, 'client');
|
|
26
|
-
}
|
|
27
|
-
return problems;
|
|
28
|
-
}
|
|
29
11
|
function findDuplicateScriptIds(controllers) {
|
|
30
12
|
const problems = [];
|
|
31
13
|
for (const property of ['scriptId', 'deployId']) {
|
|
@@ -41,24 +23,70 @@ function findDuplicateScriptIds(controllers) {
|
|
|
41
23
|
}
|
|
42
24
|
return problems;
|
|
43
25
|
}
|
|
26
|
+
/** Every generated file in the output directory that is not one of the given paths. */
|
|
27
|
+
function findLeftoverFiles(fileSystem, outDirectory, plannedPaths) {
|
|
28
|
+
const planned = new Set(plannedPaths.map((filePath) => toPosixPath(nodePath.resolve(filePath))));
|
|
29
|
+
return fileSystem
|
|
30
|
+
.listFiles(outDirectory)
|
|
31
|
+
.filter((filePath) => generatedFileNamePattern.test(nodePath.basename(filePath)) && !planned.has(toPosixPath(nodePath.resolve(filePath))));
|
|
32
|
+
}
|
|
44
33
|
export function planClientGeneration({ config, fileSystem }) {
|
|
45
34
|
const resolve = (relativePath) => nodePath.resolve(config.rootDirectory, relativePath);
|
|
46
35
|
const label = (absolutePath) => relativeLabel(config.rootDirectory, absolutePath);
|
|
47
36
|
const controllersDirectory = resolve(config.controllers);
|
|
48
37
|
const appFile = resolve(config.appFile);
|
|
38
|
+
const outDirectory = resolve(config.outDir);
|
|
49
39
|
const problems = [];
|
|
50
40
|
const controllerFiles = fileSystem.listFiles(controllersDirectory).filter((filePath) => isControllerFileName(nodePath.basename(filePath)));
|
|
51
41
|
if (controllerFiles.length === 0) {
|
|
52
42
|
problems.push({ filePath: label(controllersDirectory), message: 'holds no <name>Controller.ts file.' });
|
|
53
43
|
}
|
|
44
|
+
const controllerNames = new Set(controllerFiles.map((filePath) => nodePath.basename(filePath).replace(/Controller\.ts$/, '')));
|
|
45
|
+
const inlinableFiles = new Map();
|
|
46
|
+
const readInlinable = (specifier) => {
|
|
47
|
+
if (inlinableFiles.has(specifier))
|
|
48
|
+
return inlinableFiles.get(specifier);
|
|
49
|
+
const filePath = resolve(config.inlineTypes[specifier]);
|
|
50
|
+
let file;
|
|
51
|
+
if (fileSystem.fileExists(filePath))
|
|
52
|
+
file = readInlinableTypesFile(label(filePath), fileSystem.readTextFile(filePath));
|
|
53
|
+
else
|
|
54
|
+
problems.push({ filePath: label(filePath), message: 'the file to copy types from does not exist; run the model generator first.' });
|
|
55
|
+
inlinableFiles.set(specifier, file);
|
|
56
|
+
return file;
|
|
57
|
+
};
|
|
54
58
|
const emitted = [];
|
|
55
59
|
for (const filePath of controllerFiles) {
|
|
56
|
-
const
|
|
60
|
+
const controllerLabel = label(filePath);
|
|
61
|
+
const result = readControllerContract(controllerLabel, fileSystem.readTextFile(filePath), { typeImports: config.typeImports, inlineTypes: config.inlineTypes });
|
|
57
62
|
problems.push(...result.problems);
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
const contract = result.contract;
|
|
64
|
+
if (!contract)
|
|
65
|
+
continue;
|
|
66
|
+
if (controllerModuleFileName(contract.name) === CLIENT_INDEX_FILE_NAME) {
|
|
67
|
+
problems.push({ filePath: controllerLabel, message: `a controller named '${contract.name}' would take the client's ${CLIENT_INDEX_FILE_NAME}; name it something else.` });
|
|
68
|
+
}
|
|
69
|
+
for (const typeImport of contract.controllerTypeImports) {
|
|
70
|
+
if (!controllerNames.has(typeImport.controllerName)) {
|
|
71
|
+
problems.push({ filePath: controllerLabel, message: `imports types from './${typeImport.controllerName}Controller', which is not a controller in ${label(controllersDirectory)}.` });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const inlinedTypes = [];
|
|
75
|
+
for (const typeImport of contract.inlinedTypeImports) {
|
|
76
|
+
const file = readInlinable(typeImport.specifier);
|
|
77
|
+
if (!file)
|
|
78
|
+
continue;
|
|
79
|
+
const selected = selectInlinedTypes(file, typeImport.names, controllerLabel);
|
|
80
|
+
problems.push(...selected.problems);
|
|
81
|
+
const section = inlinedTypes.find((existing) => existing.sourceLabel === file.filePath);
|
|
82
|
+
if (section)
|
|
83
|
+
section.declarations.push(...selected.declarations.filter((declaration) => !section.declarations.some((existing) => existing.name === declaration.name)));
|
|
84
|
+
else
|
|
85
|
+
inlinedTypes.push({ sourceLabel: file.filePath, declarations: selected.declarations });
|
|
86
|
+
}
|
|
87
|
+
emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
|
|
60
88
|
}
|
|
61
|
-
problems.push(...
|
|
89
|
+
problems.push(...findDuplicateScriptIds(emitted));
|
|
62
90
|
let appDeclarations = [];
|
|
63
91
|
if (fileSystem.fileExists(appFile)) {
|
|
64
92
|
const app = readAppDeclarations(label(appFile), fileSystem.readTextFile(appFile));
|
|
@@ -68,15 +96,6 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
68
96
|
else {
|
|
69
97
|
problems.push({ filePath: label(appFile), message: 'the app file does not exist.' });
|
|
70
98
|
}
|
|
71
|
-
const copies = [];
|
|
72
|
-
for (const [source, destination] of Object.entries(config.copyFiles)) {
|
|
73
|
-
const sourcePath = resolve(source);
|
|
74
|
-
if (!fileSystem.fileExists(sourcePath)) {
|
|
75
|
-
problems.push({ filePath: label(sourcePath), message: 'the file to copy into the client does not exist; run the model generator first.' });
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
copies.push({ path: resolve(destination), content: fileSystem.readTextFile(sourcePath) });
|
|
79
|
-
}
|
|
80
99
|
const controllers = emitted.map(({ contract }) => ({
|
|
81
100
|
name: contract.name,
|
|
82
101
|
filePath: contract.filePath,
|
|
@@ -85,22 +104,27 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
85
104
|
endpointCount: contract.endpoints.length,
|
|
86
105
|
}));
|
|
87
106
|
if (problems.length > 0)
|
|
88
|
-
return { files: [], controllers, problems };
|
|
107
|
+
return { files: [], leftoverFiles: [], controllers, problems };
|
|
89
108
|
const controllersLabel = label(controllersDirectory);
|
|
90
109
|
const files = [
|
|
91
|
-
|
|
110
|
+
...sortControllers(emitted).map((controller) => ({
|
|
111
|
+
path: nodePath.join(outDirectory, controllerModuleFileName(controller.contract.name)),
|
|
112
|
+
content: emitControllerModule(controller, { clientModule: config.clientModule }),
|
|
113
|
+
})),
|
|
114
|
+
{ path: nodePath.join(outDirectory, CLIENT_INDEX_FILE_NAME), content: emitClientIndexModule(emitted, { controllersLabel }) },
|
|
92
115
|
{ path: resolve(config.appOutFile), content: emitAppModule(appDeclarations, { appLabel: label(appFile) }) },
|
|
93
116
|
{ path: resolve(config.scriptsOutFile), content: emitScriptsModule(emitted, { wireModule: config.wireModule, controllersLabel }) },
|
|
94
|
-
...copies,
|
|
95
117
|
];
|
|
96
|
-
|
|
118
|
+
const leftoverFiles = findLeftoverFiles(fileSystem, outDirectory, files.map((file) => file.path));
|
|
119
|
+
return { files, leftoverFiles, controllers, problems };
|
|
97
120
|
}
|
|
98
121
|
export function runClientGeneration(options) {
|
|
99
122
|
const plan = planClientGeneration(options);
|
|
100
123
|
const writtenFiles = [];
|
|
101
124
|
const unchangedFiles = [];
|
|
125
|
+
const deletedFiles = [];
|
|
102
126
|
if (plan.problems.length > 0)
|
|
103
|
-
return { ...plan, writtenFiles, unchangedFiles };
|
|
127
|
+
return { ...plan, writtenFiles, unchangedFiles, deletedFiles };
|
|
104
128
|
const { fileSystem } = options;
|
|
105
129
|
for (const file of plan.files) {
|
|
106
130
|
if (fileSystem.fileExists(file.path) && fileSystem.readTextFile(file.path) === file.content) {
|
|
@@ -111,7 +135,11 @@ export function runClientGeneration(options) {
|
|
|
111
135
|
fileSystem.writeTextFile(file.path, file.content);
|
|
112
136
|
writtenFiles.push(file.path);
|
|
113
137
|
}
|
|
114
|
-
|
|
138
|
+
for (const filePath of plan.leftoverFiles) {
|
|
139
|
+
fileSystem.deleteFile(filePath);
|
|
140
|
+
deletedFiles.push(filePath);
|
|
141
|
+
}
|
|
142
|
+
return { ...plan, writtenFiles, unchangedFiles, deletedFiles };
|
|
115
143
|
}
|
|
116
144
|
export function checkClientGeneration(options) {
|
|
117
145
|
const plan = planClientGeneration(options);
|
package/dist-tooling/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** The generator as a library: what the `netsuite-api` command runs, for a build tool or a test to call directly. */
|
|
2
2
|
export { DEFAULT_CONFIG_FILE_NAME, ClientGeneratorConfigError, defaultClientGeneratorConfig, loadClientGeneratorConfig } from './config.js';
|
|
3
3
|
export type { ClientGeneratorConfig, ResolvedClientGeneratorConfig } from './config.js';
|
|
4
|
-
export { RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc
|
|
5
|
-
export type { CarriedTypeImport, ControllerContract, ControllerKind, ControllerProblem, ControllerReadResult, DeclaredScript, EndpointSignature, ReadControllerOptions, TypeDeclaration } from './controllerReader.js';
|
|
4
|
+
export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
|
|
5
|
+
export type { CarriedTypeImport, ControllerContract, ControllerKind, ControllerProblem, ControllerReadResult, ControllerTypeImport, DeclaredScript, EndpointSignature, InlinedTypeImport, ReadControllerOptions, TypeDeclaration, TypeImportName } from './controllerReader.js';
|
|
6
|
+
export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
|
|
7
|
+
export type { InlinableTypeDeclaration, InlinableTypesFile, SelectedInlinedTypes } from './typesFileReader.js';
|
|
6
8
|
export { readAppDeclarations } from './appReader.js';
|
|
7
9
|
export type { AppDeclarations } from './appReader.js';
|
|
8
|
-
export { emitAppModule,
|
|
9
|
-
export type { EmitAppModuleOptions,
|
|
10
|
+
export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
|
|
11
|
+
export type { EmitAppModuleOptions, EmitClientIndexModuleOptions, EmitControllerModuleOptions, EmitScriptsModuleOptions, EmittedController, InlinedTypeSection } from './emit.js';
|
|
10
12
|
export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
|
|
11
13
|
export type { ClientGenerationCheck, ClientGenerationPlan, ClientGenerationResult, GenerateClientOptions, PlannedController, PlannedFile } from './generate.js';
|
|
12
14
|
export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
|
package/dist-tooling/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** The generator as a library: what the `netsuite-api` command runs, for a build tool or a test to call directly. */
|
|
2
2
|
export { DEFAULT_CONFIG_FILE_NAME, ClientGeneratorConfigError, defaultClientGeneratorConfig, loadClientGeneratorConfig } from './config.js';
|
|
3
|
-
export { RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc
|
|
3
|
+
export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
|
|
4
|
+
export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
|
|
4
5
|
export { readAppDeclarations } from './appReader.js';
|
|
5
|
-
export { emitAppModule,
|
|
6
|
+
export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
|
|
6
7
|
export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
|
|
7
8
|
export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
|
|
8
9
|
export { CLI_USAGE, EXIT_PROBLEMS, EXIT_SUCCESS, EXIT_USAGE, runCli } from './cli/main.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ControllerProblem, TypeDeclaration, TypeImportName } from './controllerReader.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads a type-only file whose declarations the generator copies into a controller's generated
|
|
4
|
+
* module: the generated entity types. A controller names the types it imports from the file; each
|
|
5
|
+
* of those is copied with every type it refers to in the same file, so the generated module stands
|
|
6
|
+
* on its own. A type built on something the file imports (the repository package's EntityCreate and
|
|
7
|
+
* EntityPatch) cannot be copied, because the client would need that package to resolve it.
|
|
8
|
+
*/
|
|
9
|
+
export interface InlinableTypeDeclaration extends TypeDeclaration {
|
|
10
|
+
/** The names of the types the declaration refers to, each once, in the order met. */
|
|
11
|
+
references: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface InlinableTypesFile {
|
|
14
|
+
/** How a problem names the file: its path relative to the project. */
|
|
15
|
+
filePath: string;
|
|
16
|
+
/** Every top-level type declaration by name, in file order. */
|
|
17
|
+
declarations: Map<string, InlinableTypeDeclaration>;
|
|
18
|
+
/** Every name the file imports, mapped to the module it comes from. */
|
|
19
|
+
importedNames: Map<string, string>;
|
|
20
|
+
}
|
|
21
|
+
export interface SelectedInlinedTypes {
|
|
22
|
+
/** The declarations to copy, in file order, followed by an alias for every renamed import. */
|
|
23
|
+
declarations: TypeDeclaration[];
|
|
24
|
+
problems: ControllerProblem[];
|
|
25
|
+
}
|
|
26
|
+
export declare function readInlinableTypesFile(filePath: string, source: string): InlinableTypesFile;
|
|
27
|
+
/**
|
|
28
|
+
* The declarations a controller's imports pull out of the file: the named types and, transitively,
|
|
29
|
+
* what they refer to. A name the file does not declare is a problem reported against the controller.
|
|
30
|
+
*/
|
|
31
|
+
export declare function selectInlinedTypes(file: InlinableTypesFile, names: TypeImportName[], controllerPath: string): SelectedInlinedTypes;
|