@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 CHANGED
@@ -5,7 +5,7 @@ The API layer for a NetSuite single-page app. The app's server side is SuiteScri
5
5
  - **`@amerilux/netsuite-api/server`**: declare a controller's endpoints and the script that serves them, expose them as a Restlet or a Suitelet, reject a call with an `ApiError`, call another Suitelet controller from server code, and find a File Cabinet file by name.
6
6
  - **`@amerilux/netsuite-api/client`**: a typed browser client per controller, built from the endpoint types.
7
7
  - **`@amerilux/netsuite-api/testing`**: stubs for the `N/*` modules and the vitest wiring that routes imports to them.
8
- - **`netsuite-api generate`**: reads the controllers and writes the client's whole view of the backend, one client module and one copy of the entity types, plus the server-side map of scripts. The client never imports from the server tree.
8
+ - **`netsuite-api generate`**: reads the controllers and writes the client's whole view of the backend, one module per controller and an index re-exporting them, plus the server-side map of scripts. The client never imports from the server tree.
9
9
  - **`@amerilux/netsuite-api`** (the root): the wire itself. The envelope, the endpoint types, `ScriptDeclaration`, `ScriptRef`.
10
10
 
11
11
  The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces, and `netsuite.ts` at the root holding the application's names.
@@ -24,7 +24,7 @@ import { defineEndpoints, defineRestlet } from '@amerilux/netsuite-api/server';
24
24
  import type { Customer } from '../types/models.gen';
25
25
  import { findCustomer, searchCustomers } from '../services/customerService';
26
26
 
27
- export interface CustomerSearchRequest {
27
+ export interface SearchRequest {
28
28
  search: string;
29
29
  }
30
30
 
@@ -32,7 +32,7 @@ export type CustomerSummary = Pick<Customer, 'id' | 'companyName'>;
32
32
 
33
33
  export const customerEndpoints = defineEndpoints({
34
34
  /** Customers whose name contains the search text. */
35
- search: (request: CustomerSearchRequest): CustomerSummary[] => searchCustomers(request.search),
35
+ search: (request: SearchRequest): CustomerSummary[] => searchCustomers(request.search),
36
36
  byId: (request: { id: number }): Customer => findCustomer(request.id),
37
37
  });
38
38
 
@@ -53,46 +53,60 @@ The generator reads the file as source, so a few things are rules rather than co
53
53
 
54
54
  - Handlers are written inline with their parameter and return types annotated. A reference to a service function carries no types the generator can read.
55
55
  - Every type in the file is a wire shape and is exported.
56
- - A type is imported only from the carried modules (the generated entity types, by default) or from another controller. A service's return type is never used as a DTO by reference.
56
+ - A type is imported only from the inlined files (the generated entity types, by default), the carried modules (the package's server entry, for `RawResponse`) or another controller. A service's return type is never used as a DTO by reference.
57
57
  - The script declaration is an object literal with literal ids, its `name` is the file name without `Controller`, and the entry point export and the `@NScriptType` header agree with the define function.
58
- - Type names and script ids are unique across controllers.
58
+ - Script ids are unique across controllers, and no wire shape is named `Endpoints`. A shape's name carries no controller prefix: each controller's generated module is its own namespace.
59
59
 
60
60
  ## What the generator writes
61
61
 
62
- `netsuite-api generate`, run from the project root, writes four things:
62
+ `netsuite-api generate`, run from the project root, writes four kinds of file:
63
63
 
64
- **`client/src/api/index.gen.ts`**, the client's view of the backend. For every controller its wire shapes and endpoint type, and for every browser-facing controller a client built from the declared script:
64
+ **`client/src/api/<name>.gen.ts`**, one module per controller: the entity types it names (copied in, with whatever they refer to, so the module stands on its own), its wire shapes, its endpoint type and, for a browser-facing controller, a client built from the declared script:
65
65
 
66
66
  ```ts
67
+ // client/src/api/customer.gen.ts
67
68
  import { createApiClient } from '@amerilux/netsuite-api/client';
68
- import type { Customer } from './models.gen';
69
69
 
70
- // customer (api/src/controllers/customerController.ts)
70
+ // Entity types from api/src/types/models.gen.ts, copied so this module stands on its own.
71
71
 
72
- export interface CustomerSearchRequest {
72
+ export interface Customer {
73
+ id: number;
74
+ companyName: string;
75
+ }
76
+
77
+ export interface SearchRequest {
73
78
  search: string;
74
79
  }
75
80
 
76
81
  export type CustomerSummary = Pick<Customer, 'id' | 'companyName'>;
77
82
 
78
- export type CustomerEndpoints = {
83
+ /** The endpoint signatures of the customer controller, as its handlers declare them. */
84
+ export type Endpoints = {
79
85
  /** Customers whose name contains the search text. */
80
- search: (request: CustomerSearchRequest) => CustomerSummary[];
86
+ search: (request: SearchRequest) => CustomerSummary[];
81
87
  byId: (request: { id: number }) => Customer;
82
88
  };
83
89
 
84
- export const customerApi = createApiClient<CustomerEndpoints>({ kind: 'restlet', scriptId: 'customscript_app_customer', deployId: 'customdeploy_app_customer' });
90
+ /** One typed function per endpoint of the customer controller: \`customer.api.search(...)\`. */
91
+ export const api = createApiClient<Endpoints>({ kind: 'restlet', scriptId: 'customscript_app_customer', deployId: 'customdeploy_app_customer' });
85
92
  ```
86
93
 
87
- A hook calls `customerApi.search({ search: 'acme' })` and gets a `Promise<CustomerSummary[]>`. The second argument carries an `AbortSignal`.
94
+ A type imported from another controller becomes an import of that controller's module. A type built on something the entity file imports itself (`CustomerCreate`, `CustomerPatch`: the repository package's input types) is an error, because the client could not carry it; write the wire shape out in the controller instead. A generated module no controller owns any more is deleted on the next run.
95
+
96
+ **`client/src/api/index.gen.ts`**, the client's view of the backend: every controller's module re-exported under the controller's name.
97
+
98
+ ```ts
99
+ export * as customer from './customer.gen';
100
+ export * as user from './user.gen';
101
+ ```
88
102
 
89
- **`client/src/api/models.gen.ts`**, a copy of the api's generated entity types, so the carried imports resolve.
103
+ A hook imports `{ customer }` from it, calls `customer.api.search({ search: 'acme' })` and gets a `Promise<customer.CustomerSummary[]>`. The second argument carries an `AbortSignal`.
90
104
 
91
105
  **`client/src/app.gen.ts`**, a verbatim copy of the app file, outside the api folder so a page or a component may import `app` without touching a client.
92
106
 
93
107
  **`api/src/scripts.gen.ts`**, the server-side map of every declared script by controller name. A repository passes an entry to `createSuiteletClient`; nothing else needs it.
94
108
 
95
- `netsuite-api check` exits non-zero when any generated file is missing or out of date, for CI. `netsuite-api generate --dry-run` prints the client module instead of writing anything.
109
+ `netsuite-api check` exits non-zero when any generated file is missing, out of date or left over, for CI. `netsuite-api generate --dry-run` prints every file instead of writing anything.
96
110
 
97
111
  ### The app file
98
112
 
@@ -106,17 +120,17 @@ A hook calls `customerApi.search({ search: 'acme' })` and gets a `Promise<Custom
106
120
  {
107
121
  "controllers": "api/src/controllers",
108
122
  "appFile": "netsuite.ts",
109
- "outFile": "client/src/api/index.gen.ts",
123
+ "outDir": "client/src/api",
110
124
  "appOutFile": "client/src/app.gen.ts",
111
125
  "scriptsOutFile": "api/src/scripts.gen.ts",
112
126
  "clientModule": "@amerilux/netsuite-api/client",
113
127
  "wireModule": "@amerilux/netsuite-api",
114
- "typeImports": { "../types/models.gen": "./models.gen", "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
115
- "copyFiles": { "api/src/types/models.gen.ts": "client/src/api/models.gen.ts" }
128
+ "typeImports": { "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
129
+ "inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts" }
116
130
  }
117
131
  ```
118
132
 
119
- Paths are relative to the config file. `typeImports` maps a specifier as written in a controller to the specifier the client resolves; only listed specifiers may be imported for types (the package's server entry maps to its client entry so `RawResponse` carries over). `copyFiles` copies the files those specifiers point at.
133
+ Paths are relative to the config file. `outDir` holds the controller modules and the index, and nothing else. `inlineTypes` maps a specifier as written in a controller to the type-only file whose declarations are copied into the module of every controller importing from it. `typeImports` maps a specifier to the one the client resolves, for a type that stays an import (the package's server entry maps to its client entry so `RawResponse` carries over). A type imported from any other module is an error.
120
134
 
121
135
  ## The client at runtime
122
136
 
@@ -128,7 +142,13 @@ import { configureApiClient } from '@amerilux/netsuite-api/client';
128
142
  if (import.meta.env.DEV) configureApiClient({ basePaths: { restlet: '/api/restlet', suitelet: '/api/suitelet' } });
129
143
  ```
130
144
 
131
- A failed call rejects with an `ApiClientError` carrying the envelope's status and message.
145
+ A failed call rejects with an `ApiClientError` carrying the envelope's status and message; a call that got no answer at all carries `NO_RESPONSE_STATUS` (0). Before it rejects, the failure goes to the handler `configureApiClient` was given, with the script, the endpoint and the request, so the app reports every failure in one place and a hook carries no error handling of its own:
146
+
147
+ ```ts
148
+ configureApiClient({ onError: (error, { endpoint }) => showBanner(`${endpoint}: ${error.message}`) });
149
+ ```
150
+
151
+ The rejection still reaches the caller, so a query sees its error state. A call that reports the failure itself passes `{ handleError: false }` as its second argument; an aborted call is not a failure and never reaches the handler.
132
152
 
133
153
  ## Authorizing calls
134
154
 
@@ -163,7 +183,7 @@ export const documentsEndpoints = defineEndpoints({
163
183
  });
164
184
  ```
165
185
 
166
- In the browser, `documentsApi.csv({ month })` resolves to a `Blob`; hand it to `URL.createObjectURL` for a download link. A text answer takes a content type, a body, optional headers and, for a download, a file name; a file answer takes an `N/file` object and whether to show it inline. A failure still arrives as the envelope and is thrown as an `ApiClientError`. A Restlet cannot write a raw answer: a handler returning one there is a 500.
186
+ In the browser, `documents.api.csv({ month })` resolves to a `Blob`; hand it to `URL.createObjectURL` for a download link. A text answer takes a content type, a body, optional headers and, for a download, a file name; a file answer takes an `N/file` object and whether to show it inline. A failure still arrives as the envelope and is thrown as an `ApiClientError`. A Restlet cannot write a raw answer: a handler returning one there is a 500.
167
187
 
168
188
  ## Calling a Suitelet from server code
169
189
 
@@ -4,15 +4,34 @@ import { type EndpointRequest, type EndpointResponse, type Endpoints, type RawRe
4
4
  * `kind` decides the URL, so a controller can move between Restlet and Suitelet without touching
5
5
  * the caller. In the deployed app the call rides the NetSuite session on the same origin; a
6
6
  * development server that proxies to a sandbox sets its own base paths with configureApiClient.
7
+ *
8
+ * Every failure is one error type, ApiClientError, and is handed to the configured error handler
9
+ * before the call rejects: the app reports failures in one place, and a hook or a page adds error
10
+ * handling only when it wants something other than the default.
7
11
  */
8
12
  export interface ApiCallOptions {
9
13
  signal?: AbortSignal;
14
+ /**
15
+ * Whether the configured error handler is told when this call fails. True unless set: a caller
16
+ * that reports the failure itself (a form showing it inline) passes false. The call rejects either way.
17
+ */
18
+ handleError?: boolean;
10
19
  }
20
+ /** The status of an ApiClientError for a call that got no answer at all: the network, not the script, failed. */
21
+ export declare const NO_RESPONSE_STATUS = 0;
11
22
  export declare class ApiClientError extends Error {
12
23
  readonly status: number;
13
24
  readonly details?: unknown | undefined;
14
25
  constructor(status: number, message: string, details?: unknown | undefined);
15
26
  }
27
+ /** What the error handler is told about the call that failed. */
28
+ export interface ApiCallContext {
29
+ scriptRef: ScriptRef;
30
+ endpoint: string;
31
+ request: unknown;
32
+ }
33
+ /** Runs for a failed call before it rejects. It reports; it does not recover, and it must not throw. */
34
+ export type ApiErrorHandler = (error: ApiClientError, context: ApiCallContext) => void;
16
35
  /** The path each script kind is served from, without the script and deploy query parameters. */
17
36
  export type ApiBasePaths = Record<ScriptKind, string>;
18
37
  /** Where NetSuite serves Restlets and Suitelets on the account's own origin. */
@@ -20,10 +39,18 @@ export declare const NETSUITE_API_BASE_PATHS: ApiBasePaths;
20
39
  export interface ApiClientConfiguration {
21
40
  /** Base paths to use instead of NetSuite's own, e.g. the routes of a local development proxy. */
22
41
  basePaths?: Partial<ApiBasePaths>;
42
+ /**
43
+ * Told about every failed call, unless the call passed `handleError: false`, before the call
44
+ * rejects. The rejection still reaches the caller (a query sees its error state), so the handler
45
+ * is where the app reports: a banner, a log, a redirect on 401. An aborted call is not a failure
46
+ * and never reaches it.
47
+ */
48
+ onError?: ApiErrorHandler;
23
49
  }
24
50
  /**
25
- * Sets where calls go. Call it once at startup, before the first call; a client built earlier picks
26
- * the change up because the URL is built per call. Without a call, NetSuite's own paths apply.
51
+ * Sets where calls go and who hears about failures. Call it once at startup, before the first call;
52
+ * a client built earlier picks the change up because both are read per call. Without a call,
53
+ * NetSuite's own paths apply and failures only reject.
27
54
  */
28
55
  export declare function configureApiClient(configuration: ApiClientConfiguration): void;
29
56
  export declare function buildApiUrl(scriptRef: ScriptRef): string;
@@ -38,8 +65,8 @@ export declare function callRawEndpoint(scriptRef: ScriptRef, endpointName: stri
38
65
  /** What the client resolves to for an endpoint's response type: a Blob for a raw answer, the data otherwise. */
39
66
  export type ClientResponse<TResponse> = TResponse extends RawResponse ? Blob : TResponse;
40
67
  /**
41
- * One function per endpoint, typed by the controller's handlers: `userApi.roles()`,
42
- * `ordersApi.byId({ id })`, `exportsApi.csv({ month })` resolving to a Blob. The request comes first,
68
+ * One function per endpoint, typed by the controller's handlers: `user.api.roles()`,
69
+ * `orders.api.byId({ id })`, `exports.api.csv({ month })` resolving to a Blob. The request comes first,
43
70
  * the call options second.
44
71
  */
45
72
  export type ApiClient<TEndpoints extends Endpoints> = {
@@ -50,8 +77,8 @@ export interface ApiClientOptions {
50
77
  rawEndpoints?: readonly string[];
51
78
  }
52
79
  /**
53
- * Builds the typed client for a controller from its script: `createApiClient<UserEndpoints>({ kind, scriptId, deployId })`.
80
+ * Builds the typed client for a controller from its script: `createApiClient<Endpoints>({ kind, scriptId, deployId })`.
54
81
  * The endpoint names come from the type alone; the property accessed is the endpoint named on the wire.
55
- * The generated client module of a project calls this once per browser-facing controller.
82
+ * The generated module of a browser-facing controller calls this once.
56
83
  */
57
84
  export declare function createApiClient<TEndpoints extends Endpoints>(scriptRef: ScriptRef, clientOptions?: ApiClientOptions): ApiClient<TEndpoints>;
@@ -1,4 +1,6 @@
1
1
  import { ENDPOINT_PARAMETER } from '../index.js';
2
+ /** The status of an ApiClientError for a call that got no answer at all: the network, not the script, failed. */
3
+ export const NO_RESPONSE_STATUS = 0;
2
4
  export class ApiClientError extends Error {
3
5
  constructor(status, message, details) {
4
6
  super(message);
@@ -13,17 +15,41 @@ export const NETSUITE_API_BASE_PATHS = {
13
15
  suitelet: '/app/site/hosting/scriptlet.nl',
14
16
  };
15
17
  let configuredBasePaths = NETSUITE_API_BASE_PATHS;
18
+ let configuredErrorHandler;
16
19
  /**
17
- * Sets where calls go. Call it once at startup, before the first call; a client built earlier picks
18
- * the change up because the URL is built per call. Without a call, NetSuite's own paths apply.
20
+ * Sets where calls go and who hears about failures. Call it once at startup, before the first call;
21
+ * a client built earlier picks the change up because both are read per call. Without a call,
22
+ * NetSuite's own paths apply and failures only reject.
19
23
  */
20
24
  export function configureApiClient(configuration) {
21
25
  configuredBasePaths = { ...NETSUITE_API_BASE_PATHS, ...configuration.basePaths };
26
+ configuredErrorHandler = configuration.onError;
22
27
  }
23
28
  export function buildApiUrl(scriptRef) {
24
29
  const parameters = new URLSearchParams({ script: scriptRef.scriptId, deploy: scriptRef.deployId });
25
30
  return `${configuredBasePaths[scriptRef.kind]}?${parameters.toString()}`;
26
31
  }
32
+ function isAbort(error) {
33
+ return error instanceof Error && error.name === 'AbortError';
34
+ }
35
+ /**
36
+ * Runs one call: whatever fails inside becomes an ApiClientError (a fetch that never answered gets
37
+ * NO_RESPONSE_STATUS), goes to the configured handler unless the call opted out, and rejects. An
38
+ * abort is the caller's own doing and passes through untouched.
39
+ */
40
+ async function reportingFailures(scriptRef, endpointName, request, options, call) {
41
+ try {
42
+ return await call();
43
+ }
44
+ catch (error) {
45
+ if (isAbort(error))
46
+ throw error;
47
+ const failure = error instanceof ApiClientError ? error : new ApiClientError(NO_RESPONSE_STATUS, `Could not reach ${scriptRef.scriptId}.${endpointName}: ${error instanceof Error ? error.message : String(error)}`, error);
48
+ if (options.handleError !== false)
49
+ configuredErrorHandler === null || configuredErrorHandler === void 0 ? void 0 : configuredErrorHandler(failure, { scriptRef, endpoint: endpointName, request });
50
+ throw failure;
51
+ }
52
+ }
27
53
  /** The POST every call is: the request plus the endpoint name, as JSON. */
28
54
  function postEndpoint(scriptRef, endpointName, request, options) {
29
55
  return fetch(buildApiUrl(scriptRef), {
@@ -44,43 +70,47 @@ function parseEnvelope(text) {
44
70
  }
45
71
  }
46
72
  /** Calls one endpoint of a controller: a POST whose JSON body carries the request and the endpoint name. */
47
- export async function callEndpoint(scriptRef, endpointName, request = {}, options = {}) {
48
- var _a;
49
- const response = await postEndpoint(scriptRef, endpointName, request, options);
50
- const text = await response.text();
51
- const envelope = parseEnvelope(text);
52
- if (!envelope) {
53
- throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
54
- }
55
- if (envelope.error !== null || envelope.status >= 400) {
56
- throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`);
57
- }
58
- return envelope.data;
73
+ export function callEndpoint(scriptRef, endpointName, request = {}, options = {}) {
74
+ return reportingFailures(scriptRef, endpointName, request, options, async () => {
75
+ var _a;
76
+ const response = await postEndpoint(scriptRef, endpointName, request, options);
77
+ const text = await response.text();
78
+ const envelope = parseEnvelope(text);
79
+ if (!envelope) {
80
+ throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
81
+ }
82
+ if (envelope.error !== null || envelope.status >= 400) {
83
+ throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`);
84
+ }
85
+ return envelope.data;
86
+ });
59
87
  }
60
88
  /**
61
89
  * Calls an endpoint that answers with a document instead of the envelope (a Suitelet handler returning
62
90
  * rawResponse) and resolves to its body as a Blob. A failure still arrives as an envelope, and is
63
91
  * thrown as an ApiClientError with its status.
64
92
  */
65
- export async function callRawEndpoint(scriptRef, endpointName, request = {}, options = {}) {
66
- var _a, _b;
67
- const response = await postEndpoint(scriptRef, endpointName, request, options);
68
- const contentType = (_a = response.headers.get('Content-Type')) !== null && _a !== void 0 ? _a : '';
69
- if (!response.ok || contentType.includes('application/json')) {
70
- const text = await response.text();
71
- const envelope = parseEnvelope(text);
72
- if (envelope && (envelope.error !== null || envelope.status >= 400))
73
- throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`);
74
- if (!response.ok)
75
- throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
76
- throw new ApiClientError(response.status, `${scriptRef.scriptId}.${endpointName} answered JSON where a document was expected.`, text.slice(0, 500));
77
- }
78
- return response.blob();
93
+ export function callRawEndpoint(scriptRef, endpointName, request = {}, options = {}) {
94
+ return reportingFailures(scriptRef, endpointName, request, options, async () => {
95
+ var _a, _b;
96
+ const response = await postEndpoint(scriptRef, endpointName, request, options);
97
+ const contentType = (_a = response.headers.get('Content-Type')) !== null && _a !== void 0 ? _a : '';
98
+ if (!response.ok || contentType.includes('application/json')) {
99
+ const text = await response.text();
100
+ const envelope = parseEnvelope(text);
101
+ if (envelope && (envelope.error !== null || envelope.status >= 400))
102
+ throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`);
103
+ if (!response.ok)
104
+ throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
105
+ throw new ApiClientError(response.status, `${scriptRef.scriptId}.${endpointName} answered JSON where a document was expected.`, text.slice(0, 500));
106
+ }
107
+ return response.blob();
108
+ });
79
109
  }
80
110
  /**
81
- * Builds the typed client for a controller from its script: `createApiClient<UserEndpoints>({ kind, scriptId, deployId })`.
111
+ * Builds the typed client for a controller from its script: `createApiClient<Endpoints>({ kind, scriptId, deployId })`.
82
112
  * The endpoint names come from the type alone; the property accessed is the endpoint named on the wire.
83
- * The generated client module of a project calls this once per browser-facing controller.
113
+ * The generated module of a browser-facing controller calls this once.
84
114
  */
85
115
  export function createApiClient(scriptRef, clientOptions = {}) {
86
116
  var _a;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The browser side of the API: a typed client per controller, built from the endpoint types the
3
- * generator writes into the project's client module. Runs in the browser only; nothing here imports N/*.
3
+ * generator writes into the project's client modules. Runs in the browser only; nothing here imports N/*.
4
4
  */
5
- export { ApiClientError, NETSUITE_API_BASE_PATHS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
6
- export type { ApiBasePaths, ApiCallOptions, ApiClient, ApiClientConfiguration, ApiClientOptions, ClientResponse } from './apiClient.js';
5
+ export { ApiClientError, NETSUITE_API_BASE_PATHS, NO_RESPONSE_STATUS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
6
+ export type { ApiBasePaths, ApiCallContext, ApiCallOptions, ApiClient, ApiClientConfiguration, ApiClientOptions, ApiErrorHandler, ClientResponse } from './apiClient.js';
7
7
  export { ENDPOINT_PARAMETER } from '../index.js';
8
8
  export type { ApiEnvelope, ApiErrorBody, Endpoint, Endpoints, EndpointRequest, EndpointResponse, RawResponse, ScriptDeclaration, ScriptKind, ScriptRef } from '../index.js';
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The browser side of the API: a typed client per controller, built from the endpoint types the
3
- * generator writes into the project's client module. Runs in the browser only; nothing here imports N/*.
3
+ * generator writes into the project's client modules. Runs in the browser only; nothing here imports N/*.
4
4
  */
5
- export { ApiClientError, NETSUITE_API_BASE_PATHS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
5
+ export { ApiClientError, NETSUITE_API_BASE_PATHS, NO_RESPONSE_STATUS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
6
6
  export { ENDPOINT_PARAMETER } from '../index.js';
@@ -7,13 +7,13 @@ export const CLI_USAGE = [
7
7
  'Usage: netsuite-api <command> [options]',
8
8
  '',
9
9
  'Commands:',
10
- ' generate Read the controllers and the app file; write the client module, the scripts map and the copied type files.',
11
- ' check Exit non-zero when a generated file is missing or out of date.',
10
+ ' generate Read the controllers and the app file; write a client module per controller, the client index, the app module and the scripts map, and delete a generated file no controller owns.',
11
+ ' check Exit non-zero when a generated file is missing, out of date or left over.',
12
12
  ' help Show this message.',
13
13
  '',
14
14
  'Options:',
15
15
  ' --config <path> Config file (default: netsuite-api.config.json in the working directory).',
16
- ' --dry-run With generate: print the client module that would be written without writing anything.',
16
+ ' --dry-run With generate: print every file that would be written, without writing anything.',
17
17
  ].join('\n');
18
18
  export const EXIT_SUCCESS = 0;
19
19
  export const EXIT_PROBLEMS = 1;
@@ -51,7 +51,7 @@ export function runCli(argv, environment) {
51
51
  }
52
52
  const options = { config, fileSystem };
53
53
  const failure = (plan) => {
54
- environment.stderr(['The controllers cannot be turned into a client module:', ...formatProblems(plan)].join('\n'));
54
+ environment.stderr(['The controllers cannot be turned into client modules:', ...formatProblems(plan)].join('\n'));
55
55
  return EXIT_PROBLEMS;
56
56
  };
57
57
  if (command === 'generate') {
@@ -59,27 +59,29 @@ export function runCli(argv, environment) {
59
59
  const plan = planClientGeneration(options);
60
60
  if (plan.problems.length > 0)
61
61
  return failure(plan);
62
- environment.stdout(plan.files[0].content);
62
+ environment.stdout(plan.files.map((file) => `// ---- ${relativeTo(environment.cwd, file.path)}\n${file.content}`).join('\n'));
63
63
  return EXIT_SUCCESS;
64
64
  }
65
65
  const result = runClientGeneration(options);
66
66
  if (result.problems.length > 0)
67
67
  return failure(result);
68
68
  environment.stdout([
69
- `netsuite-api: ${result.controllers.length} controller(s), ${result.writtenFiles.length} file(s) written, ${result.unchangedFiles.length} unchanged.`,
69
+ `netsuite-api: ${result.controllers.length} controller(s), ${result.writtenFiles.length} file(s) written, ${result.unchangedFiles.length} unchanged${result.deletedFiles.length > 0 ? `, ${result.deletedFiles.length} deleted` : ''}.`,
70
70
  ...describeControllers(result),
71
71
  ...result.writtenFiles.map((filePath) => ` - wrote ${relativeTo(environment.cwd, filePath)}`),
72
+ ...result.deletedFiles.map((filePath) => ` - deleted ${relativeTo(environment.cwd, filePath)}`),
72
73
  ].join('\n'));
73
74
  return EXIT_SUCCESS;
74
75
  }
75
76
  const check = checkClientGeneration(options);
76
77
  if (check.problems.length > 0)
77
78
  return failure(check);
78
- if (check.missingFiles.length > 0 || check.staleFiles.length > 0) {
79
+ if (check.missingFiles.length > 0 || check.staleFiles.length > 0 || check.leftoverFiles.length > 0) {
79
80
  environment.stderr([
80
81
  'The generated files are not up to date. Run `netsuite-api generate`.',
81
82
  ...check.missingFiles.map((filePath) => ` - missing: ${relativeTo(environment.cwd, filePath)}`),
82
83
  ...check.staleFiles.map((filePath) => ` - out of date: ${relativeTo(environment.cwd, filePath)}`),
84
+ ...check.leftoverFiles.map((filePath) => ` - left over: ${relativeTo(environment.cwd, filePath)}`),
83
85
  ].join('\n'));
84
86
  return EXIT_PROBLEMS;
85
87
  }
@@ -6,27 +6,32 @@ import type { FileSystemAdapter } from './file-system.js';
6
6
  export interface ClientGeneratorConfig {
7
7
  /** Directory holding the controllers: one `<name>Controller.ts` per script. */
8
8
  controllers: string;
9
- /** The file declaring `app` (and any other id no controller or model owns), copied into the client module verbatim. */
9
+ /** The file declaring `app` (and any other id no controller or model owns), copied into the client verbatim. */
10
10
  appFile: string;
11
- /** The generated client module: every wire shape, every endpoint type, one client per browser-facing controller. */
12
- outFile: string;
11
+ /** The client's generated directory: one `<name>.gen.ts` per controller (its wire shapes, its endpoint type, its client) and `index.gen.ts` re-exporting each under the controller's name. Nothing else lives there. */
12
+ outDir: string;
13
13
  /** The generated copy of the app file for the client, outside the api folder so pages and components may import it. */
14
14
  appOutFile: string;
15
15
  /** The generated server-side `scripts` map: what a repository passes to createSuiteletClient. */
16
16
  scriptsOutFile: string;
17
- /** The specifier the client module imports `createApiClient` from. */
17
+ /** The specifier the controller modules import `createApiClient` from. */
18
18
  clientModule: string;
19
19
  /** The specifier the scripts map imports `ScriptRef` from. */
20
20
  wireModule: string;
21
21
  /**
22
- * Type imports a controller may carry into the client module, as the specifier written in the
23
- * controller mapped to the specifier the client resolves: the generated entity types, and the
24
- * package's server entry mapped to its client entry (for `RawResponse`). A type imported from any
25
- * other module is an error, because the client could not resolve it.
22
+ * Type imports a controller may carry into its generated module as imports, as the specifier
23
+ * written in the controller mapped to the specifier the client resolves: the package's server
24
+ * entry mapped to its client entry (for `RawResponse`). A type imported from any module listed
25
+ * in neither this nor `inlineTypes` is an error, because the client could not resolve it.
26
26
  */
27
27
  typeImports: Record<string, string>;
28
- /** Files copied into the client as they are, source to destination: the generated entity types the carried imports point at. */
29
- copyFiles: Record<string, string>;
28
+ /**
29
+ * Type-only files whose declarations are copied into the generated module of every controller
30
+ * that imports from them, as the specifier written in the controller mapped to the file: the
31
+ * generated entity types. A controller module carries the types it names, and what those refer
32
+ * to, so the client needs no copy of the file.
33
+ */
34
+ inlineTypes: Record<string, string>;
30
35
  }
31
36
  export interface ResolvedClientGeneratorConfig extends ClientGeneratorConfig {
32
37
  /** The config file's directory, or the working directory when no config file exists and the defaults apply. */
@@ -3,13 +3,13 @@ export const DEFAULT_CONFIG_FILE_NAME = 'netsuite-api.config.json';
3
3
  export const defaultClientGeneratorConfig = {
4
4
  controllers: 'api/src/controllers',
5
5
  appFile: 'netsuite.ts',
6
- outFile: 'client/src/api/index.gen.ts',
6
+ outDir: 'client/src/api',
7
7
  appOutFile: 'client/src/app.gen.ts',
8
8
  scriptsOutFile: 'api/src/scripts.gen.ts',
9
9
  clientModule: '@amerilux/netsuite-api/client',
10
10
  wireModule: '@amerilux/netsuite-api',
11
- typeImports: { '../types/models.gen': './models.gen', '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
12
- copyFiles: { 'api/src/types/models.gen.ts': 'client/src/api/models.gen.ts' },
11
+ typeImports: { '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
12
+ inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts' },
13
13
  };
14
14
  export class ClientGeneratorConfigError extends Error {
15
15
  configPath;
@@ -21,14 +21,14 @@ export class ClientGeneratorConfigError extends Error {
21
21
  this.name = 'ClientGeneratorConfigError';
22
22
  }
23
23
  }
24
- const stringSettings = ['controllers', 'appFile', 'outFile', 'appOutFile', 'scriptsOutFile', 'clientModule', 'wireModule'];
25
- const mapSettings = ['typeImports', 'copyFiles'];
24
+ const stringSettings = ['controllers', 'appFile', 'outDir', 'appOutFile', 'scriptsOutFile', 'clientModule', 'wireModule'];
25
+ const mapSettings = ['typeImports', 'inlineTypes'];
26
26
  function isStringMap(value) {
27
27
  return !!value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === 'string' && entry !== '');
28
28
  }
29
29
  function validateClientGeneratorConfig(raw, configPath) {
30
30
  const problems = [];
31
- const config = { ...defaultClientGeneratorConfig, typeImports: { ...defaultClientGeneratorConfig.typeImports }, copyFiles: { ...defaultClientGeneratorConfig.copyFiles } };
31
+ const config = { ...defaultClientGeneratorConfig, typeImports: { ...defaultClientGeneratorConfig.typeImports }, inlineTypes: { ...defaultClientGeneratorConfig.inlineTypes } };
32
32
  for (const setting of stringSettings) {
33
33
  const value = raw[setting];
34
34
  if (value === undefined)
@@ -4,7 +4,8 @@ import ts from 'typescript';
4
4
  * defineRestlet or defineSuitelet call, the exported types (the DTOs), and the name, request type and
5
5
  * response type of every endpoint in its `defineEndpoints({ ... })`. A parse, not a type check: the
6
6
  * handler annotations are the contract, so they must be written out, a DTO may only reference types
7
- * from the carried modules or from another controller, and the script ids are string literals.
7
+ * from the inlined files, the carried modules or another controller, and the script ids are string
8
+ * literals.
8
9
  */
9
10
  export interface ControllerProblem {
10
11
  filePath: string;
@@ -22,10 +23,31 @@ export interface EndpointSignature {
22
23
  }
23
24
  /** The return type a handler writes, exactly, to answer with a document instead of the envelope. */
24
25
  export declare const RAW_RESPONSE_TYPE_NAME = "RawResponse";
26
+ /** The type every generated controller module declares for its endpoint signatures: `user.Endpoints`. */
27
+ export declare const GENERATED_ENDPOINTS_TYPE_NAME = "Endpoints";
28
+ /** The client every generated browser-facing controller module exports: `user.api`. */
29
+ export declare const GENERATED_CLIENT_NAME = "api";
30
+ /** One name of a type import, `Employee` or `Employee as EmployeeRecord`. */
31
+ export interface TypeImportName {
32
+ name: string;
33
+ alias?: string;
34
+ }
35
+ /** A type import the generated module keeps as an import: a package type such as RawResponse, under the specifier the client resolves. */
25
36
  export interface CarriedTypeImport {
26
37
  /** The specifier as the client resolves it, after the typeImports mapping. */
27
38
  moduleSpecifier: string;
28
- names: string[];
39
+ names: TypeImportName[];
40
+ }
41
+ /** A type import whose declarations are copied into the generated module: the generated entity types. */
42
+ export interface InlinedTypeImport {
43
+ /** The specifier as written in the controller: a key of inlineTypes. */
44
+ specifier: string;
45
+ names: TypeImportName[];
46
+ }
47
+ /** A type import from a sibling controller, resolved to that controller's generated module. */
48
+ export interface ControllerTypeImport {
49
+ controllerName: string;
50
+ names: TypeImportName[];
29
51
  }
30
52
  export interface TypeDeclaration {
31
53
  name: string;
@@ -44,25 +66,24 @@ export interface ControllerContract {
44
66
  /** The controller's name: `user` for userController.ts, matching `name` in its declaration. */
45
67
  name: string;
46
68
  filePath: string;
47
- /** `UserEndpoints`: the type the generated module declares for the controller. */
48
- endpointsTypeName: string;
49
- /** `userApi`: the client the generated module exports for a browser-facing controller. */
50
- clientName: string;
51
69
  script: DeclaredScript;
52
- typeImports: CarriedTypeImport[];
70
+ carriedTypeImports: CarriedTypeImport[];
71
+ inlinedTypeImports: InlinedTypeImport[];
72
+ controllerTypeImports: ControllerTypeImport[];
53
73
  typeDeclarations: TypeDeclaration[];
54
74
  endpoints: EndpointSignature[];
55
75
  }
56
76
  export interface ReadControllerOptions {
57
- /** Type imports a controller may carry: the specifier written in the controller mapped to the specifier the client resolves. */
77
+ /** Type imports a controller may carry as imports: the specifier written in the controller mapped to the specifier the client resolves. */
58
78
  typeImports: Record<string, string>;
79
+ /** Type imports whose declarations are copied into the generated module: the specifier written in the controller mapped to the file it names. */
80
+ inlineTypes: Record<string, string>;
59
81
  }
60
82
  export interface ControllerReadResult {
61
83
  contract?: ControllerContract;
62
84
  problems: ControllerProblem[];
63
85
  }
64
86
  export declare function isControllerFileName(fileName: string): boolean;
65
- export declare function toPascalCase(name: string): string;
66
87
  /** The JSDoc block directly above a node (no blank line between them), or undefined. */
67
88
  export declare function readLeadingJsDoc(node: ts.Node, sourceFile: ts.SourceFile): string | undefined;
68
89
  export declare function readControllerContract(filePath: string, source: string, options: ReadControllerOptions): ControllerReadResult;