@sasjs/api 0.0.0-stage → 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/LICENCE.md +21 -0
- package/README.md +87 -2
- package/build/browser.js +2469 -0
- package/build/cjs/corpus/drive.js +288 -0
- package/build/cjs/corpus/runner.js +35 -0
- package/build/cjs/drive/memory-store.js +126 -0
- package/build/cjs/drive/paths.js +82 -0
- package/build/cjs/drive/service.js +143 -0
- package/build/cjs/host/dispatch.js +102 -0
- package/build/cjs/index.js +58 -0
- package/build/cjs/routes/generated.js +1603 -0
- package/build/cjs/routes/index.js +111 -0
- package/build/cjs/routes/overlay.js +75 -0
- package/build/cjs/types.js +35 -0
- package/build/esm/corpus/drive.d.ts +33 -0
- package/build/esm/corpus/drive.d.ts.map +1 -0
- package/build/esm/corpus/drive.js +285 -0
- package/build/esm/corpus/runner.d.ts +27 -0
- package/build/esm/corpus/runner.d.ts.map +1 -0
- package/build/esm/corpus/runner.js +30 -0
- package/build/esm/drive/memory-store.d.ts +37 -0
- package/build/esm/drive/memory-store.d.ts.map +1 -0
- package/build/esm/drive/memory-store.js +122 -0
- package/build/esm/drive/paths.d.ts +49 -0
- package/build/esm/drive/paths.d.ts.map +1 -0
- package/build/esm/drive/paths.js +73 -0
- package/build/esm/drive/service.d.ts +42 -0
- package/build/esm/drive/service.d.ts.map +1 -0
- package/build/esm/drive/service.js +139 -0
- package/build/esm/host/dispatch.d.ts +24 -0
- package/build/esm/host/dispatch.d.ts.map +1 -0
- package/build/esm/host/dispatch.js +98 -0
- package/build/esm/index.d.ts +31 -0
- package/build/esm/index.d.ts.map +1 -0
- package/build/esm/index.js +24 -0
- package/build/esm/package.json +3 -0
- package/build/esm/routes/generated.d.ts +14 -0
- package/build/esm/routes/generated.d.ts.map +1 -0
- package/build/esm/routes/generated.js +1600 -0
- package/build/esm/routes/index.d.ts +18 -0
- package/build/esm/routes/index.d.ts.map +1 -0
- package/build/esm/routes/index.js +106 -0
- package/build/esm/routes/overlay.d.ts +40 -0
- package/build/esm/routes/overlay.d.ts.map +1 -0
- package/build/esm/routes/overlay.js +72 -0
- package/build/esm/types.d.ts +110 -0
- package/build/esm/types.d.ts.map +1 -0
- package/build/esm/types.js +30 -0
- package/package.json +55 -4
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findRoute = exports.routeById = exports.ROUTES = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The route table, and the one matcher both hosts dispatch with.
|
|
6
|
+
*
|
|
7
|
+
* `GENERATED_ROUTES` is the OpenAPI document, `OVERLAY` is the part a document
|
|
8
|
+
* cannot carry, and this file is where they meet. Importing it validates the
|
|
9
|
+
* join as a side effect: an overlay entry naming an operation the document does
|
|
10
|
+
* not have is a stale entry, and it throws here rather than being silently
|
|
11
|
+
* ignored - the failure mode that lets a "documented" behaviour rot.
|
|
12
|
+
*/
|
|
13
|
+
const generated_js_1 = require("./generated.js");
|
|
14
|
+
const overlay_js_1 = require("./overlay.js");
|
|
15
|
+
const merge = (generated) => {
|
|
16
|
+
const overlay = overlay_js_1.OVERLAY[generated.id] || {};
|
|
17
|
+
const documented = new Map(generated.responses.map((r) => [r.status, r.description]));
|
|
18
|
+
// The document states the statuses and the overlay only annotates the ones a
|
|
19
|
+
// client keys a body on. Naming a status the document does not state is a
|
|
20
|
+
// build failure: the alternative - listing it anyway - advertises a status
|
|
21
|
+
// nothing can observe without the parity test failing, which is a documented
|
|
22
|
+
// wish rather than a contract. A host that genuinely answers a status the
|
|
23
|
+
// document omits needs the document corrected in sasjs/server.
|
|
24
|
+
for (const status of Object.keys(overlay.responses || {}).map(Number)) {
|
|
25
|
+
if (!documented.has(status)) {
|
|
26
|
+
throw new Error(`src/routes/overlay.ts annotates status ${status} for ${generated.id}, which the OpenAPI document does not state. Correct the document in sasjs/server and re-vendor spec/swagger.yaml, or drop the annotation.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const responses = [...documented.keys()]
|
|
30
|
+
.sort((a, b) => a - b)
|
|
31
|
+
.map((status) => {
|
|
32
|
+
const body = overlay.responses ? overlay.responses[status] : undefined;
|
|
33
|
+
return {
|
|
34
|
+
status,
|
|
35
|
+
description: documented.get(status) || '',
|
|
36
|
+
...(body === undefined ? {} : { body })
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
...generated,
|
|
41
|
+
responses,
|
|
42
|
+
capabilities: overlay.capabilities || overlay_js_1.TAG_CAPABILITIES[generated.tag] || [],
|
|
43
|
+
implemented: overlay.implemented === true
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
exports.ROUTES = generated_js_1.GENERATED_ROUTES.map(merge);
|
|
47
|
+
const known = new Set(exports.ROUTES.map((route) => route.id));
|
|
48
|
+
for (const id of Object.keys(overlay_js_1.OVERLAY))
|
|
49
|
+
if (!known.has(id))
|
|
50
|
+
throw new Error(`src/routes/overlay.ts names an operation the OpenAPI document does not have: ${id}. Remove the entry or refresh spec/swagger.yaml.`);
|
|
51
|
+
const byId = new Map(exports.ROUTES.map((route) => [route.id, route]));
|
|
52
|
+
/** The route with this `METHOD /path` identity, if the document has one. */
|
|
53
|
+
const routeById = (id) => byId.get(id);
|
|
54
|
+
exports.routeById = routeById;
|
|
55
|
+
const escape = (segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
/**
|
|
57
|
+
* Compiles a documented path into a matcher. The table is static, so this runs
|
|
58
|
+
* once at import; `findRoute` then tests exact paths first (most of the API is
|
|
59
|
+
* static) and only falls through to the templates for `{uid}`-style paths.
|
|
60
|
+
*/
|
|
61
|
+
const compiled = exports.ROUTES.filter((route) => route.path.includes('{')).map((route) => {
|
|
62
|
+
const keys = [];
|
|
63
|
+
const pattern = route.path
|
|
64
|
+
.split('/')
|
|
65
|
+
.map((segment) => {
|
|
66
|
+
if (segment.startsWith('{') && segment.endsWith('}')) {
|
|
67
|
+
keys.push(segment.slice(1, -1));
|
|
68
|
+
return '([^/]+)';
|
|
69
|
+
}
|
|
70
|
+
return escape(segment);
|
|
71
|
+
})
|
|
72
|
+
.join('/');
|
|
73
|
+
return { route, keys, regex: new RegExp('^' + pattern + '/?$') };
|
|
74
|
+
});
|
|
75
|
+
const exact = new Map();
|
|
76
|
+
for (const route of exports.ROUTES)
|
|
77
|
+
if (!route.path.includes('{'))
|
|
78
|
+
exact.set(route.method + ' ' + route.path, route);
|
|
79
|
+
/**
|
|
80
|
+
* The route a request names, or undefined when the API has no such operation.
|
|
81
|
+
*
|
|
82
|
+
* A host that gets undefined answers 404 "not part of the API"; a host that gets
|
|
83
|
+
* a route it cannot serve answers 501 naming the capability. Keeping those two
|
|
84
|
+
* apart is the difference between a client's bug and a host's limit.
|
|
85
|
+
*/
|
|
86
|
+
const findRoute = (method, path) => {
|
|
87
|
+
const verb = method.toUpperCase();
|
|
88
|
+
const clean = path.split('?')[0].replace(/\/+$/, '') || '/';
|
|
89
|
+
const hit = exact.get(verb + ' ' + clean);
|
|
90
|
+
if (hit)
|
|
91
|
+
return { route: hit, params: {} };
|
|
92
|
+
for (const candidate of compiled) {
|
|
93
|
+
if (candidate.route.method !== verb)
|
|
94
|
+
continue;
|
|
95
|
+
const match = candidate.regex.exec(clean);
|
|
96
|
+
if (!match)
|
|
97
|
+
continue;
|
|
98
|
+
const params = {};
|
|
99
|
+
candidate.keys.forEach((key, index) => {
|
|
100
|
+
try {
|
|
101
|
+
params[key] = decodeURIComponent(match[index + 1]);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
params[key] = match[index + 1];
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return { route: candidate.route, params };
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
};
|
|
111
|
+
exports.findRoute = findRoute;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.METHODS = exports.OVERLAY = exports.TAG_CAPABILITIES = void 0;
|
|
4
|
+
/** The default capability for each tag, overridden per route where it differs. */
|
|
5
|
+
exports.TAG_CAPABILITIES = {
|
|
6
|
+
Drive: ['storage'],
|
|
7
|
+
STP: ['runtime', 'storage'],
|
|
8
|
+
Code: ['runtime'],
|
|
9
|
+
Session: ['runtime'],
|
|
10
|
+
Info: [],
|
|
11
|
+
Admin: ['storage'],
|
|
12
|
+
Auth: ['identity'],
|
|
13
|
+
'Auth Config': ['identity'],
|
|
14
|
+
User: ['identity'],
|
|
15
|
+
Group: ['identity'],
|
|
16
|
+
Permission: ['identity'],
|
|
17
|
+
Client: ['identity'],
|
|
18
|
+
Web: ['identity']
|
|
19
|
+
};
|
|
20
|
+
const success = { status: 'success' };
|
|
21
|
+
exports.OVERLAY = {
|
|
22
|
+
// the route inventory reports what the CALLER may use, so it needs an identity
|
|
23
|
+
// even though the rest of the Info tag needs nothing
|
|
24
|
+
'GET /SASjsApi/info/authorizedRoutes': { capabilities: ['identity'] },
|
|
25
|
+
'GET /SASjsApi/info/myAuthorizedRoutes': { capabilities: ['identity'] },
|
|
26
|
+
// the body is the file itself, so there is nothing to annotate
|
|
27
|
+
'GET /SASjsApi/drive/file': { implemented: true },
|
|
28
|
+
'POST /SASjsApi/drive/file': {
|
|
29
|
+
implemented: true,
|
|
30
|
+
responses: { 200: success, 409: 'File already exists.' }
|
|
31
|
+
},
|
|
32
|
+
'PATCH /SASjsApi/drive/file': {
|
|
33
|
+
implemented: true,
|
|
34
|
+
responses: { 200: success, 404: `File doesn't exist.` }
|
|
35
|
+
},
|
|
36
|
+
'DELETE /SASjsApi/drive/file': {
|
|
37
|
+
implemented: true,
|
|
38
|
+
responses: { 200: success, 404: `File doesn't exist.` }
|
|
39
|
+
},
|
|
40
|
+
'GET /SASjsApi/drive/folder': {
|
|
41
|
+
implemented: true,
|
|
42
|
+
responses: { 200: { files: [], folders: [] } }
|
|
43
|
+
},
|
|
44
|
+
'POST /SASjsApi/drive/folder': {
|
|
45
|
+
implemented: true,
|
|
46
|
+
responses: { 200: success, 409: 'Folder already exists.' }
|
|
47
|
+
},
|
|
48
|
+
'DELETE /SASjsApi/drive/folder': {
|
|
49
|
+
implemented: true,
|
|
50
|
+
responses: { 200: success, 404: `Folder doesn't exist.` }
|
|
51
|
+
},
|
|
52
|
+
'POST /SASjsApi/drive/rename': {
|
|
53
|
+
implemented: true,
|
|
54
|
+
responses: {
|
|
55
|
+
200: success,
|
|
56
|
+
409: 'File with new name already exists.',
|
|
57
|
+
404: 'No file/folder found for provided path.'
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
'GET /SASjsApi/drive/filetree': {
|
|
61
|
+
implemented: true,
|
|
62
|
+
responses: { 200: { status: 'success', tree: {} } }
|
|
63
|
+
},
|
|
64
|
+
// The two deploy routes write a whole tree in one request. They are the only
|
|
65
|
+
// ones where a caller can exhaust the host's storage, so they carry a cap -
|
|
66
|
+
// see the plan in sasjs/cli#1550 - and they are not implemented yet.
|
|
67
|
+
'POST /SASjsApi/drive/deploy': {
|
|
68
|
+
implemented: false
|
|
69
|
+
},
|
|
70
|
+
'POST /SASjsApi/drive/deploy/upload': {
|
|
71
|
+
implemented: false
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** Every method the table may state, in the order a diff reads best. */
|
|
75
|
+
exports.METHODS = ['GET', 'POST', 'PATCH', 'DELETE', 'PUT'];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The vocabulary the two hosts share.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here imports a runtime: the package is consumed by an Express server
|
|
6
|
+
* in node and by a service worker in a browser, so the types are all this file
|
|
7
|
+
* may contain. That is also why the build compiles `src` with `types: []` - an
|
|
8
|
+
* accidental `Buffer` or `process` reference fails the build rather than the
|
|
9
|
+
* worker at run time.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.isApiError = exports.ApiError = void 0;
|
|
13
|
+
/**
|
|
14
|
+
* A refusal the API answers with: the status a client keys on, and the message
|
|
15
|
+
* it prints. Both hosts throw this and both render it the same way, so the
|
|
16
|
+
* corpus can assert on the pair.
|
|
17
|
+
*/
|
|
18
|
+
class ApiError extends Error {
|
|
19
|
+
constructor(status, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
/** Told apart by marker rather than `instanceof` - see `isApiError`. */
|
|
23
|
+
this.apiError = true;
|
|
24
|
+
this.name = 'ApiError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.ApiError = ApiError;
|
|
28
|
+
/**
|
|
29
|
+
* `instanceof` survives here (the package targets ES2020, not ES5), but a
|
|
30
|
+
* consumer that downlevels it - the CLI does - loses the prototype chain, so the
|
|
31
|
+
* marker is what the checks use. Asserting both in a spec is what keeps that
|
|
32
|
+
* honest.
|
|
33
|
+
*/
|
|
34
|
+
const isApiError = (value) => !!value && value.apiError === true;
|
|
35
|
+
exports.isApiError = isApiError;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conformance corpus: what the API does, as data.
|
|
3
|
+
*
|
|
4
|
+
* Each case names an operation, the request a client would make, and the answer
|
|
5
|
+
* a client expects - the status, the body, or the file's contents. The same
|
|
6
|
+
* cases run against a live sasjs/server and against the browser build, which is
|
|
7
|
+
* the only way "the same API" becomes something a test can hold; two hand-
|
|
8
|
+
* written suites is how the header seed and the webout block drifted.
|
|
9
|
+
*
|
|
10
|
+
* The statuses and the wording are the implementation's (sasjs/server's
|
|
11
|
+
* controllers), not the published document's, and `src/routes/overlay.ts`
|
|
12
|
+
* records each place the two disagree.
|
|
13
|
+
*/
|
|
14
|
+
import type { ApiRequest } from '../host/dispatch.js';
|
|
15
|
+
export interface CorpusCase {
|
|
16
|
+
/** Stable name, used as the test's title. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** The operation, `METHOD /path` as the route table states it. */
|
|
19
|
+
route: string;
|
|
20
|
+
description: string;
|
|
21
|
+
/** Files the case starts from. */
|
|
22
|
+
seed?: Record<string, string | Uint8Array>;
|
|
23
|
+
request: ApiRequest;
|
|
24
|
+
expect: {
|
|
25
|
+
status: number;
|
|
26
|
+
body?: unknown;
|
|
27
|
+
contents?: string | Uint8Array;
|
|
28
|
+
/** The refusal's message, where the case expects one. */
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare const DRIVE_CORPUS: CorpusCase[];
|
|
33
|
+
//# sourceMappingURL=drive.d.ts.map
|
|
@@ -0,0 +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;AAErD,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,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;KACf,CAAA;CACF;AAWD,eAAO,MAAM,YAAY,EAAE,UAAU,EAyRpC,CAAA"}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
const success = { status: 'success' };
|
|
2
|
+
/**
|
|
3
|
+
* Bytes without an encoding API: the package builds with `lib: ES2020` and no
|
|
4
|
+
* ambient types, so a `TextEncoder` reference would compile here and fail in a
|
|
5
|
+
* host that does not have one. An explicit byte array is both.
|
|
6
|
+
*/
|
|
7
|
+
const bytes = (...values) => Uint8Array.from(values);
|
|
8
|
+
export const DRIVE_CORPUS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'create-file',
|
|
11
|
+
route: 'POST /SASjsApi/drive/file',
|
|
12
|
+
description: 'creates a file the drive did not have',
|
|
13
|
+
request: {
|
|
14
|
+
query: { _filePath: '/data/users.csv' },
|
|
15
|
+
file: { name: 'users.csv', contents: 'id,name\n1,Ann\n' }
|
|
16
|
+
},
|
|
17
|
+
expect: { status: 200, body: success }
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'create-file-at-form-path',
|
|
21
|
+
route: 'POST /SASjsApi/drive/file',
|
|
22
|
+
description: 'takes the destination from the form field when the query has none',
|
|
23
|
+
request: {
|
|
24
|
+
file: { name: 'x.csv', contents: 'x' },
|
|
25
|
+
body: { filePath: '/data/from-form.csv' }
|
|
26
|
+
},
|
|
27
|
+
expect: { status: 200, body: success }
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'create-file-over-existing',
|
|
31
|
+
route: 'POST /SASjsApi/drive/file',
|
|
32
|
+
description: 'refuses a create over a file that exists, as 409',
|
|
33
|
+
seed: { '/data/exists.csv': 'a' },
|
|
34
|
+
request: {
|
|
35
|
+
query: { _filePath: '/data/exists.csv' },
|
|
36
|
+
file: { name: 'x', contents: 'b' }
|
|
37
|
+
},
|
|
38
|
+
expect: { status: 409, error: 'File already exists.' }
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'create-file-blocked-extension',
|
|
42
|
+
route: 'POST /SASjsApi/drive/file',
|
|
43
|
+
description: 'refuses a refused extension before touching the drive',
|
|
44
|
+
request: {
|
|
45
|
+
query: { _filePath: '/data/run.sh' },
|
|
46
|
+
file: { name: 'run.sh', contents: 'x' }
|
|
47
|
+
},
|
|
48
|
+
expect: { status: 400, error: 'Invalid file extension' }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'create-file-outside-drive',
|
|
52
|
+
route: 'POST /SASjsApi/drive/file',
|
|
53
|
+
description: 'refuses a path that climbs out of the drive',
|
|
54
|
+
request: {
|
|
55
|
+
query: { _filePath: '/../escape.csv' },
|
|
56
|
+
file: { name: 'x', contents: 'x' }
|
|
57
|
+
},
|
|
58
|
+
expect: { status: 400, error: "Can't put file outside drive." }
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'create-file-without-payload',
|
|
62
|
+
route: 'POST /SASjsApi/drive/file',
|
|
63
|
+
description: 'answers for a missing file with the server wording',
|
|
64
|
+
request: { query: { _filePath: '/data/x.csv' } },
|
|
65
|
+
expect: { status: 400, error: '"file" is not present.' }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'create-file-without-destination',
|
|
69
|
+
route: 'POST /SASjsApi/drive/file',
|
|
70
|
+
description: 'answers for a missing destination with the validation wording',
|
|
71
|
+
request: { file: { name: 'x', contents: 'x' } },
|
|
72
|
+
expect: { status: 400, error: '"_filePath" is required' }
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'replace-file',
|
|
76
|
+
route: 'PATCH /SASjsApi/drive/file',
|
|
77
|
+
description: 'replaces the contents of a file that exists',
|
|
78
|
+
seed: { '/data/keep.csv': 'old' },
|
|
79
|
+
request: {
|
|
80
|
+
query: { _filePath: '/data/keep.csv' },
|
|
81
|
+
file: { name: 'keep.csv', contents: 'new' }
|
|
82
|
+
},
|
|
83
|
+
expect: { status: 200, body: success }
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'replace-file-missing',
|
|
87
|
+
route: 'PATCH /SASjsApi/drive/file',
|
|
88
|
+
description: 'refuses a replace of a file that is not there, as 404',
|
|
89
|
+
request: {
|
|
90
|
+
query: { _filePath: '/data/missing.csv' },
|
|
91
|
+
file: { name: 'x', contents: 'x' }
|
|
92
|
+
},
|
|
93
|
+
expect: { status: 404, error: `File doesn't exist.` }
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'replace-file-outside-drive',
|
|
97
|
+
route: 'PATCH /SASjsApi/drive/file',
|
|
98
|
+
description: 'refuses a replace of a path that climbs out of the drive',
|
|
99
|
+
request: {
|
|
100
|
+
query: { _filePath: '/../escape.csv' },
|
|
101
|
+
file: { name: 'x', contents: 'x' }
|
|
102
|
+
},
|
|
103
|
+
expect: { status: 400, error: "Can't modify file outside drive." }
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'read-file',
|
|
107
|
+
route: 'GET /SASjsApi/drive/file',
|
|
108
|
+
description: 'answers with the file contents',
|
|
109
|
+
seed: { '/data/keep.csv': 'a,b\n1,2\n' },
|
|
110
|
+
request: { query: { _filePath: '/data/keep.csv' } },
|
|
111
|
+
expect: { status: 200, contents: 'a,b\n1,2\n' }
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'read-file-missing',
|
|
115
|
+
route: 'GET /SASjsApi/drive/file',
|
|
116
|
+
description: 'refuses a read of a file that is not there',
|
|
117
|
+
request: { query: { _filePath: '/data/missing.csv' } },
|
|
118
|
+
expect: { status: 404, error: `File doesn't exist.` }
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'read-file-outside-drive',
|
|
122
|
+
route: 'GET /SASjsApi/drive/file',
|
|
123
|
+
description: 'refuses a read of a path that climbs out of the drive',
|
|
124
|
+
request: { query: { _filePath: '/../escape.csv' } },
|
|
125
|
+
expect: { status: 400, error: "Can't get file outside drive." }
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'delete-file',
|
|
129
|
+
route: 'DELETE /SASjsApi/drive/file',
|
|
130
|
+
description: 'deletes a file that exists',
|
|
131
|
+
seed: { '/data/keep.csv': 'a' },
|
|
132
|
+
request: { query: { _filePath: '/data/keep.csv' } },
|
|
133
|
+
expect: { status: 200, body: success }
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'delete-file-missing',
|
|
137
|
+
route: 'DELETE /SASjsApi/drive/file',
|
|
138
|
+
description: 'refuses a delete of a file that is not there',
|
|
139
|
+
request: { query: { _filePath: '/data/missing.csv' } },
|
|
140
|
+
expect: { status: 404, error: `File doesn't exist.` }
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'delete-file-outside-drive',
|
|
144
|
+
route: 'DELETE /SASjsApi/drive/file',
|
|
145
|
+
description: 'refuses a delete of a path that climbs out of the drive',
|
|
146
|
+
request: { query: { _filePath: '/../escape.csv' } },
|
|
147
|
+
expect: { status: 400, error: "Can't delete file outside drive." }
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'list-folder',
|
|
151
|
+
route: 'GET /SASjsApi/drive/folder',
|
|
152
|
+
description: 'lists the files and folders of a folder',
|
|
153
|
+
seed: { '/data/a.csv': 'a', '/data/reports/b.csv': 'b' },
|
|
154
|
+
request: { query: { _folderPath: '/data' } },
|
|
155
|
+
expect: { status: 200, body: { files: ['a.csv'], folders: ['reports'] } }
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'list-folder-missing',
|
|
159
|
+
route: 'GET /SASjsApi/drive/folder',
|
|
160
|
+
description: 'refuses a listing of a folder that is not there',
|
|
161
|
+
request: { query: { _folderPath: '/nope' } },
|
|
162
|
+
expect: { status: 404, error: `Folder doesn't exist.` }
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'list-folder-outside-drive',
|
|
166
|
+
route: 'GET /SASjsApi/drive/folder',
|
|
167
|
+
description: 'refuses a listing of a path that climbs out of the drive',
|
|
168
|
+
request: { query: { _folderPath: '/../escape' } },
|
|
169
|
+
expect: { status: 400, error: "Can't get folder outside drive." }
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'delete-folder-without-path',
|
|
173
|
+
route: 'DELETE /SASjsApi/drive/folder',
|
|
174
|
+
description: 'answers for a missing folder path, because the table marks it required',
|
|
175
|
+
request: {},
|
|
176
|
+
expect: { status: 400, error: '"_folderPath" is required' }
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'create-folder-without-path',
|
|
180
|
+
route: 'POST /SASjsApi/drive/folder',
|
|
181
|
+
description: 'answers for a missing folder path in the body',
|
|
182
|
+
request: { body: {} },
|
|
183
|
+
expect: { status: 400, error: '"folderPath" is required' }
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'create-folder',
|
|
187
|
+
route: 'POST /SASjsApi/drive/folder',
|
|
188
|
+
description: 'creates a folder',
|
|
189
|
+
request: { body: { folderPath: '/data/reports' } },
|
|
190
|
+
expect: { status: 200, body: success }
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'create-folder-existing',
|
|
194
|
+
route: 'POST /SASjsApi/drive/folder',
|
|
195
|
+
description: 'refuses a folder that already exists, as 409',
|
|
196
|
+
seed: { '/data/reports/a.csv': 'a' },
|
|
197
|
+
request: { body: { folderPath: '/data/reports' } },
|
|
198
|
+
expect: { status: 409, error: 'Folder already exists.' }
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'delete-folder',
|
|
202
|
+
route: 'DELETE /SASjsApi/drive/folder',
|
|
203
|
+
description: 'deletes a folder with everything under it',
|
|
204
|
+
seed: { '/data/reports/a.csv': 'a', '/data/reports/nested/b.csv': 'b' },
|
|
205
|
+
request: { query: { _folderPath: '/data/reports' } },
|
|
206
|
+
expect: { status: 200, body: success }
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'delete-folder-missing',
|
|
210
|
+
route: 'DELETE /SASjsApi/drive/folder',
|
|
211
|
+
description: 'refuses a delete of a folder that is not there',
|
|
212
|
+
request: { query: { _folderPath: '/nope' } },
|
|
213
|
+
expect: { status: 404, error: `Folder doesn't exist.` }
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: 'rename-file',
|
|
217
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
218
|
+
description: 'moves a file to a new path',
|
|
219
|
+
seed: { '/data/old.csv': 'r' },
|
|
220
|
+
request: { body: { oldPath: '/data/old.csv', newPath: '/data/new.csv' } },
|
|
221
|
+
expect: { status: 200, body: success }
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'rename-onto-existing',
|
|
225
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
226
|
+
description: 'refuses a move onto something that exists',
|
|
227
|
+
seed: { '/data/a.csv': 'a', '/data/b.csv': 'b' },
|
|
228
|
+
request: { body: { oldPath: '/data/a.csv', newPath: '/data/b.csv' } },
|
|
229
|
+
expect: { status: 409, error: 'File with new name already exists.' }
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'rename-missing',
|
|
233
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
234
|
+
description: 'refuses a move of something that is not there',
|
|
235
|
+
request: { body: { oldPath: '/data/nope.csv', newPath: '/data/x.csv' } },
|
|
236
|
+
expect: { status: 404, error: 'No file/folder found for provided path.' }
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'rename-folder-into-itself',
|
|
240
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
241
|
+
description: 'refuses a folder moved inside itself, which the server loses at the filesystem',
|
|
242
|
+
seed: { '/data/reports/a.csv': 'a' },
|
|
243
|
+
request: {
|
|
244
|
+
body: { oldPath: '/data/reports', newPath: '/data/reports/nested' }
|
|
245
|
+
},
|
|
246
|
+
expect: {
|
|
247
|
+
status: 400,
|
|
248
|
+
error: "New path can't be inside the folder being moved."
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 'tree',
|
|
253
|
+
route: 'GET /SASjsApi/drive/filetree',
|
|
254
|
+
description: 'answers with the drive tree',
|
|
255
|
+
seed: { '/data/a.csv': 'a' },
|
|
256
|
+
request: { query: { _folderPath: '/data' } },
|
|
257
|
+
expect: {
|
|
258
|
+
status: 200,
|
|
259
|
+
body: {
|
|
260
|
+
status: 'success',
|
|
261
|
+
tree: {
|
|
262
|
+
name: 'data',
|
|
263
|
+
relativePath: '/data',
|
|
264
|
+
isFolder: true,
|
|
265
|
+
children: [
|
|
266
|
+
{
|
|
267
|
+
name: 'a.csv',
|
|
268
|
+
relativePath: '/data/a.csv',
|
|
269
|
+
isFolder: false,
|
|
270
|
+
children: []
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'bytes-round-trip',
|
|
279
|
+
route: 'GET /SASjsApi/drive/file',
|
|
280
|
+
description: 'keeps a byte file as bytes',
|
|
281
|
+
seed: { '/data/pack.zip': bytes(80, 75, 3, 4) },
|
|
282
|
+
request: { query: { _filePath: '/data/pack.zip' } },
|
|
283
|
+
expect: { status: 200, contents: bytes(80, 75, 3, 4) }
|
|
284
|
+
}
|
|
285
|
+
];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a corpus case against a drive the case seeds itself.
|
|
3
|
+
*
|
|
4
|
+
* The runner exists so the corpus is data rather than a spec file: a host proves
|
|
5
|
+
* its conformance by supplying something that answers `dispatch`, and the
|
|
6
|
+
* assertions live in one place. Today that is this package's services over a
|
|
7
|
+
* `MemoryDriveStore`; a live server and the browser build plug in through their
|
|
8
|
+
* own transports and the same cases run there.
|
|
9
|
+
*/
|
|
10
|
+
import type { CorpusCase } from './drive.js';
|
|
11
|
+
import type { Services } from '../host/dispatch.js';
|
|
12
|
+
export interface CaseResult {
|
|
13
|
+
status: number;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
contents?: string | Uint8Array;
|
|
16
|
+
/** The refusal's message, when the case was refused. */
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Services over a fresh store, seeded with the case's files. */
|
|
20
|
+
export declare const servicesFor: (testCase: CorpusCase) => Services;
|
|
21
|
+
/**
|
|
22
|
+
* Runs a corpus case against the drive the case seeds itself, or against
|
|
23
|
+
* services a caller supplies - that is how a host (or a failing store) is put
|
|
24
|
+
* behind the same cases.
|
|
25
|
+
*/
|
|
26
|
+
export declare const runCase: (testCase: CorpusCase, services?: Services) => Promise<CaseResult>;
|
|
27
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +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;AAKnD,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,iEAAiE;AACjE,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,QAEjD,CAAA;AAEF;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAcpB,CAAA"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { dispatch } from '../host/dispatch.js';
|
|
2
|
+
import { createDriveService } from '../drive/service.js';
|
|
3
|
+
import { MemoryDriveStore } from '../drive/memory-store.js';
|
|
4
|
+
import { isApiError } from '../types.js';
|
|
5
|
+
/** Services over a fresh store, seeded with the case's files. */
|
|
6
|
+
export const servicesFor = (testCase) => ({
|
|
7
|
+
drive: createDriveService(new MemoryDriveStore(testCase.seed || {}))
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* Runs a corpus case against the drive the case seeds itself, or against
|
|
11
|
+
* services a caller supplies - that is how a host (or a failing store) is put
|
|
12
|
+
* behind the same cases.
|
|
13
|
+
*/
|
|
14
|
+
export const runCase = async (testCase, services = servicesFor(testCase)) => {
|
|
15
|
+
try {
|
|
16
|
+
const response = await dispatch(services, testCase.route, testCase.request);
|
|
17
|
+
return {
|
|
18
|
+
status: response.status,
|
|
19
|
+
body: response.body,
|
|
20
|
+
contents: response.contents
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
// only a refusal the API decided on becomes a status; anything else is a
|
|
25
|
+
// broken host, and reporting it as a 500 would hide it from the suite
|
|
26
|
+
if (isApiError(error))
|
|
27
|
+
return { status: error.status, error: error.message };
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `DriveStore` over two Maps, for tests and for hosts that have no filesystem.
|
|
3
|
+
*
|
|
4
|
+
* It is deliberately not clever: text is kept as a string, bytes as a copy of
|
|
5
|
+
* the `Uint8Array`, and folders are implied by the paths that exist plus the
|
|
6
|
+
* ones `mkdirp` recorded. That is exactly the model the browser build needs, so
|
|
7
|
+
* a host can use this class as its store rather than writing another one - the
|
|
8
|
+
* browser build already persists the whole drive as a map of path to string.
|
|
9
|
+
*/
|
|
10
|
+
import type { DriveStore } from '../types.js';
|
|
11
|
+
type Entry = string | Uint8Array;
|
|
12
|
+
export declare class MemoryDriveStore implements DriveStore {
|
|
13
|
+
private files;
|
|
14
|
+
private folders;
|
|
15
|
+
constructor(seed?: Record<string, Entry>);
|
|
16
|
+
/** Everything on the drive, for a snapshot or an assertion. */
|
|
17
|
+
dump(): Record<string, Entry>;
|
|
18
|
+
private putSync;
|
|
19
|
+
private mkdirpSync;
|
|
20
|
+
private children;
|
|
21
|
+
read(path: string): Promise<Entry>;
|
|
22
|
+
write(path: string, contents: Entry): Promise<void>;
|
|
23
|
+
exists(path: string): Promise<boolean>;
|
|
24
|
+
stat(path: string): Promise<{
|
|
25
|
+
directory: boolean;
|
|
26
|
+
size: number;
|
|
27
|
+
}>;
|
|
28
|
+
list(path: string): Promise<{
|
|
29
|
+
files: string[];
|
|
30
|
+
folders: string[];
|
|
31
|
+
}>;
|
|
32
|
+
mkdirp(path: string): Promise<void>;
|
|
33
|
+
remove(path: string): Promise<void>;
|
|
34
|
+
move(from: string, to: string): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=memory-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../../src/drive/memory-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAI7C,KAAK,KAAK,GAAG,MAAM,GAAG,UAAU,CAAA;AAEhC,qBAAa,gBAAiB,YAAW,UAAU;IACjD,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,OAAO,CAAyB;gBAE5B,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAM;IAM5C,+DAA+D;IAC/D,IAAI,IAAI,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;IAM7B,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,QAAQ;IAiBV,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAMlC,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAItC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAWjE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAInE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYnC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA8BpD"}
|