@amerilux/netsuite-api 0.1.0 → 0.2.1
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 -30
- 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 +14 -13
- package/dist-tooling/config.js +6 -8
- package/dist-tooling/controllerReader.d.ts +30 -9
- package/dist-tooling/controllerReader.js +29 -14
- package/dist-tooling/emit.d.ts +24 -13
- package/dist-tooling/emit.js +53 -28
- 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 +66 -50
- package/dist-tooling/index.d.ts +6 -6
- package/dist-tooling/index.js +3 -3
- package/dist-tooling/typesFileReader.d.ts +31 -0
- package/dist-tooling/typesFileReader.js +97 -0
- package/package.json +1 -1
- package/dist-tooling/appReader.d.ts +0 -12
- package/dist-tooling/appReader.js +0 -31
- package/dist-tooling/scriptsReader.d.ts +0 -17
- package/dist-tooling/scriptsReader.js +0 -57
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ 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
|
|
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
|
-
The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces
|
|
11
|
+
The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces.
|
|
12
12
|
|
|
13
13
|
## A controller
|
|
14
14
|
|
|
@@ -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
|
|
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:
|
|
35
|
+
search: (request: SearchRequest): CustomerSummary[] => searchCustomers(request.search),
|
|
36
36
|
byId: (request: { id: number }): Customer => findCustomer(request.id),
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -53,50 +53,58 @@ 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
|
|
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
|
-
-
|
|
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
|
|
62
|
+
`netsuite-api generate`, run from the project root, writes four kinds of file:
|
|
63
63
|
|
|
64
|
-
**`client/src/api
|
|
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
|
-
//
|
|
70
|
+
// Entity types from api/src/types/models.gen.ts, copied so this module stands on its own.
|
|
71
71
|
|
|
72
|
-
export interface
|
|
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
|
-
|
|
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:
|
|
86
|
+
search: (request: SearchRequest) => CustomerSummary[];
|
|
81
87
|
byId: (request: { id: number }) => Customer;
|
|
82
88
|
};
|
|
83
89
|
|
|
84
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
**`client/src/api/models.gen.ts`**, a copy of the api's generated entity types, so the carried imports resolve.
|
|
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.
|
|
90
95
|
|
|
91
|
-
**`client/src/
|
|
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.
|
|
92
97
|
|
|
93
|
-
|
|
98
|
+
```ts
|
|
99
|
+
export * as customer from './customer.gen';
|
|
100
|
+
export * as user from './user.gen';
|
|
101
|
+
```
|
|
94
102
|
|
|
95
|
-
|
|
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`.
|
|
96
104
|
|
|
97
|
-
|
|
105
|
+
**`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.
|
|
98
106
|
|
|
99
|
-
`netsuite
|
|
107
|
+
`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.
|
|
100
108
|
|
|
101
109
|
### Configuration
|
|
102
110
|
|
|
@@ -105,18 +113,16 @@ A hook calls `customerApi.search({ search: 'acme' })` and gets a `Promise<Custom
|
|
|
105
113
|
```json
|
|
106
114
|
{
|
|
107
115
|
"controllers": "api/src/controllers",
|
|
108
|
-
"
|
|
109
|
-
"outFile": "client/src/api/index.gen.ts",
|
|
110
|
-
"appOutFile": "client/src/app.gen.ts",
|
|
116
|
+
"outDir": "client/src/api",
|
|
111
117
|
"scriptsOutFile": "api/src/scripts.gen.ts",
|
|
112
118
|
"clientModule": "@amerilux/netsuite-api/client",
|
|
113
119
|
"wireModule": "@amerilux/netsuite-api",
|
|
114
|
-
"typeImports": { "
|
|
115
|
-
"
|
|
120
|
+
"typeImports": { "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
|
|
121
|
+
"inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts" }
|
|
116
122
|
}
|
|
117
123
|
```
|
|
118
124
|
|
|
119
|
-
Paths are relative to the config file. `
|
|
125
|
+
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
126
|
|
|
121
127
|
## The client at runtime
|
|
122
128
|
|
|
@@ -128,7 +134,13 @@ import { configureApiClient } from '@amerilux/netsuite-api/client';
|
|
|
128
134
|
if (import.meta.env.DEV) configureApiClient({ basePaths: { restlet: '/api/restlet', suitelet: '/api/suitelet' } });
|
|
129
135
|
```
|
|
130
136
|
|
|
131
|
-
A failed call rejects with an `ApiClientError` carrying the envelope's status and message.
|
|
137
|
+
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:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
configureApiClient({ onError: (error, { endpoint }) => showBanner(`${endpoint}: ${error.message}`) });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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
144
|
|
|
133
145
|
## Authorizing calls
|
|
134
146
|
|
|
@@ -163,7 +175,7 @@ export const documentsEndpoints = defineEndpoints({
|
|
|
163
175
|
});
|
|
164
176
|
```
|
|
165
177
|
|
|
166
|
-
In the browser, `
|
|
178
|
+
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
179
|
|
|
168
180
|
## Calling a Suitelet from server code
|
|
169
181
|
|
|
@@ -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;
|
|
26
|
-
* the change up because
|
|
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: `
|
|
42
|
-
* `
|
|
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<
|
|
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
|
|
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>;
|
package/dist/client/apiClient.js
CHANGED
|
@@ -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;
|
|
18
|
-
* the change up because
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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<
|
|
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
|
|
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;
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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
|
|
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';
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
|
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';
|
package/dist-tooling/cli/main.js
CHANGED
|
@@ -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
|
|
11
|
-
' check Exit non-zero when a generated file is missing
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist-tooling/config.d.ts
CHANGED
|
@@ -6,27 +6,28 @@ 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
|
|
10
|
-
|
|
11
|
-
/** The generated client module: every wire shape, every endpoint type, one client per browser-facing controller. */
|
|
12
|
-
outFile: string;
|
|
13
|
-
/** The generated copy of the app file for the client, outside the api folder so pages and components may import it. */
|
|
14
|
-
appOutFile: string;
|
|
9
|
+
/** 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. */
|
|
10
|
+
outDir: string;
|
|
15
11
|
/** The generated server-side `scripts` map: what a repository passes to createSuiteletClient. */
|
|
16
12
|
scriptsOutFile: string;
|
|
17
|
-
/** The specifier the
|
|
13
|
+
/** The specifier the controller modules import `createApiClient` from. */
|
|
18
14
|
clientModule: string;
|
|
19
15
|
/** The specifier the scripts map imports `ScriptRef` from. */
|
|
20
16
|
wireModule: string;
|
|
21
17
|
/**
|
|
22
|
-
* Type imports a controller may carry into
|
|
23
|
-
* controller mapped to the specifier the client resolves: the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
18
|
+
* Type imports a controller may carry into its generated module as imports, as the specifier
|
|
19
|
+
* written in the controller mapped to the specifier the client resolves: the package's server
|
|
20
|
+
* entry mapped to its client entry (for `RawResponse`). A type imported from any module listed
|
|
21
|
+
* in neither this nor `inlineTypes` is an error, because the client could not resolve it.
|
|
26
22
|
*/
|
|
27
23
|
typeImports: Record<string, string>;
|
|
28
|
-
/**
|
|
29
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Type-only files whose declarations are copied into the generated module of every controller
|
|
26
|
+
* that imports from them, as the specifier written in the controller mapped to the file: the
|
|
27
|
+
* generated entity types. A controller module carries the types it names, and what those refer
|
|
28
|
+
* to, so the client needs no copy of the file.
|
|
29
|
+
*/
|
|
30
|
+
inlineTypes: Record<string, string>;
|
|
30
31
|
}
|
|
31
32
|
export interface ResolvedClientGeneratorConfig extends ClientGeneratorConfig {
|
|
32
33
|
/** The config file's directory, or the working directory when no config file exists and the defaults apply. */
|
package/dist-tooling/config.js
CHANGED
|
@@ -2,14 +2,12 @@ import * as nodePath from 'node:path';
|
|
|
2
2
|
export const DEFAULT_CONFIG_FILE_NAME = 'netsuite-api.config.json';
|
|
3
3
|
export const defaultClientGeneratorConfig = {
|
|
4
4
|
controllers: 'api/src/controllers',
|
|
5
|
-
|
|
6
|
-
outFile: 'client/src/api/index.gen.ts',
|
|
7
|
-
appOutFile: 'client/src/app.gen.ts',
|
|
5
|
+
outDir: 'client/src/api',
|
|
8
6
|
scriptsOutFile: 'api/src/scripts.gen.ts',
|
|
9
7
|
clientModule: '@amerilux/netsuite-api/client',
|
|
10
8
|
wireModule: '@amerilux/netsuite-api',
|
|
11
|
-
typeImports: { '
|
|
12
|
-
|
|
9
|
+
typeImports: { '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
|
|
10
|
+
inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts' },
|
|
13
11
|
};
|
|
14
12
|
export class ClientGeneratorConfigError extends Error {
|
|
15
13
|
configPath;
|
|
@@ -21,14 +19,14 @@ export class ClientGeneratorConfigError extends Error {
|
|
|
21
19
|
this.name = 'ClientGeneratorConfigError';
|
|
22
20
|
}
|
|
23
21
|
}
|
|
24
|
-
const stringSettings = ['controllers', '
|
|
25
|
-
const mapSettings = ['typeImports', '
|
|
22
|
+
const stringSettings = ['controllers', 'outDir', 'scriptsOutFile', 'clientModule', 'wireModule'];
|
|
23
|
+
const mapSettings = ['typeImports', 'inlineTypes'];
|
|
26
24
|
function isStringMap(value) {
|
|
27
25
|
return !!value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === 'string' && entry !== '');
|
|
28
26
|
}
|
|
29
27
|
function validateClientGeneratorConfig(raw, configPath) {
|
|
30
28
|
const problems = [];
|
|
31
|
-
const config = { ...defaultClientGeneratorConfig, typeImports: { ...defaultClientGeneratorConfig.typeImports },
|
|
29
|
+
const config = { ...defaultClientGeneratorConfig, typeImports: { ...defaultClientGeneratorConfig.typeImports }, inlineTypes: { ...defaultClientGeneratorConfig.inlineTypes } };
|
|
32
30
|
for (const setting of stringSettings) {
|
|
33
31
|
const value = raw[setting];
|
|
34
32
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|