@sasjs/api 0.5.0 → 0.6.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 +2 -1
- package/build/browser.js +355 -7
- package/build/cjs/corpus/drive.js +119 -0
- package/build/cjs/corpus/runner.js +18 -2
- package/build/cjs/drive/deploy.js +309 -0
- package/build/cjs/drive/service.js +5 -0
- package/build/cjs/host/dispatch.js +8 -0
- package/build/cjs/index.js +10 -1
- package/build/cjs/routes/generated.js +8 -0
- package/build/cjs/routes/overlay.js +21 -3
- package/build/cjs/types.js +11 -1
- package/build/esm/corpus/drive.d.ts +6 -0
- package/build/esm/corpus/drive.d.ts.map +1 -1
- package/build/esm/corpus/drive.js +119 -0
- package/build/esm/corpus/runner.d.ts.map +1 -1
- package/build/esm/corpus/runner.js +18 -2
- package/build/esm/drive/deploy.d.ts +153 -0
- package/build/esm/drive/deploy.d.ts.map +1 -0
- package/build/esm/drive/deploy.js +301 -0
- package/build/esm/drive/service.d.ts +8 -0
- package/build/esm/drive/service.d.ts.map +1 -1
- package/build/esm/drive/service.js +5 -0
- package/build/esm/host/dispatch.d.ts.map +1 -1
- package/build/esm/host/dispatch.js +8 -0
- package/build/esm/index.d.ts +2 -0
- package/build/esm/index.d.ts.map +1 -1
- package/build/esm/index.js +1 -0
- package/build/esm/routes/generated.d.ts.map +1 -1
- package/build/esm/routes/generated.js +8 -0
- package/build/esm/routes/overlay.d.ts.map +1 -1
- package/build/esm/routes/overlay.js +21 -3
- package/build/esm/types.d.ts +19 -1
- package/build/esm/types.d.ts.map +1 -1
- package/build/esm/types.js +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deployTree = exports.isDeployTree = exports.checkDeployTree = exports.measureDeployTree = exports.decodeBase64 = exports.MAX_DEPTH = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = void 0;
|
|
4
|
+
const types_js_1 = require("../types.js");
|
|
5
|
+
const paths_js_1 = require("./paths.js");
|
|
6
|
+
exports.DEPLOY_LIMITS = {
|
|
7
|
+
entries: 10000,
|
|
8
|
+
totalBytes: 100 * 1024 * 1024,
|
|
9
|
+
fileBytes: 20 * 1024 * 1024
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The largest a deploy tree can be on the wire, which is what a host's transport
|
|
13
|
+
* has to admit.
|
|
14
|
+
*
|
|
15
|
+
* The cap is stated in DECODED bytes; the tree travels as JSON in which a file
|
|
16
|
+
* member's contents are base64 - 4/3 of what they write - and a service's are
|
|
17
|
+
* escaped text (~1.04). A tree at `totalBytes` therefore reaches the parser at
|
|
18
|
+
* up to ~134MB, and a transport that admits less refuses first with its own
|
|
19
|
+
* shape, so the cap it was meant to enforce is never consulted. 140MB leaves
|
|
20
|
+
* room for the tree's JSON scaffolding.
|
|
21
|
+
*
|
|
22
|
+
* Only the deploy routes use it: everywhere else a smaller limit applies,
|
|
23
|
+
* because a body is buffered before authentication and nothing else needs the
|
|
24
|
+
* headroom.
|
|
25
|
+
*/
|
|
26
|
+
exports.DEPLOY_PAYLOAD_BYTES = 140 * 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* The body a deploy answers success with.
|
|
29
|
+
*
|
|
30
|
+
* The wording is the published contract - a client may key on the message - so
|
|
31
|
+
* it is one constant here rather than a string in each host. It names
|
|
32
|
+
* `@sasjs/server` because that is the document's example, which predates this
|
|
33
|
+
* package; changing it is a document change, not a host's choice.
|
|
34
|
+
*/
|
|
35
|
+
exports.DEPLOY_SUCCESS = {
|
|
36
|
+
status: 'success',
|
|
37
|
+
message: 'Files deployed successfully to @sasjs/server.'
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* How deep a tree may nest.
|
|
41
|
+
*
|
|
42
|
+
* A deployment is an app: `services/`, `web/`, a folder per service. A few dozen
|
|
43
|
+
* levels is generous, and the number matters because the walk recurses once per
|
|
44
|
+
* level - without a ceiling, a tree nested deeply enough exhausts the stack and
|
|
45
|
+
* throws a `RangeError`, which is neither the `400` nor the `413` this route
|
|
46
|
+
* documents, on input the cap exists to bound.
|
|
47
|
+
*/
|
|
48
|
+
exports.MAX_DEPTH = 32;
|
|
49
|
+
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
50
|
+
/** A base64 body without its padding, which is what the arithmetic needs. */
|
|
51
|
+
const base64Body = (value) => value.replace(/[^A-Za-z0-9+/]/g, '');
|
|
52
|
+
/** The bytes a base64 body decodes to, without decoding it. */
|
|
53
|
+
const base64Length = (value) => Math.floor((base64Body(value).length * 3) / 4);
|
|
54
|
+
/** The bytes a base64 body decodes to, for a store that writes bytes. */
|
|
55
|
+
const decodeBase64 = (value) => {
|
|
56
|
+
const body = base64Body(value);
|
|
57
|
+
const bytes = new Uint8Array(Math.floor((body.length * 3) / 4));
|
|
58
|
+
let buffer = 0;
|
|
59
|
+
let bits = 0;
|
|
60
|
+
let at = 0;
|
|
61
|
+
for (const char of body) {
|
|
62
|
+
buffer = (buffer << 6) | BASE64_ALPHABET.indexOf(char);
|
|
63
|
+
bits += 6;
|
|
64
|
+
if (bits >= 8) {
|
|
65
|
+
bits -= 8;
|
|
66
|
+
bytes[at++] = (buffer >> bits) & 0xff;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return bytes.subarray(0, at);
|
|
70
|
+
};
|
|
71
|
+
exports.decodeBase64 = decodeBase64;
|
|
72
|
+
/** The bytes a string occupies as UTF-8, without an encoding API. */
|
|
73
|
+
const utf8Length = (value) => {
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
for (let at = 0; at < value.length; at++) {
|
|
76
|
+
const code = value.charCodeAt(at);
|
|
77
|
+
if (code < 0x80)
|
|
78
|
+
bytes += 1;
|
|
79
|
+
else if (code < 0x800)
|
|
80
|
+
bytes += 2;
|
|
81
|
+
else if (code >= 0xd800 && code <= 0xdbff && at + 1 < value.length) {
|
|
82
|
+
// a surrogate pair is one code point of four bytes
|
|
83
|
+
bytes += 4;
|
|
84
|
+
at++;
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
bytes += 3;
|
|
88
|
+
}
|
|
89
|
+
return bytes;
|
|
90
|
+
};
|
|
91
|
+
/** The name a member is written under - a service gains the extension. */
|
|
92
|
+
const memberName = (member) => member.type === 'service' ? `${member.name}.sas` : member.name;
|
|
93
|
+
/** The bytes a member writes. */
|
|
94
|
+
const memberBytes = (member) => member.type === 'file' ? base64Length(member.code) : utf8Length(member.code);
|
|
95
|
+
const MEGABYTE = 1024 * 1024;
|
|
96
|
+
/**
|
|
97
|
+
* A MEASURED size, rounded UP to one decimal in binary megabytes.
|
|
98
|
+
*
|
|
99
|
+
* Up, not to nearest: a tree one byte over a 100 MB cap must not read as "100 MB
|
|
100
|
+
* (maximum 100 MB)", which is a refusal that states no overage at all. Rounding
|
|
101
|
+
* up is the direction that never understates what is being refused.
|
|
102
|
+
*
|
|
103
|
+
* Bytes below a megabyte are printed as bytes, because a limit small enough to
|
|
104
|
+
* be measured in bytes should not read as "0 MB" - which is what a host passing
|
|
105
|
+
* a tighter set would otherwise report.
|
|
106
|
+
*/
|
|
107
|
+
const describeSize = (bytes) => bytes < MEGABYTE
|
|
108
|
+
? `${bytes} bytes`
|
|
109
|
+
: `${Math.ceil((bytes / MEGABYTE) * 10) / 10} MB`;
|
|
110
|
+
/**
|
|
111
|
+
* A LIMIT, printed as it is stated: `DEPLOY_LIMITS` holds exact megabytes, so
|
|
112
|
+
* rounding cannot make it disagree with itself.
|
|
113
|
+
*/
|
|
114
|
+
const describeLimit = (bytes) => bytes < MEGABYTE
|
|
115
|
+
? `${bytes} bytes`
|
|
116
|
+
: `${Math.round((bytes / MEGABYTE) * 10) / 10} MB`;
|
|
117
|
+
/** A walk's starting point. */
|
|
118
|
+
const emptyWalk = () => ({
|
|
119
|
+
entries: 0,
|
|
120
|
+
totalBytes: 0,
|
|
121
|
+
largest: { path: '', bytes: 0 }
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* Every member name becomes a path SEGMENT under the deployment root, so a name
|
|
125
|
+
* carrying '/', '\' or '..' is a traversal attempt rather than a name.
|
|
126
|
+
*/
|
|
127
|
+
const isSafeSegment = (name) => name !== '' &&
|
|
128
|
+
name !== '.' &&
|
|
129
|
+
name !== '..' &&
|
|
130
|
+
!/[/\\]/.test(name) &&
|
|
131
|
+
// a Windows drive or a NUL byte is never a segment either
|
|
132
|
+
!/[\0]/.test(name);
|
|
133
|
+
/**
|
|
134
|
+
* Walks a tree, counting it and noting the first unsafe name.
|
|
135
|
+
*
|
|
136
|
+
* A name problem is RECORDED rather than returned, so the walk always finishes:
|
|
137
|
+
* a caller that only wants the numbers gets the size of the whole tree, and the
|
|
138
|
+
* validator decides whether the name matters. Returning early would report a
|
|
139
|
+
* truncated tree as if it were the real one.
|
|
140
|
+
*
|
|
141
|
+
* The one thing that stops the walk is depth: past `MAX_DEPTH` it does not
|
|
142
|
+
* descend, because descending is the work that would exhaust the stack. The
|
|
143
|
+
* level it reached is recorded so the refusal can state it.
|
|
144
|
+
*/
|
|
145
|
+
const walk = (members, parentPath, acc, depth = 1) => {
|
|
146
|
+
for (const member of members) {
|
|
147
|
+
const name = memberName(member);
|
|
148
|
+
const memberPath = parentPath ? `${parentPath}/${name}` : name;
|
|
149
|
+
if (!isSafeSegment(name) && !acc.nameRefusal)
|
|
150
|
+
acc.nameRefusal = {
|
|
151
|
+
code: 400,
|
|
152
|
+
status: 'failure',
|
|
153
|
+
message: `Invalid member name: '${name}'. Member names must be single path segments (no '/', '\\', '..').`
|
|
154
|
+
};
|
|
155
|
+
acc.entries++;
|
|
156
|
+
if (member.type === 'folder') {
|
|
157
|
+
if (depth + 1 > exports.MAX_DEPTH) {
|
|
158
|
+
if (!acc.tooDeep)
|
|
159
|
+
acc.tooDeep = depth + 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
walk(member.members, memberPath, acc, depth + 1);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const bytes = memberBytes(member);
|
|
166
|
+
acc.totalBytes += bytes;
|
|
167
|
+
if (bytes > acc.largest.bytes)
|
|
168
|
+
acc.largest = { path: memberPath, bytes };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Measures a tree without touching a drive.
|
|
173
|
+
*
|
|
174
|
+
* Every member is counted, whatever its name: this answers "how big is this
|
|
175
|
+
* tree", and a caller that also needs the names checked is `checkDeployTree`.
|
|
176
|
+
*/
|
|
177
|
+
const measureDeployTree = (members) => {
|
|
178
|
+
const acc = emptyWalk();
|
|
179
|
+
walk(members, '', acc);
|
|
180
|
+
return {
|
|
181
|
+
entries: acc.entries,
|
|
182
|
+
totalBytes: acc.totalBytes,
|
|
183
|
+
largest: acc.largest
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
exports.measureDeployTree = measureDeployTree;
|
|
187
|
+
/**
|
|
188
|
+
* The refusal a file tree earns, or `null` when it can be written.
|
|
189
|
+
*
|
|
190
|
+
* Names are checked first, then the dimensions in a fixed order - entries,
|
|
191
|
+
* total, single file - so a tree breaching two of them is refused for the same
|
|
192
|
+
* one every time, which is what lets a caller key on the message and a test pin
|
|
193
|
+
* it.
|
|
194
|
+
*
|
|
195
|
+
* `limits` defaults to `DEPLOY_LIMITS`; a host may pass its own, stricter set.
|
|
196
|
+
*/
|
|
197
|
+
const checkDeployTree = (members, limits = exports.DEPLOY_LIMITS) => {
|
|
198
|
+
const measured = emptyWalk();
|
|
199
|
+
walk(members, '', measured);
|
|
200
|
+
if (measured.nameRefusal)
|
|
201
|
+
return measured.nameRefusal;
|
|
202
|
+
if (measured.tooDeep)
|
|
203
|
+
return {
|
|
204
|
+
code: 413,
|
|
205
|
+
status: 'failure',
|
|
206
|
+
message: `The file tree is nested too deeply: ${measured.tooDeep} levels (maximum ${exports.MAX_DEPTH}).`
|
|
207
|
+
};
|
|
208
|
+
if (measured.entries > limits.entries)
|
|
209
|
+
return {
|
|
210
|
+
code: 413,
|
|
211
|
+
status: 'failure',
|
|
212
|
+
message: `The file tree exceeds the deploy limit: ${measured.entries} entries (maximum ${limits.entries}).`
|
|
213
|
+
};
|
|
214
|
+
if (measured.totalBytes > limits.totalBytes)
|
|
215
|
+
return {
|
|
216
|
+
code: 413,
|
|
217
|
+
status: 'failure',
|
|
218
|
+
message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${describeLimit(limits.totalBytes)}).`
|
|
219
|
+
};
|
|
220
|
+
if (measured.largest.bytes > limits.fileBytes)
|
|
221
|
+
return {
|
|
222
|
+
code: 413,
|
|
223
|
+
status: 'failure',
|
|
224
|
+
message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${describeLimit(limits.fileBytes)} per file).`
|
|
225
|
+
};
|
|
226
|
+
return null;
|
|
227
|
+
};
|
|
228
|
+
exports.checkDeployTree = checkDeployTree;
|
|
229
|
+
/**
|
|
230
|
+
* The tree's shape, as the wire format states it.
|
|
231
|
+
*
|
|
232
|
+
* A port of `isFileTree` from `@sasjs/utils`, which is what sasjs/server
|
|
233
|
+
* validates with: a tree is an object with a `members` array, and every member
|
|
234
|
+
* is a folder with its own members, or a service/file with a string `code` and
|
|
235
|
+
* a string `name`. Anything else is the `400` the routes already answer.
|
|
236
|
+
*/
|
|
237
|
+
const isDeployTree = (value) => {
|
|
238
|
+
if (!value || typeof value !== 'object')
|
|
239
|
+
return false;
|
|
240
|
+
const members = value.members;
|
|
241
|
+
return Array.isArray(members) && members.every(isDeployMember);
|
|
242
|
+
};
|
|
243
|
+
exports.isDeployTree = isDeployTree;
|
|
244
|
+
const isDeployMember = (value) => {
|
|
245
|
+
if (!value || typeof value !== 'object')
|
|
246
|
+
return false;
|
|
247
|
+
const member = value;
|
|
248
|
+
if (typeof member.name !== 'string')
|
|
249
|
+
return false;
|
|
250
|
+
if (member.type === 'folder')
|
|
251
|
+
return Array.isArray(member.members) && member.members.every(isDeployMember);
|
|
252
|
+
if (member.type === 'service' || member.type === 'file')
|
|
253
|
+
return typeof member.code === 'string';
|
|
254
|
+
return false;
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Deploys a tree into `appLoc`, or refuses without writing.
|
|
258
|
+
*
|
|
259
|
+
* The order is the promise: shape, then containment, then the cap and the names,
|
|
260
|
+
* and only then the first write. `appLoc` is caller-supplied and every segment
|
|
261
|
+
* must stay a segment, so it is resolved against the drive root and refused when
|
|
262
|
+
* it leaves it - joined naively, a `..` would write outside the drive.
|
|
263
|
+
*
|
|
264
|
+
* A write that fails part-way answers the deploy routes' `500`; the tree before
|
|
265
|
+
* the failure is already on the drive, which the message does not hide.
|
|
266
|
+
*/
|
|
267
|
+
const deployTree = async (store, appLoc, tree, limits = exports.DEPLOY_LIMITS) => {
|
|
268
|
+
if (!(0, exports.isDeployTree)(tree))
|
|
269
|
+
throw new types_js_1.ApiError(400, 'Provided not supported data format.', {
|
|
270
|
+
status: 'failure',
|
|
271
|
+
message: 'Provided not supported data format.'
|
|
272
|
+
});
|
|
273
|
+
const root = (0, paths_js_1.normaliseDrivePath)(appLoc);
|
|
274
|
+
if (root === null)
|
|
275
|
+
throw new types_js_1.ApiError(400, 'appLoc cannot be outside drive.', {
|
|
276
|
+
status: 'failure',
|
|
277
|
+
message: 'appLoc cannot be outside drive.'
|
|
278
|
+
});
|
|
279
|
+
const refusal = (0, exports.checkDeployTree)(tree.members, limits);
|
|
280
|
+
// the body is the envelope WITHOUT the refusal's own `code`: that field is
|
|
281
|
+
// how a host turns the refusal into a status, and it is not sent
|
|
282
|
+
if (refusal)
|
|
283
|
+
throw new types_js_1.ApiError(refusal.code, refusal.message, {
|
|
284
|
+
status: refusal.status,
|
|
285
|
+
message: refusal.message
|
|
286
|
+
});
|
|
287
|
+
try {
|
|
288
|
+
await writeMembers(store, tree.members, root);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
throw new types_js_1.ApiError(500, 'Deployment failed!', {
|
|
292
|
+
status: 'failure',
|
|
293
|
+
message: 'Deployment failed!'
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
exports.deployTree = deployTree;
|
|
298
|
+
/** Writes every member under `parentPath`, depth first. */
|
|
299
|
+
const writeMembers = async (store, members, parentPath) => {
|
|
300
|
+
for (const member of members) {
|
|
301
|
+
const path = `${parentPath}/${memberName(member)}`;
|
|
302
|
+
if (member.type === 'folder') {
|
|
303
|
+
await store.mkdirp(path);
|
|
304
|
+
await writeMembers(store, member.members, path);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
await store.write(path, member.type === 'file' ? (0, exports.decodeBase64)(member.code) : member.code);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createDriveService = void 0;
|
|
4
4
|
const types_js_1 = require("../types.js");
|
|
5
|
+
const deploy_js_1 = require("./deploy.js");
|
|
5
6
|
const paths_js_1 = require("./paths.js");
|
|
6
7
|
/** A path a file operation may act on, or the refusal the operation answers. */
|
|
7
8
|
const filePath = (input, outside) => {
|
|
@@ -145,6 +146,10 @@ const createDriveService = (store) => {
|
|
|
145
146
|
if (!stat.directory)
|
|
146
147
|
throw new types_js_1.ApiError(400, 'Not a Folder.');
|
|
147
148
|
return { status: 'success', tree: await treeOf(path) };
|
|
149
|
+
},
|
|
150
|
+
async deploy(appLoc, tree) {
|
|
151
|
+
await (0, deploy_js_1.deployTree)(store, appLoc, tree);
|
|
152
|
+
return { status: 'success' };
|
|
148
153
|
}
|
|
149
154
|
};
|
|
150
155
|
};
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HANDLED_ROUTES = exports.dispatch = void 0;
|
|
4
4
|
const index_js_1 = require("../routes/index.js");
|
|
5
5
|
const types_js_1 = require("../types.js");
|
|
6
|
+
const deploy_js_1 = require("../drive/deploy.js");
|
|
6
7
|
/**
|
|
7
8
|
* The parameters the table marks required, checked before the handler runs.
|
|
8
9
|
*
|
|
@@ -129,6 +130,12 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
129
130
|
status: 200,
|
|
130
131
|
body: await drive.rename(String(request.body?.oldPath || ''), String(request.body?.newPath || ''))
|
|
131
132
|
};
|
|
133
|
+
case 'POST /SASjsApi/drive/deploy': {
|
|
134
|
+
// `streamWebFolder` is the host's: publishing an app stream is not a drive
|
|
135
|
+
// operation, and a host that has no app stream ignores it.
|
|
136
|
+
await drive.deploy(String(body.appLoc ?? ''), body.fileTree);
|
|
137
|
+
return { status: 200, body: deploy_js_1.DEPLOY_SUCCESS };
|
|
138
|
+
}
|
|
132
139
|
case 'GET /SASjsApi/drive/filetree':
|
|
133
140
|
return { status: 200, body: await drive.tree(query._folderPath) };
|
|
134
141
|
default: {
|
|
@@ -152,6 +159,7 @@ exports.HANDLED_ROUTES = [
|
|
|
152
159
|
'DELETE /SASjsApi/drive/folder',
|
|
153
160
|
'POST /SASjsApi/drive/rename',
|
|
154
161
|
'GET /SASjsApi/drive/filetree',
|
|
162
|
+
'POST /SASjsApi/drive/deploy',
|
|
155
163
|
'POST /SASjsApi/auth/token',
|
|
156
164
|
'POST /SASjsApi/auth/refresh',
|
|
157
165
|
'POST /SASjsApi/auth/logout',
|
package/build/cjs/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.servicesFor = exports.runCase = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
|
|
17
|
+
exports.servicesFor = exports.runCase = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.measureDeployTree = exports.isDeployTree = exports.deployTree = exports.decodeBase64 = exports.checkDeployTree = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
|
|
18
18
|
/**
|
|
19
19
|
* `@sasjs/api` - the SASjs Server API described once.
|
|
20
20
|
*
|
|
@@ -39,6 +39,15 @@ Object.defineProperty(exports, "basename", { enumerable: true, get: function ()
|
|
|
39
39
|
Object.defineProperty(exports, "isRoot", { enumerable: true, get: function () { return paths_js_1.isRoot; } });
|
|
40
40
|
var service_js_1 = require("./drive/service.js");
|
|
41
41
|
Object.defineProperty(exports, "createDriveService", { enumerable: true, get: function () { return service_js_1.createDriveService; } });
|
|
42
|
+
var deploy_js_1 = require("./drive/deploy.js");
|
|
43
|
+
Object.defineProperty(exports, "DEPLOY_LIMITS", { enumerable: true, get: function () { return deploy_js_1.DEPLOY_LIMITS; } });
|
|
44
|
+
Object.defineProperty(exports, "DEPLOY_PAYLOAD_BYTES", { enumerable: true, get: function () { return deploy_js_1.DEPLOY_PAYLOAD_BYTES; } });
|
|
45
|
+
Object.defineProperty(exports, "DEPLOY_SUCCESS", { enumerable: true, get: function () { return deploy_js_1.DEPLOY_SUCCESS; } });
|
|
46
|
+
Object.defineProperty(exports, "checkDeployTree", { enumerable: true, get: function () { return deploy_js_1.checkDeployTree; } });
|
|
47
|
+
Object.defineProperty(exports, "decodeBase64", { enumerable: true, get: function () { return deploy_js_1.decodeBase64; } });
|
|
48
|
+
Object.defineProperty(exports, "deployTree", { enumerable: true, get: function () { return deploy_js_1.deployTree; } });
|
|
49
|
+
Object.defineProperty(exports, "isDeployTree", { enumerable: true, get: function () { return deploy_js_1.isDeployTree; } });
|
|
50
|
+
Object.defineProperty(exports, "measureDeployTree", { enumerable: true, get: function () { return deploy_js_1.measureDeployTree; } });
|
|
42
51
|
var memory_store_js_1 = require("./drive/memory-store.js");
|
|
43
52
|
Object.defineProperty(exports, "MemoryDriveStore", { enumerable: true, get: function () { return memory_store_js_1.MemoryDriveStore; } });
|
|
44
53
|
var index_js_1 = require("./routes/index.js");
|
|
@@ -472,6 +472,10 @@ exports.GENERATED_ROUTES = [
|
|
|
472
472
|
status: 400,
|
|
473
473
|
description: 'Invalid Format'
|
|
474
474
|
},
|
|
475
|
+
{
|
|
476
|
+
status: 413,
|
|
477
|
+
description: 'File tree exceeds the deploy limit'
|
|
478
|
+
},
|
|
475
479
|
{
|
|
476
480
|
status: 500,
|
|
477
481
|
description: 'Execution Error'
|
|
@@ -502,6 +506,10 @@ exports.GENERATED_ROUTES = [
|
|
|
502
506
|
status: 400,
|
|
503
507
|
description: 'Invalid Format'
|
|
504
508
|
},
|
|
509
|
+
{
|
|
510
|
+
status: 413,
|
|
511
|
+
description: 'File tree exceeds the deploy limit'
|
|
512
|
+
},
|
|
505
513
|
{
|
|
506
514
|
status: 500,
|
|
507
515
|
description: 'Execution Error'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.METHODS = exports.OVERLAY = exports.TAG_CAPABILITIES = void 0;
|
|
4
|
+
const deploy_js_1 = require("../drive/deploy.js");
|
|
4
5
|
/** The default capability for each tag, overridden per route where it differs. */
|
|
5
6
|
exports.TAG_CAPABILITIES = {
|
|
6
7
|
Drive: ['storage'],
|
|
@@ -32,7 +33,7 @@ const HOST_CONFIG = "The body is the host's own configuration - ports, run times
|
|
|
32
33
|
const PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
|
|
33
34
|
const HOST_OWNED = "The host owns this report: it describes the host's own process and files, which a browser build has no equivalent of.";
|
|
34
35
|
const WEB_BUNDLE = 'The host serves its own web bundle; there is no API decision here for this package to answer.';
|
|
35
|
-
const
|
|
36
|
+
const ARCHIVE = "Expanding the uploaded archive is the host's transport work - multer and a zip library on the server, `DecompressionStream` in a worker - and the host hands this package the tree it produces, which is the same tree `POST /SASjsApi/drive/deploy` takes.";
|
|
36
37
|
const UNPUBLISH = 'Unpublishing inverts the deploy route, so the two land together.';
|
|
37
38
|
const success = { status: 'success' };
|
|
38
39
|
const user = {
|
|
@@ -135,10 +136,27 @@ exports.OVERLAY = {
|
|
|
135
136
|
implemented: true,
|
|
136
137
|
responses: { 200: { status: 'success', tree: {} } }
|
|
137
138
|
},
|
|
138
|
-
|
|
139
|
+
// The deploy routes answer an ENVELOPE, unlike the file routes above: their
|
|
140
|
+
// `400` is `{status:'failure', message}` rather than the message as text, and
|
|
141
|
+
// `413` is the cap refusing the tree before anything is written.
|
|
142
|
+
// `"appLoc" is required` and `"fileTree" is required` are the other `400` this
|
|
143
|
+
// route sends - a missing body property, worded by the table's own
|
|
144
|
+
// validation - so the body annotated here is the substantial one.
|
|
145
|
+
'POST /SASjsApi/drive/deploy': {
|
|
146
|
+
implemented: true,
|
|
147
|
+
responses: {
|
|
148
|
+
200: deploy_js_1.DEPLOY_SUCCESS,
|
|
149
|
+
400: {
|
|
150
|
+
status: 'failure',
|
|
151
|
+
message: 'Provided not supported data format.'
|
|
152
|
+
},
|
|
153
|
+
413: 'The file tree exceeds the deploy limit.',
|
|
154
|
+
500: { status: 'failure', message: 'Deployment failed!' }
|
|
155
|
+
}
|
|
156
|
+
},
|
|
139
157
|
'POST /SASjsApi/drive/deploy/upload': {
|
|
140
158
|
implemented: false,
|
|
141
|
-
reason:
|
|
159
|
+
reason: ARCHIVE
|
|
142
160
|
},
|
|
143
161
|
// `404` is a pair - the registry name and the appLoc it was published from -
|
|
144
162
|
// that matches no entry, and the drive's content is deliberately left alone.
|
package/build/cjs/types.js
CHANGED
|
@@ -16,9 +16,19 @@ exports.isApiError = exports.ApiError = void 0;
|
|
|
16
16
|
* corpus can assert on the pair.
|
|
17
17
|
*/
|
|
18
18
|
class ApiError extends Error {
|
|
19
|
-
constructor(status, message
|
|
19
|
+
constructor(status, message,
|
|
20
|
+
/**
|
|
21
|
+
* The body the route renders instead of the bare message.
|
|
22
|
+
*
|
|
23
|
+
* Most drive routes send the message as text (`res.status(code).send(err)`),
|
|
24
|
+
* and that stays the default: a body here is for the routes that answer an
|
|
25
|
+
* envelope, which today is the two deploy routes and their
|
|
26
|
+
* `{status: 'failure', message}`.
|
|
27
|
+
*/
|
|
28
|
+
body) {
|
|
20
29
|
super(message);
|
|
21
30
|
this.status = status;
|
|
31
|
+
this.body = body;
|
|
22
32
|
/** Told apart by marker rather than `instanceof` - see `isApiError`. */
|
|
23
33
|
this.apiError = true;
|
|
24
34
|
this.name = 'ApiError';
|
|
@@ -23,6 +23,12 @@ export interface CorpusCase {
|
|
|
23
23
|
seed?: Record<string, string | Uint8Array>;
|
|
24
24
|
/** The identity the case starts from; the default fixture when omitted. */
|
|
25
25
|
identity?: IdentityFixture;
|
|
26
|
+
/**
|
|
27
|
+
* The drive refuses every write, so a case can pin the refusal a route answers
|
|
28
|
+
* when storing fails - `POST /drive/deploy` documents a `500`, and a mock drive
|
|
29
|
+
* that always accepts writes could never produce one.
|
|
30
|
+
*/
|
|
31
|
+
unwritable?: boolean;
|
|
26
32
|
request: ApiRequest;
|
|
27
33
|
expect: {
|
|
28
34
|
status: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;KAChB,CAAA;CACF;
|
|
1
|
+
{"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;KAChB,CAAA;CACF;AAmBD,eAAO,MAAM,YAAY,EAAE,UAAU,EAqapC,CAAA"}
|
|
@@ -5,6 +5,12 @@ const success = { status: 'success' };
|
|
|
5
5
|
* host that does not have one. An explicit byte array is both.
|
|
6
6
|
*/
|
|
7
7
|
const bytes = (...values) => Uint8Array.from(values);
|
|
8
|
+
/** A flat tree of `count` empty file members, for the cap's entry dimension. */
|
|
9
|
+
const manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
|
|
10
|
+
name: `f${at}.txt`,
|
|
11
|
+
type: 'file',
|
|
12
|
+
code: ''
|
|
13
|
+
}));
|
|
8
14
|
export const DRIVE_CORPUS = [
|
|
9
15
|
{
|
|
10
16
|
id: 'create-file',
|
|
@@ -294,6 +300,119 @@ export const DRIVE_CORPUS = [
|
|
|
294
300
|
}
|
|
295
301
|
}
|
|
296
302
|
},
|
|
303
|
+
{
|
|
304
|
+
id: 'deploy-tree',
|
|
305
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
306
|
+
description: 'writes a whole tree under the appLoc it names',
|
|
307
|
+
request: {
|
|
308
|
+
body: {
|
|
309
|
+
appLoc: '/Public/apps/demo',
|
|
310
|
+
fileTree: {
|
|
311
|
+
members: [
|
|
312
|
+
{
|
|
313
|
+
name: 'services',
|
|
314
|
+
type: 'folder',
|
|
315
|
+
members: [{ name: 'run', type: 'service', code: '%put hi;' }]
|
|
316
|
+
},
|
|
317
|
+
{ name: 'index.html', type: 'file', code: 'PGgxPkhlbGxvPC9oMT4=' }
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
expect: {
|
|
323
|
+
status: 200,
|
|
324
|
+
body: {
|
|
325
|
+
status: 'success',
|
|
326
|
+
message: 'Files deployed successfully to @sasjs/server.'
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'deploy-refused-by-entry-count',
|
|
332
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
333
|
+
description: 'refuses a tree over the entry cap, naming the figure and the cap',
|
|
334
|
+
request: {
|
|
335
|
+
body: {
|
|
336
|
+
appLoc: '/Public/apps/many',
|
|
337
|
+
fileTree: { members: manyMembers(10001) }
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
expect: {
|
|
341
|
+
status: 413,
|
|
342
|
+
body: {
|
|
343
|
+
status: 'failure',
|
|
344
|
+
message: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
|
|
345
|
+
},
|
|
346
|
+
error: 'The file tree exceeds the deploy limit: 10001 entries (maximum 10000).'
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: 'deploy-refused-by-name',
|
|
351
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
352
|
+
description: 'refuses a member name that would escape the deployment root',
|
|
353
|
+
request: {
|
|
354
|
+
body: {
|
|
355
|
+
appLoc: '/Public/apps/hostile',
|
|
356
|
+
fileTree: {
|
|
357
|
+
members: [{ name: '../escape', type: 'service', code: '%put hi;' }]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
expect: {
|
|
362
|
+
status: 400,
|
|
363
|
+
body: {
|
|
364
|
+
status: 'failure',
|
|
365
|
+
message: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
|
|
366
|
+
},
|
|
367
|
+
error: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'deploy-refused-by-shape',
|
|
372
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
373
|
+
description: 'refuses a fileTree that is not a tree, in the routes own envelope',
|
|
374
|
+
request: { body: { appLoc: '/Public/apps/bad', fileTree: { members: 1 } } },
|
|
375
|
+
expect: {
|
|
376
|
+
status: 400,
|
|
377
|
+
body: {
|
|
378
|
+
status: 'failure',
|
|
379
|
+
message: 'Provided not supported data format.'
|
|
380
|
+
},
|
|
381
|
+
error: 'Provided not supported data format.'
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: 'deploy-refused-by-store',
|
|
386
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
387
|
+
description: 'answers the routes 500 when the drive refuses the write',
|
|
388
|
+
unwritable: true,
|
|
389
|
+
request: {
|
|
390
|
+
body: {
|
|
391
|
+
appLoc: '/Public/apps/demo',
|
|
392
|
+
fileTree: {
|
|
393
|
+
members: [{ name: 'run', type: 'service', code: '%put hi;' }]
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
expect: {
|
|
398
|
+
status: 500,
|
|
399
|
+
body: { status: 'failure', message: 'Deployment failed!' },
|
|
400
|
+
error: 'Deployment failed!'
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: 'deploy-refused-outside-drive',
|
|
405
|
+
route: 'POST /SASjsApi/drive/deploy',
|
|
406
|
+
description: 'refuses an appLoc that resolves outside the drive',
|
|
407
|
+
request: {
|
|
408
|
+
body: { appLoc: '/../outside', fileTree: { members: [] } }
|
|
409
|
+
},
|
|
410
|
+
expect: {
|
|
411
|
+
status: 400,
|
|
412
|
+
body: { status: 'failure', message: 'appLoc cannot be outside drive.' },
|
|
413
|
+
error: 'appLoc cannot be outside drive.'
|
|
414
|
+
}
|
|
415
|
+
},
|
|
297
416
|
{
|
|
298
417
|
id: 'bytes-round-trip',
|
|
299
418
|
route: 'GET /SASjsApi/drive/file',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAQnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAQnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,QASjD,CAAA;AAkBF;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAepB,CAAA"}
|
|
@@ -12,9 +12,25 @@ import { isApiError } from '../types.js';
|
|
|
12
12
|
* mid-flow can declare the tokens and codes it begins with.
|
|
13
13
|
*/
|
|
14
14
|
export const servicesFor = (testCase) => ({
|
|
15
|
-
drive: createDriveService(
|
|
15
|
+
drive: createDriveService(testCase.unwritable
|
|
16
|
+
? new RefusingDriveStore(testCase.seed || {})
|
|
17
|
+
: new MemoryDriveStore(testCase.seed || {})),
|
|
16
18
|
identity: createIdentityService(new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY))
|
|
17
19
|
});
|
|
20
|
+
/**
|
|
21
|
+
* A drive that reads but refuses every write.
|
|
22
|
+
*
|
|
23
|
+
* Only the corpus uses it: `POST /drive/deploy` answers `500` when storing
|
|
24
|
+
* fails, and a case for that answer needs a store that fails.
|
|
25
|
+
*/
|
|
26
|
+
class RefusingDriveStore extends MemoryDriveStore {
|
|
27
|
+
async write() {
|
|
28
|
+
throw new Error('the store refused the write');
|
|
29
|
+
}
|
|
30
|
+
async mkdirp() {
|
|
31
|
+
throw new Error('the store refused the write');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
/**
|
|
19
35
|
* Runs a corpus case against the drive the case seeds itself, or against
|
|
20
36
|
* services a caller supplies - that is how a host (or a failing store) is put
|
|
@@ -33,7 +49,7 @@ export const runCase = async (testCase, services = servicesFor(testCase)) => {
|
|
|
33
49
|
// only a refusal the API decided on becomes a status; anything else is a
|
|
34
50
|
// broken host, and reporting it as a 500 would hide it from the suite
|
|
35
51
|
if (isApiError(error))
|
|
36
|
-
return { status: error.status, error: error.message };
|
|
52
|
+
return { status: error.status, body: error.body, error: error.message };
|
|
37
53
|
throw error;
|
|
38
54
|
}
|
|
39
55
|
};
|