@sasjs/api 0.0.0-stage → 0.1.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 +85 -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,122 @@
|
|
|
1
|
+
import { ApiError } from '../types.js';
|
|
2
|
+
import { dirname } from './paths.js';
|
|
3
|
+
export class MemoryDriveStore {
|
|
4
|
+
constructor(seed = {}) {
|
|
5
|
+
this.files = new Map();
|
|
6
|
+
this.folders = new Set(['/']);
|
|
7
|
+
for (const [path, contents] of Object.entries(seed)) {
|
|
8
|
+
this.putSync(path, contents);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Everything on the drive, for a snapshot or an assertion. */
|
|
12
|
+
dump() {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [path, contents] of this.files)
|
|
15
|
+
out[path] = contents;
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
putSync(path, contents) {
|
|
19
|
+
this.mkdirpSync(dirname(path));
|
|
20
|
+
this.files.set(path, typeof contents === 'string' ? contents : contents.slice());
|
|
21
|
+
}
|
|
22
|
+
mkdirpSync(path) {
|
|
23
|
+
const parts = path.split('/').filter(Boolean);
|
|
24
|
+
let acc = '';
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
acc += '/' + part;
|
|
27
|
+
this.folders.add(acc);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
children(path) {
|
|
31
|
+
const prefix = path === '/' ? '/' : path + '/';
|
|
32
|
+
const files = [];
|
|
33
|
+
const folders = [];
|
|
34
|
+
for (const candidate of this.files.keys()) {
|
|
35
|
+
if (!candidate.startsWith(prefix))
|
|
36
|
+
continue;
|
|
37
|
+
const rest = candidate.slice(prefix.length);
|
|
38
|
+
if (rest && !rest.includes('/'))
|
|
39
|
+
files.push(rest);
|
|
40
|
+
}
|
|
41
|
+
for (const candidate of this.folders) {
|
|
42
|
+
if (!candidate.startsWith(prefix))
|
|
43
|
+
continue;
|
|
44
|
+
const rest = candidate.slice(prefix.length);
|
|
45
|
+
if (rest && !rest.includes('/'))
|
|
46
|
+
folders.push(rest);
|
|
47
|
+
}
|
|
48
|
+
return { files: files.sort(), folders: folders.sort() };
|
|
49
|
+
}
|
|
50
|
+
async read(path) {
|
|
51
|
+
const contents = this.files.get(path);
|
|
52
|
+
if (contents === undefined)
|
|
53
|
+
throw new ApiError(404, `File doesn't exist.`);
|
|
54
|
+
return contents;
|
|
55
|
+
}
|
|
56
|
+
async write(path, contents) {
|
|
57
|
+
this.putSync(path, contents);
|
|
58
|
+
}
|
|
59
|
+
async exists(path) {
|
|
60
|
+
return this.files.has(path) || this.folders.has(path);
|
|
61
|
+
}
|
|
62
|
+
async stat(path) {
|
|
63
|
+
if (this.folders.has(path) && !this.files.has(path))
|
|
64
|
+
return { directory: true, size: 0 };
|
|
65
|
+
const contents = this.files.get(path);
|
|
66
|
+
if (contents === undefined)
|
|
67
|
+
throw new ApiError(404, `File doesn't exist.`);
|
|
68
|
+
return {
|
|
69
|
+
directory: false,
|
|
70
|
+
size: typeof contents === 'string' ? contents.length : contents.length
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async list(path) {
|
|
74
|
+
return this.children(path);
|
|
75
|
+
}
|
|
76
|
+
async mkdirp(path) {
|
|
77
|
+
this.mkdirpSync(path);
|
|
78
|
+
}
|
|
79
|
+
async remove(path) {
|
|
80
|
+
if (this.files.delete(path))
|
|
81
|
+
return;
|
|
82
|
+
const prefix = path === '/' ? '/' : path + '/';
|
|
83
|
+
for (const candidate of [...this.files.keys()]) {
|
|
84
|
+
if (candidate.startsWith(prefix))
|
|
85
|
+
this.files.delete(candidate);
|
|
86
|
+
}
|
|
87
|
+
for (const candidate of [...this.folders]) {
|
|
88
|
+
if (candidate === path || candidate.startsWith(prefix))
|
|
89
|
+
this.folders.delete(candidate);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async move(from, to) {
|
|
93
|
+
const contents = this.files.get(from);
|
|
94
|
+
if (contents !== undefined) {
|
|
95
|
+
const copy = typeof contents === 'string' ? contents : contents.slice();
|
|
96
|
+
this.files.delete(from);
|
|
97
|
+
this.mkdirpSync(dirname(to));
|
|
98
|
+
this.files.set(to, copy);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// a folder: every entry under it moves by prefix, then the folder records
|
|
102
|
+
const prefix = from + '/';
|
|
103
|
+
const moves = [];
|
|
104
|
+
for (const [candidate, value] of this.files) {
|
|
105
|
+
if (candidate.startsWith(prefix))
|
|
106
|
+
moves.push([candidate, to + candidate.slice(from.length), value]);
|
|
107
|
+
}
|
|
108
|
+
for (const [source] of moves)
|
|
109
|
+
this.files.delete(source);
|
|
110
|
+
for (const [, destination, value] of moves) {
|
|
111
|
+
this.mkdirpSync(dirname(destination));
|
|
112
|
+
this.files.set(destination, value);
|
|
113
|
+
}
|
|
114
|
+
for (const candidate of [...this.folders]) {
|
|
115
|
+
if (candidate === from || candidate.startsWith(prefix)) {
|
|
116
|
+
this.folders.delete(candidate);
|
|
117
|
+
this.folders.add(to + candidate.slice(from.length));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.mkdirpSync(to);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive path rules, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These are the server's rules, not an invention of this package:
|
|
5
|
+
* `resolveWithinDrive` in sasjs/server resolves a caller-supplied path against
|
|
6
|
+
* the drive root and refuses anything that leaves it, and `filePathSchema`
|
|
7
|
+
* refuses a handful of extensions before a path reaches the drive at all.
|
|
8
|
+
* Because both hosts now import this file, the rule can only be changed in one
|
|
9
|
+
* place - which is the point of the package.
|
|
10
|
+
*/
|
|
11
|
+
/** True when the last segment of the path carries a refused extension. */
|
|
12
|
+
export declare const hasBlockedExtension: (path: string) => boolean;
|
|
13
|
+
/**
|
|
14
|
+
* A caller-supplied path as a drive-relative path, or `null` when it would land
|
|
15
|
+
* outside the drive.
|
|
16
|
+
*
|
|
17
|
+
* The rules, each of which a test pins:
|
|
18
|
+
*
|
|
19
|
+
* - a leading slash means "under the drive root", never filesystem-absolute -
|
|
20
|
+
* so `/Public/app/x` and `Public/app/x` are the same destination;
|
|
21
|
+
* - a backslash is a separator, because a Windows client sends them;
|
|
22
|
+
* - `.` is ignored and `..` pops a segment, so traversal is resolved rather
|
|
23
|
+
* than string-matched;
|
|
24
|
+
* - a `..` that would pop past the root returns null instead of clamping,
|
|
25
|
+
* because a path that escapes is refused, not rewritten;
|
|
26
|
+
* - a name that merely starts with dots (`..foo`) is a name, not a traversal.
|
|
27
|
+
*
|
|
28
|
+
* The return value starts with '/' (the root is '/'), has no trailing slash.
|
|
29
|
+
*/
|
|
30
|
+
export declare const normaliseDrivePath: (input: unknown) => string | null;
|
|
31
|
+
/** The folder a file path sits in ('/' for a path at the root). */
|
|
32
|
+
export declare const dirname: (path: string) => string;
|
|
33
|
+
/** The last segment of a path. */
|
|
34
|
+
export declare const basename: (path: string) => string;
|
|
35
|
+
/** The drive root itself, which no write may name. */
|
|
36
|
+
export declare const isRoot: (path: string) => boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The path a file request names, with the blocked-extension rule applied.
|
|
39
|
+
* Returns the reason rather than a status, so the caller chooses the wording:
|
|
40
|
+
* a traversal and an extension are both 400s, with different messages.
|
|
41
|
+
*/
|
|
42
|
+
export declare const checkFilePath: (input: unknown) => {
|
|
43
|
+
path: string;
|
|
44
|
+
} | {
|
|
45
|
+
reason: "outside";
|
|
46
|
+
} | {
|
|
47
|
+
reason: "extension";
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/drive/paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AASH,0EAA0E;AAC1E,eAAO,MAAM,mBAAmB,GAAI,MAAM,MAAM,KAAG,OACrB,CAAA;AAE9B;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,IAe5D,CAAA;AAED,mEAAmE;AACnE,eAAO,MAAM,OAAO,GAAI,MAAM,MAAM,KAAG,MAGtC,CAAA;AAED,kCAAkC;AAClC,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,KAAG,MACD,CAAA;AAEvC,sDAAsD;AACtD,eAAO,MAAM,MAAM,GAAI,MAAM,MAAM,KAAG,OAAuB,CAAA;AAE7D;;;;GAIG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,OAAO,KACb;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,WAAW,CAAA;CAKlE,CAAA"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive path rules, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These are the server's rules, not an invention of this package:
|
|
5
|
+
* `resolveWithinDrive` in sasjs/server resolves a caller-supplied path against
|
|
6
|
+
* the drive root and refuses anything that leaves it, and `filePathSchema`
|
|
7
|
+
* refuses a handful of extensions before a path reaches the drive at all.
|
|
8
|
+
* Because both hosts now import this file, the rule can only be changed in one
|
|
9
|
+
* place - which is the point of the package.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Extensions a drive request may not name, on any route (the server's
|
|
13
|
+
* `blockFileRegex`). Nothing is executable on a browser host, but a client that
|
|
14
|
+
* would be refused by a server should hear the same answer from the mock.
|
|
15
|
+
*/
|
|
16
|
+
const BLOCKED_EXTENSION = /\.(exe|sh|htaccess)$/i;
|
|
17
|
+
/** True when the last segment of the path carries a refused extension. */
|
|
18
|
+
export const hasBlockedExtension = (path) => BLOCKED_EXTENSION.test(path);
|
|
19
|
+
/**
|
|
20
|
+
* A caller-supplied path as a drive-relative path, or `null` when it would land
|
|
21
|
+
* outside the drive.
|
|
22
|
+
*
|
|
23
|
+
* The rules, each of which a test pins:
|
|
24
|
+
*
|
|
25
|
+
* - a leading slash means "under the drive root", never filesystem-absolute -
|
|
26
|
+
* so `/Public/app/x` and `Public/app/x` are the same destination;
|
|
27
|
+
* - a backslash is a separator, because a Windows client sends them;
|
|
28
|
+
* - `.` is ignored and `..` pops a segment, so traversal is resolved rather
|
|
29
|
+
* than string-matched;
|
|
30
|
+
* - a `..` that would pop past the root returns null instead of clamping,
|
|
31
|
+
* because a path that escapes is refused, not rewritten;
|
|
32
|
+
* - a name that merely starts with dots (`..foo`) is a name, not a traversal.
|
|
33
|
+
*
|
|
34
|
+
* The return value starts with '/' (the root is '/'), has no trailing slash.
|
|
35
|
+
*/
|
|
36
|
+
export const normaliseDrivePath = (input) => {
|
|
37
|
+
const given = typeof input === 'string' ? input : '';
|
|
38
|
+
const segments = [];
|
|
39
|
+
for (const segment of given.replace(/\\/g, '/').split('/')) {
|
|
40
|
+
if (!segment || segment === '.')
|
|
41
|
+
continue;
|
|
42
|
+
if (segment === '..') {
|
|
43
|
+
if (!segments.length)
|
|
44
|
+
return null;
|
|
45
|
+
segments.pop();
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
segments.push(segment);
|
|
49
|
+
}
|
|
50
|
+
return '/' + segments.join('/');
|
|
51
|
+
};
|
|
52
|
+
/** The folder a file path sits in ('/' for a path at the root). */
|
|
53
|
+
export const dirname = (path) => {
|
|
54
|
+
const cut = path.lastIndexOf('/');
|
|
55
|
+
return cut <= 0 ? '/' : path.slice(0, cut);
|
|
56
|
+
};
|
|
57
|
+
/** The last segment of a path. */
|
|
58
|
+
export const basename = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
59
|
+
/** The drive root itself, which no write may name. */
|
|
60
|
+
export const isRoot = (path) => path === '/';
|
|
61
|
+
/**
|
|
62
|
+
* The path a file request names, with the blocked-extension rule applied.
|
|
63
|
+
* Returns the reason rather than a status, so the caller chooses the wording:
|
|
64
|
+
* a traversal and an extension are both 400s, with different messages.
|
|
65
|
+
*/
|
|
66
|
+
export const checkFilePath = (input) => {
|
|
67
|
+
const path = normaliseDrivePath(input);
|
|
68
|
+
if (path === null)
|
|
69
|
+
return { reason: 'outside' };
|
|
70
|
+
if (hasBlockedExtension(path))
|
|
71
|
+
return { reason: 'extension' };
|
|
72
|
+
return { path };
|
|
73
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drive, as behaviour rather than storage.
|
|
3
|
+
*
|
|
4
|
+
* Every function here is the same decision a SASjs Server makes today - the
|
|
5
|
+
* status, the message, the order of the checks - with the bytes left to a
|
|
6
|
+
* `DriveStore` the host supplies. The server passes a store over node's fs; the
|
|
7
|
+
* browser build passes one over its in-memory drive. Neither re-implements the
|
|
8
|
+
* rules, which is how the two drifted apart before (sasjs/cli#1542).
|
|
9
|
+
*
|
|
10
|
+
* The functions are `async` even though both current stores are synchronous, so
|
|
11
|
+
* that an async backend later is a store change rather than a signature change
|
|
12
|
+
* in every consumer.
|
|
13
|
+
*/
|
|
14
|
+
import type { DriveStore, TreeNode } from '../types.js';
|
|
15
|
+
export interface DriveResult {
|
|
16
|
+
status: 'success';
|
|
17
|
+
}
|
|
18
|
+
export interface DriveListing {
|
|
19
|
+
files: string[];
|
|
20
|
+
folders: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface DriveFile {
|
|
23
|
+
contents: string | Uint8Array;
|
|
24
|
+
binary: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface DriveTree {
|
|
27
|
+
status: 'success';
|
|
28
|
+
tree: TreeNode;
|
|
29
|
+
}
|
|
30
|
+
export interface DriveService {
|
|
31
|
+
list(folderPath?: string): Promise<DriveListing>;
|
|
32
|
+
read(filePath: string): Promise<DriveFile>;
|
|
33
|
+
save(filePath: string, contents: string | Uint8Array): Promise<DriveResult>;
|
|
34
|
+
update(filePath: string, contents: string | Uint8Array): Promise<DriveResult>;
|
|
35
|
+
remove(filePath: string): Promise<DriveResult>;
|
|
36
|
+
mkdir(folderPath: string): Promise<DriveResult>;
|
|
37
|
+
rmdir(folderPath: string): Promise<DriveResult>;
|
|
38
|
+
rename(oldPath: string, newPath: string): Promise<DriveResult>;
|
|
39
|
+
tree(folderPath?: string): Promise<DriveTree>;
|
|
40
|
+
}
|
|
41
|
+
export declare const createDriveService: (store: DriveStore) => DriveService;
|
|
42
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/drive/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAUvD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,SAAS,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;IAC7B,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE,QAAQ,CAAA;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IAChD,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC3E,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC7E,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9C,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9D,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;CAC9C;AAqBD,eAAO,MAAM,kBAAkB,GAAI,OAAO,UAAU,KAAG,YA4ItD,CAAA"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { ApiError } from '../types.js';
|
|
2
|
+
import { basename, checkFilePath, dirname, isRoot, normaliseDrivePath } from './paths.js';
|
|
3
|
+
/** A path a file operation may act on, or the refusal the operation answers. */
|
|
4
|
+
const filePath = (input, outside) => {
|
|
5
|
+
const checked = checkFilePath(input);
|
|
6
|
+
if ('reason' in checked) {
|
|
7
|
+
throw checked.reason === 'extension'
|
|
8
|
+
? new ApiError(400, 'Invalid file extension')
|
|
9
|
+
: new ApiError(400, outside);
|
|
10
|
+
}
|
|
11
|
+
if (isRoot(checked.path))
|
|
12
|
+
throw new ApiError(400, outside);
|
|
13
|
+
return checked.path;
|
|
14
|
+
};
|
|
15
|
+
/** A folder path, which may be the drive root but never an extension check. */
|
|
16
|
+
const folderPath = (input, outside) => {
|
|
17
|
+
const path = normaliseDrivePath(input);
|
|
18
|
+
if (path === null)
|
|
19
|
+
throw new ApiError(400, outside);
|
|
20
|
+
return path;
|
|
21
|
+
};
|
|
22
|
+
export const createDriveService = (store) => {
|
|
23
|
+
const treeOf = async (path) => {
|
|
24
|
+
const listing = await store.list(path);
|
|
25
|
+
const children = [];
|
|
26
|
+
for (const folder of listing.folders) {
|
|
27
|
+
children.push(await treeOf(path === '/' ? '/' + folder : path + '/' + folder));
|
|
28
|
+
}
|
|
29
|
+
for (const file of listing.files) {
|
|
30
|
+
children.push({
|
|
31
|
+
name: file,
|
|
32
|
+
relativePath: path === '/' ? '/' + file : path + '/' + file,
|
|
33
|
+
isFolder: false,
|
|
34
|
+
children: []
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
name: path === '/' ? 'files' : basename(path),
|
|
39
|
+
relativePath: path,
|
|
40
|
+
isFolder: true,
|
|
41
|
+
children
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
async list(given) {
|
|
46
|
+
// an empty path is the root, which is what the server's GET /folder does
|
|
47
|
+
// with no `_folderPath`
|
|
48
|
+
const path = folderPath(given === undefined ? '' : given, "Can't get folder outside drive.");
|
|
49
|
+
if (!(await store.exists(path)))
|
|
50
|
+
throw new ApiError(404, `Folder doesn't exist.`);
|
|
51
|
+
const stat = await store.stat(path);
|
|
52
|
+
if (!stat.directory)
|
|
53
|
+
throw new ApiError(400, 'Not a Folder.');
|
|
54
|
+
const listing = await store.list(path);
|
|
55
|
+
return {
|
|
56
|
+
files: [...listing.files].sort(),
|
|
57
|
+
folders: [...listing.folders].sort()
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async read(given) {
|
|
61
|
+
const path = filePath(given, "Can't get file outside drive.");
|
|
62
|
+
if (!(await store.exists(path)))
|
|
63
|
+
throw new ApiError(404, `File doesn't exist.`);
|
|
64
|
+
const contents = await store.read(path);
|
|
65
|
+
return { contents, binary: typeof contents !== 'string' };
|
|
66
|
+
},
|
|
67
|
+
async save(given, contents) {
|
|
68
|
+
const path = filePath(given, "Can't put file outside drive.");
|
|
69
|
+
if (await store.exists(path))
|
|
70
|
+
throw new ApiError(409, 'File already exists.');
|
|
71
|
+
await store.mkdirp(dirname(path));
|
|
72
|
+
await store.write(path, contents);
|
|
73
|
+
return { status: 'success' };
|
|
74
|
+
},
|
|
75
|
+
async update(given, contents) {
|
|
76
|
+
const path = filePath(given, "Can't modify file outside drive.");
|
|
77
|
+
if (!(await store.exists(path)))
|
|
78
|
+
throw new ApiError(404, `File doesn't exist.`);
|
|
79
|
+
await store.write(path, contents);
|
|
80
|
+
return { status: 'success' };
|
|
81
|
+
},
|
|
82
|
+
async remove(given) {
|
|
83
|
+
const path = filePath(given, "Can't delete file outside drive.");
|
|
84
|
+
if (!(await store.exists(path)))
|
|
85
|
+
throw new ApiError(404, `File doesn't exist.`);
|
|
86
|
+
await store.remove(path);
|
|
87
|
+
return { status: 'success' };
|
|
88
|
+
},
|
|
89
|
+
async mkdir(given) {
|
|
90
|
+
const path = folderPath(given, "Can't put folder outside drive.");
|
|
91
|
+
if (isRoot(path))
|
|
92
|
+
throw new ApiError(400, "Can't put folder outside drive.");
|
|
93
|
+
if (await store.exists(path))
|
|
94
|
+
throw new ApiError(409, 'Folder already exists.');
|
|
95
|
+
await store.mkdirp(path);
|
|
96
|
+
return { status: 'success' };
|
|
97
|
+
},
|
|
98
|
+
async rmdir(given) {
|
|
99
|
+
const path = folderPath(given, "Can't delete folder outside drive.");
|
|
100
|
+
// the root is the whole drive: refused here as well as at validation,
|
|
101
|
+
// because a caller reaching this function directly has no validation
|
|
102
|
+
if (isRoot(path))
|
|
103
|
+
throw new ApiError(400, "Can't delete folder outside drive.");
|
|
104
|
+
if (!(await store.exists(path)) || !(await store.stat(path)).directory)
|
|
105
|
+
throw new ApiError(404, `Folder doesn't exist.`);
|
|
106
|
+
await store.remove(path);
|
|
107
|
+
return { status: 'success' };
|
|
108
|
+
},
|
|
109
|
+
async rename(oldPath, newPath) {
|
|
110
|
+
const from = folderPath(oldPath, "Old path can't be outside of drive.");
|
|
111
|
+
const to = folderPath(newPath, "New path can't be outside of drive.");
|
|
112
|
+
if (isRoot(from) || isRoot(to))
|
|
113
|
+
throw new ApiError(400, "New path can't be outside of drive.");
|
|
114
|
+
if (await store.exists(from)) {
|
|
115
|
+
const stat = await store.stat(from);
|
|
116
|
+
// a folder moved inside itself would be an infinite copy, and the
|
|
117
|
+
// server loses this at the filesystem with an opaque error
|
|
118
|
+
if (stat.directory && (to === from || to.startsWith(from + '/')))
|
|
119
|
+
throw new ApiError(400, "New path can't be inside the folder being moved.");
|
|
120
|
+
if (await store.exists(to))
|
|
121
|
+
throw new ApiError(409, stat.directory
|
|
122
|
+
? 'Folder with new name already exists.'
|
|
123
|
+
: 'File with new name already exists.');
|
|
124
|
+
await store.move(from, to);
|
|
125
|
+
return { status: 'success' };
|
|
126
|
+
}
|
|
127
|
+
throw new ApiError(404, 'No file/folder found for provided path.');
|
|
128
|
+
},
|
|
129
|
+
async tree(given) {
|
|
130
|
+
const path = folderPath(given === undefined ? '' : given, "Can't get folder outside drive.");
|
|
131
|
+
if (!(await store.exists(path)))
|
|
132
|
+
throw new ApiError(404, `Folder doesn't exist.`);
|
|
133
|
+
const stat = await store.stat(path);
|
|
134
|
+
if (!stat.directory)
|
|
135
|
+
throw new ApiError(400, 'Not a Folder.');
|
|
136
|
+
return { status: 'success', tree: await treeOf(path) };
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DriveService } from '../drive/service.js';
|
|
2
|
+
export interface ApiRequest {
|
|
3
|
+
query?: Record<string, string | undefined>;
|
|
4
|
+
body?: Record<string, unknown>;
|
|
5
|
+
/** The multipart payload: the API names it `file` on every route that takes one. */
|
|
6
|
+
file?: {
|
|
7
|
+
name: string;
|
|
8
|
+
contents: string | Uint8Array;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export interface ApiResponse {
|
|
12
|
+
status: number;
|
|
13
|
+
/** A JSON body. */
|
|
14
|
+
body?: unknown;
|
|
15
|
+
/** A raw body, for the one route that answers with the file itself. */
|
|
16
|
+
contents?: string | Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
export interface Services {
|
|
19
|
+
drive: DriveService;
|
|
20
|
+
}
|
|
21
|
+
export declare const dispatch: (services: Services, routeId: string, request?: ApiRequest) => Promise<ApiResponse>;
|
|
22
|
+
/** Whether a route is one `dispatch` can answer today. */
|
|
23
|
+
export declare const HANDLED_ROUTES: string[];
|
|
24
|
+
//# sourceMappingURL=dispatch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../../src/host/dispatch.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAGvD,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,oFAAoF;IACpF,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAA;CACvD;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,mBAAmB;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;CAC/B;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,YAAY,CAAA;CACpB;AAsCD,eAAO,MAAM,QAAQ,GACnB,UAAU,QAAQ,EAClB,SAAS,MAAM,EACf,UAAS,UAAe,KACvB,OAAO,CAAC,WAAW,CA2DrB,CAAA;AAED,0DAA0D;AAC1D,eAAO,MAAM,cAAc,EAAE,MAAM,EAUlC,CAAA"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { routeById } from '../routes/index.js';
|
|
2
|
+
import { ApiError } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The parameters the table marks required, checked before the handler runs.
|
|
5
|
+
*
|
|
6
|
+
* Multipart fields are deliberately left to the handler: the payload carries the
|
|
7
|
+
* file itself, and the server words a missing one as `"file" is not present.`
|
|
8
|
+
* rather than `"file" is required` - a distinction a client may already key on.
|
|
9
|
+
*/
|
|
10
|
+
const validate = (route, request) => {
|
|
11
|
+
for (const param of route.params) {
|
|
12
|
+
if (!param.required || param.in === 'formData' || param.in === 'header')
|
|
13
|
+
continue;
|
|
14
|
+
// a path parameter comes from the matcher, not from the caller's payload, so
|
|
15
|
+
// it is the host's job to have supplied it
|
|
16
|
+
if (param.in === 'path')
|
|
17
|
+
continue;
|
|
18
|
+
const given = param.in === 'query'
|
|
19
|
+
? request.query?.[param.name]
|
|
20
|
+
: request.body?.[param.name];
|
|
21
|
+
if (given === undefined || given === '')
|
|
22
|
+
throw new ApiError(400, `"${param.name}" is required`);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** The drive path a write names: the query parameter wins over the form field. */
|
|
26
|
+
const targetPath = (request) => {
|
|
27
|
+
const fromQuery = request.query?._filePath;
|
|
28
|
+
const fromBody = request.body?.filePath;
|
|
29
|
+
const path = fromQuery !== undefined
|
|
30
|
+
? String(fromQuery)
|
|
31
|
+
: fromBody;
|
|
32
|
+
// neither given: the server's validation answers for the query parameter
|
|
33
|
+
if (path === undefined)
|
|
34
|
+
throw new ApiError(400, '"_filePath" is required');
|
|
35
|
+
return path;
|
|
36
|
+
};
|
|
37
|
+
export const dispatch = async (services, routeId, request = {}) => {
|
|
38
|
+
const route = routeById(routeId);
|
|
39
|
+
if (!route)
|
|
40
|
+
throw new ApiError(404, `No such operation: ${routeId}`);
|
|
41
|
+
validate(route, request);
|
|
42
|
+
const { drive } = services;
|
|
43
|
+
const query = request.query || {};
|
|
44
|
+
switch (routeId) {
|
|
45
|
+
case 'GET /SASjsApi/drive/file': {
|
|
46
|
+
const file = await drive.read(String(query._filePath || ''));
|
|
47
|
+
return { status: 200, contents: file.contents };
|
|
48
|
+
}
|
|
49
|
+
case 'POST /SASjsApi/drive/file':
|
|
50
|
+
case 'PATCH /SASjsApi/drive/file': {
|
|
51
|
+
if (!request.file)
|
|
52
|
+
throw new ApiError(400, '"file" is not present.');
|
|
53
|
+
const path = targetPath(request);
|
|
54
|
+
const result = routeId === 'PATCH /SASjsApi/drive/file'
|
|
55
|
+
? await drive.update(path, request.file.contents)
|
|
56
|
+
: await drive.save(path, request.file.contents);
|
|
57
|
+
return { status: 200, body: result };
|
|
58
|
+
}
|
|
59
|
+
case 'DELETE /SASjsApi/drive/file':
|
|
60
|
+
return {
|
|
61
|
+
status: 200,
|
|
62
|
+
body: await drive.remove(String(query._filePath || ''))
|
|
63
|
+
};
|
|
64
|
+
case 'GET /SASjsApi/drive/folder':
|
|
65
|
+
return { status: 200, body: await drive.list(query._folderPath) };
|
|
66
|
+
case 'POST /SASjsApi/drive/folder':
|
|
67
|
+
return {
|
|
68
|
+
status: 200,
|
|
69
|
+
body: await drive.mkdir(String(request.body?.folderPath || ''))
|
|
70
|
+
};
|
|
71
|
+
case 'DELETE /SASjsApi/drive/folder':
|
|
72
|
+
return {
|
|
73
|
+
status: 200,
|
|
74
|
+
body: await drive.rmdir(String(query._folderPath || ''))
|
|
75
|
+
};
|
|
76
|
+
case 'POST /SASjsApi/drive/rename':
|
|
77
|
+
return {
|
|
78
|
+
status: 200,
|
|
79
|
+
body: await drive.rename(String(request.body?.oldPath || ''), String(request.body?.newPath || ''))
|
|
80
|
+
};
|
|
81
|
+
case 'GET /SASjsApi/drive/filetree':
|
|
82
|
+
return { status: 200, body: await drive.tree(query._folderPath) };
|
|
83
|
+
default:
|
|
84
|
+
throw new ApiError(501, `This package has no handler for ${routeId}.`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/** Whether a route is one `dispatch` can answer today. */
|
|
88
|
+
export const HANDLED_ROUTES = [
|
|
89
|
+
'GET /SASjsApi/drive/file',
|
|
90
|
+
'POST /SASjsApi/drive/file',
|
|
91
|
+
'PATCH /SASjsApi/drive/file',
|
|
92
|
+
'DELETE /SASjsApi/drive/file',
|
|
93
|
+
'GET /SASjsApi/drive/folder',
|
|
94
|
+
'POST /SASjsApi/drive/folder',
|
|
95
|
+
'DELETE /SASjsApi/drive/folder',
|
|
96
|
+
'POST /SASjsApi/drive/rename',
|
|
97
|
+
'GET /SASjsApi/drive/filetree'
|
|
98
|
+
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sasjs/api` - the SASjs Server API described once.
|
|
3
|
+
*
|
|
4
|
+
* The package has three parts, and each is usable on its own:
|
|
5
|
+
*
|
|
6
|
+
* - the route table (`ROUTES`, `findRoute`), generated from sasjs/server's
|
|
7
|
+
* published OpenAPI document and amended by a reviewed overlay;
|
|
8
|
+
* - the services (`createDriveService`), which are the API's decisions over
|
|
9
|
+
* storage a host supplies;
|
|
10
|
+
* - the conformance corpus (`DRIVE_CORPUS`, `runCase`), which is what "the
|
|
11
|
+
* same API" means when a test has to check it.
|
|
12
|
+
*
|
|
13
|
+
* It imports no runtime on purpose: the same build runs in an Express server and
|
|
14
|
+
* in a service worker, so anything node-shaped is a store a host passes in.
|
|
15
|
+
*/
|
|
16
|
+
export * from './types.js';
|
|
17
|
+
export { normaliseDrivePath, hasBlockedExtension, dirname, basename, isRoot } from './drive/paths.js';
|
|
18
|
+
export { createDriveService } from './drive/service.js';
|
|
19
|
+
export type { DriveService, DriveResult, DriveListing, DriveFile, DriveTree } from './drive/service.js';
|
|
20
|
+
export { MemoryDriveStore } from './drive/memory-store.js';
|
|
21
|
+
export { ROUTES, routeById, findRoute } from './routes/index.js';
|
|
22
|
+
export type { RouteMatch } from './routes/index.js';
|
|
23
|
+
export { OVERLAY, TAG_CAPABILITIES } from './routes/overlay.js';
|
|
24
|
+
export type { RouteOverlay } from './routes/overlay.js';
|
|
25
|
+
export { dispatch, HANDLED_ROUTES } from './host/dispatch.js';
|
|
26
|
+
export type { ApiRequest, ApiResponse, Services } from './host/dispatch.js';
|
|
27
|
+
export { DRIVE_CORPUS } from './corpus/drive.js';
|
|
28
|
+
export type { CorpusCase } from './corpus/drive.js';
|
|
29
|
+
export { runCase, servicesFor } from './corpus/runner.js';
|
|
30
|
+
export type { CaseResult } from './corpus/runner.js';
|
|
31
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,MAAM,EACP,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,YAAY,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,EACT,SAAS,EACV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAChE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAC/D,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAC7D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sasjs/api` - the SASjs Server API described once.
|
|
3
|
+
*
|
|
4
|
+
* The package has three parts, and each is usable on its own:
|
|
5
|
+
*
|
|
6
|
+
* - the route table (`ROUTES`, `findRoute`), generated from sasjs/server's
|
|
7
|
+
* published OpenAPI document and amended by a reviewed overlay;
|
|
8
|
+
* - the services (`createDriveService`), which are the API's decisions over
|
|
9
|
+
* storage a host supplies;
|
|
10
|
+
* - the conformance corpus (`DRIVE_CORPUS`, `runCase`), which is what "the
|
|
11
|
+
* same API" means when a test has to check it.
|
|
12
|
+
*
|
|
13
|
+
* It imports no runtime on purpose: the same build runs in an Express server and
|
|
14
|
+
* in a service worker, so anything node-shaped is a store a host passes in.
|
|
15
|
+
*/
|
|
16
|
+
export * from './types.js';
|
|
17
|
+
export { normaliseDrivePath, hasBlockedExtension, dirname, basename, isRoot } from './drive/paths.js';
|
|
18
|
+
export { createDriveService } from './drive/service.js';
|
|
19
|
+
export { MemoryDriveStore } from './drive/memory-store.js';
|
|
20
|
+
export { ROUTES, routeById, findRoute } from './routes/index.js';
|
|
21
|
+
export { OVERLAY, TAG_CAPABILITIES } from './routes/overlay.js';
|
|
22
|
+
export { dispatch, HANDLED_ROUTES } from './host/dispatch.js';
|
|
23
|
+
export { DRIVE_CORPUS } from './corpus/drive.js';
|
|
24
|
+
export { runCase, servicesFor } from './corpus/runner.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED - do not edit.
|
|
3
|
+
*
|
|
4
|
+
* Derived from spec/swagger.yaml (sasjs/server's published OpenAPI document) by
|
|
5
|
+
* scripts/sync-routes.mjs. Run `npm run sync:routes` after replacing that file;
|
|
6
|
+
* `npm run check:routes` fails the build when the two disagree.
|
|
7
|
+
*
|
|
8
|
+
* The overlay in ./overlay.ts adds what a document cannot say: the body each
|
|
9
|
+
* response carries, which capability a route needs, and whether this package
|
|
10
|
+
* implements it.
|
|
11
|
+
*/
|
|
12
|
+
import type { GeneratedRoute } from '../types.js';
|
|
13
|
+
export declare const GENERATED_ROUTES: GeneratedRoute[];
|
|
14
|
+
//# sourceMappingURL=generated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generated.d.ts","sourceRoot":"","sources":["../../../src/routes/generated.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,eAAO,MAAM,gBAAgB,EAAE,cAAc,EA+jD5C,CAAA"}
|