Skip to content

Commit a2828fe

Browse files
authored
feat(fs): support non recursive file listing (#370)
limit file listing to be non recursive with {recursive: false}
1 parent 2d9975b commit a2828fe

File tree

11 files changed

+265
-61
lines changed

11 files changed

+265
-61
lines changed

packages/__tests__/index.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import S3v3 from '@aws-sdk/client-s3';
2+
import { FsAwsS3 } from '@chunkd/source-aws';
3+
import { S3LikeV3 } from '@chunkd/source-aws-v3';
4+
import { FsGoogleStorage } from '@chunkd/source-google-cloud';
5+
import { Storage } from '@google-cloud/storage';
6+
import S3 from 'aws-sdk/clients/s3.js';
7+
import o from 'ospec';
8+
import { fsa } from '../fs/build/index.node.js';
9+
10+
fsa.register(`s3://blacha-chunkd-test/v2`, new FsAwsS3(new S3()));
11+
fsa.register(`s3://blacha-chunkd-test/v3`, new FsAwsS3(new S3LikeV3(new S3v3.S3())));
12+
fsa.register(`gs://blacha-chunkd-test/`, new FsGoogleStorage(new Storage()));
13+
14+
const TestFiles = [
15+
{ path: 'a/b/file-a-b-1.txt', buffer: Buffer.from('a/b/file-a-b-1.txt') },
16+
{ path: 'a/b/file-a-b-2', buffer: Buffer.from('a/b/file-a-b-2') },
17+
{ path: 'a/file-a-1', buffer: Buffer.from('file-a-1') },
18+
{ path: 'c/file-c-1', buffer: Buffer.from('file-c-1') },
19+
{ path: 'd/file-d-1', buffer: Buffer.from('file-d-1') },
20+
{ path: 'file-1', buffer: Buffer.from('file-1') },
21+
{ path: 'file-2', buffer: Buffer.from('file-2') },
22+
{ path: '🦄.json', buffer: Buffer.from('🦄') },
23+
];
24+
25+
async function setupTestData(prefix) {
26+
try {
27+
const existing = await fsa.toArray(fsa.list(prefix));
28+
if (existing.length === TestFiles.length) return;
29+
} catch (e) {
30+
//noop
31+
}
32+
for (const file of TestFiles) {
33+
const target = fsa.join(prefix, file.path);
34+
console.log(target);
35+
await fsa.write(target, file.buffer);
36+
}
37+
}
38+
39+
function removeSlashes(f) {
40+
if (f.startsWith('/')) f = f.slice(1);
41+
if (f.endsWith('/')) f = f.slice(0, f.length - 1);
42+
return f;
43+
}
44+
45+
function testPrefix(prefix) {
46+
o.spec(prefix, () => {
47+
o.specTimeout(5000);
48+
o.before(async () => {
49+
await setupTestData(prefix);
50+
});
51+
52+
o('should list recursive:default ', async () => {
53+
const files = await fsa.toArray(fsa.list(prefix));
54+
o(files.length).equals(TestFiles.length);
55+
});
56+
57+
o('should list recursive:true ', async () => {
58+
const files = await fsa.toArray(fsa.list(prefix, { recursive: true }));
59+
o(files.length).equals(TestFiles.length);
60+
61+
for (const file of TestFiles) {
62+
o(files.find((f) => f.endsWith(file.path))).notEquals(undefined);
63+
}
64+
});
65+
66+
o('should list recursive:false ', async () => {
67+
const files = await fsa.toArray(fsa.list(prefix, { recursive: false }));
68+
o(files.length).equals(6);
69+
o(files.map((f) => f.slice(prefix.length)).map(removeSlashes)).deepEquals([
70+
'a',
71+
'c',
72+
'd',
73+
'file-1',
74+
'file-2',
75+
'🦄.json',
76+
]);
77+
});
78+
79+
o('should list folders', async () => {
80+
const files = await fsa.toArray(fsa.details(prefix, { recursive: false }));
81+
o(
82+
files
83+
.filter((f) => f.isDirectory)
84+
.map((f) => f.path.slice(prefix.length))
85+
.map(removeSlashes),
86+
).deepEquals(['a', 'c', 'd']);
87+
});
88+
89+
o('should read a file', async () => {
90+
const file = await fsa.read(fsa.join(prefix, TestFiles[0].path));
91+
o(file.toString()).equals(TestFiles[0].buffer.toString());
92+
});
93+
94+
o('should head a file', async () => {
95+
const ret = await fsa.head(fsa.join(prefix, TestFiles[0].path));
96+
o(ret.path).equals(fsa.join(prefix, TestFiles[0].path));
97+
o(ret.size).equals(TestFiles[0].buffer.length);
98+
});
99+
});
100+
}
101+
102+
testPrefix('/tmp/blacha-chunkd-test/');
103+
// testPrefix('s3://blacha-chunkd-test/v2/');
104+
// testPrefix('s3://blacha-chunkd-test/v3/');
105+
// testPrefix('gs://blacha-chunkd-test/');
106+
107+
o.run();

packages/core/src/fs.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export interface FileInfo {
99
* undefined if no size found
1010
*/
1111
size?: number;
12+
13+
/** Is this file a directory */
14+
isDirectory?: boolean;
1215
}
1316

1417
export interface WriteOptions {
@@ -18,6 +21,14 @@ export interface WriteOptions {
1821
contentType?: string;
1922
}
2023

24+
export interface ListOptions {
25+
/**
26+
* List recursively
27+
* @default true
28+
*/
29+
recursive?: boolean;
30+
}
31+
2132
export interface FileSystem<T extends ChunkSource = ChunkSource> {
2233
/**
2334
* Protocol used for communication
@@ -33,12 +44,10 @@ export interface FileSystem<T extends ChunkSource = ChunkSource> {
3344
stream(filePath: string): Readable;
3445
/** Write a file from either a buffer or stream */
3546
write(filePath: string, buffer: Buffer | Readable | string, opts?: Partial<WriteOptions>): Promise<void>;
36-
/** Recursively list all files in path */
37-
list(filePath: string): AsyncGenerator<string>;
38-
/** Recursively list all files in path with additional details */
39-
details(filePath: string): AsyncGenerator<FileInfo>;
40-
/** Does the path exists */
41-
exists(filePath: string): Promise<boolean>;
47+
/** list all files in path */
48+
list(filePath: string, opt?: ListOptions): AsyncGenerator<string>;
49+
/** list all files with file info in path */
50+
details(filePath: string, opt?: ListOptions): AsyncGenerator<FileInfo>;
4251
/** Get information about the path */
4352
head(filePath: string): Promise<FileInfo | null>;
4453
/** Create a file source to read chunks out of */

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { ChunkSourceBase } from './chunk.source.js';
33
export { SourceMemory } from './chunk.source.memory.js';
44
export { ChunkSource } from './source.js';
55
export { ErrorCodes, CompositeError } from './composite.js';
6-
export { FileSystem, FileInfo, WriteOptions } from './fs.js';
6+
export { FileSystem, FileInfo, WriteOptions, ListOptions } from './fs.js';
77

88
export function isRecord<T = unknown>(value: unknown): value is Record<string, T> {
99
return typeof value === 'object' && value !== null;

packages/fs/src/fs.abstraction.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Readable } from 'stream';
22
import { ChunkSource, FileInfo, FileSystem, WriteOptions } from '@chunkd/core';
3+
import { ListOptions } from '@chunkd/core';
34

45
export type FileWriteTypes = Buffer | Readable | string | Record<string, unknown> | Array<unknown>;
56

@@ -96,8 +97,8 @@ export class FileSystemAbstraction implements FileSystem {
9697
* @param filePath file path to search
9798
* @returns list of files inside that path
9899
*/
99-
list(filePath: string): AsyncGenerator<string> {
100-
return this.get(filePath).list(filePath);
100+
list(filePath: string, opts?: ListOptions): AsyncGenerator<string> {
101+
return this.get(filePath).list(filePath, opts);
101102
}
102103

103104
/**
@@ -107,8 +108,8 @@ export class FileSystemAbstraction implements FileSystem {
107108
* @param filePath file path to search
108109
* @returns list of files inside that path
109110
*/
110-
details(filePath: string): AsyncGenerator<FileInfo> {
111-
return this.get(filePath).details(filePath);
111+
details(filePath: string, opts?: ListOptions): AsyncGenerator<FileInfo> {
112+
return this.get(filePath).details(filePath, opts);
112113
}
113114

114115
/**
@@ -118,7 +119,9 @@ export class FileSystemAbstraction implements FileSystem {
118119
* @returns true if file exists, false otherwise
119120
*/
120121
exists(filePath: string): Promise<boolean> {
121-
return this.get(filePath).exists(filePath);
122+
return this.get(filePath)
123+
.head(filePath)
124+
.then((f) => f != null);
122125
}
123126

124127
/**
@@ -168,3 +171,17 @@ export class FileSystemAbstraction implements FileSystem {
168171
}
169172

170173
export const fsa = new FileSystemAbstraction();
174+
175+
// async function main(): Promise<void> {
176+
// for await (const f of fsa.list('')) {
177+
// console.log(f);
178+
// }
179+
180+
// for await (const f of fsa.list('', { details: true })) {
181+
// console.log(f.path);
182+
// }
183+
184+
// for await (const f of fsa.list('', { details: false, recursive: false })) {
185+
// console.log(f.path);
186+
// }
187+
// }

packages/source-aws/src/__test__/s3.fs.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,14 @@ o.spec('file.s3', () => {
7878
]);
7979
o(stub.callCount).equals(5);
8080
const [firstCall] = stub.args[0] as any;
81-
o(firstCall).deepEquals({ Bucket: 'bucket', Prefix: undefined, ContinuationToken: undefined });
81+
o(firstCall).deepEquals({
82+
Bucket: 'bucket',
83+
Prefix: undefined,
84+
ContinuationToken: undefined,
85+
Delimiter: undefined,
86+
});
8287
const [secondCall] = stub.args[1] as any;
83-
o(secondCall).deepEquals({ Bucket: 'bucket', Prefix: undefined, ContinuationToken: 1 });
88+
o(secondCall).deepEquals({ Bucket: 'bucket', Prefix: undefined, ContinuationToken: 1, Delimiter: undefined });
8489
});
8590

8691
o('should allow listing of bucket', async () => {
@@ -94,7 +99,12 @@ o.spec('file.s3', () => {
9499
o(data).deepEquals(['s3://bucket/FirstFile']);
95100
o(stub.callCount).equals(1);
96101
const [firstCall] = stub.args[0] as any;
97-
o(firstCall).deepEquals({ Bucket: 'bucket', Prefix: undefined, ContinuationToken: undefined });
102+
o(firstCall).deepEquals({
103+
Bucket: 'bucket',
104+
Prefix: undefined,
105+
ContinuationToken: undefined,
106+
Delimiter: undefined,
107+
});
98108
});
99109

100110
o('should allow listing of bucket with prefix', async () => {
@@ -108,7 +118,7 @@ o.spec('file.s3', () => {
108118
o(data).deepEquals(['s3://bucket/keyFirstFile']);
109119
o(stub.callCount).equals(1);
110120
const [firstCall] = stub.args[0] as any;
111-
o(firstCall).deepEquals({ Bucket: 'bucket', Prefix: 'key', ContinuationToken: undefined });
121+
o(firstCall).deepEquals({ Bucket: 'bucket', Prefix: 'key', ContinuationToken: undefined, Delimiter: undefined });
112122
});
113123
});
114124

packages/source-aws/src/s3.fs.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FileInfo, FileSystem, isRecord, parseUri, WriteOptions } from '@chunkd/core';
1+
import { FileInfo, FileSystem, isRecord, ListOptions, parseUri, WriteOptions } from '@chunkd/core';
22
import type { Readable } from 'stream';
33
import { getCompositeError, SourceAwsS3 } from './s3.source.js';
44
import { ListRes, S3Like, toPromise } from './type.js';
@@ -35,29 +35,36 @@ export class FsAwsS3 implements FileSystem<SourceAwsS3> {
3535

3636
/** Parse a s3:// URI into the bucket and key components */
3737

38-
async *list(filePath: string): AsyncGenerator<string> {
39-
for await (const obj of this.details(filePath)) yield obj.path;
38+
async *list(filePath: string, opts?: ListOptions): AsyncGenerator<string> {
39+
for await (const obj of this.details(filePath, opts)) yield obj.path;
4040
}
4141

42-
async *details(filePath: string): AsyncGenerator<FileInfo> {
43-
const opts = parseUri(filePath);
44-
if (opts == null) return;
42+
async *details(filePath: string, opts?: ListOptions): AsyncGenerator<FileInfo> {
43+
const loc = parseUri(filePath);
44+
if (loc == null) return;
4545
let ContinuationToken: string | undefined = undefined;
46-
const Bucket = opts.bucket;
47-
const Prefix = opts.key;
46+
const Delimiter: string | undefined = opts?.recursive === false ? '/' : undefined;
47+
const Bucket = loc.bucket;
48+
const Prefix = loc.key;
4849

4950
let count = 0;
5051
try {
5152
while (true) {
5253
count++;
53-
const res: ListRes = await toPromise(this.s3.listObjectsV2({ Bucket, Prefix, ContinuationToken }));
54+
const res: ListRes = await toPromise(this.s3.listObjectsV2({ Bucket, Prefix, ContinuationToken, Delimiter }));
5455

55-
// Failed to get any content abort
56-
if (res.Contents == null) break;
56+
if (res.CommonPrefixes != null) {
57+
for (const prefix of res.CommonPrefixes) {
58+
if (prefix.Prefix == null) continue;
59+
yield { path: `s3://${Bucket}/${prefix.Prefix}`, isDirectory: true };
60+
}
61+
}
5762

58-
for (const obj of res.Contents) {
59-
if (obj.Key == null) continue;
60-
yield { path: `s3://${Bucket}/${obj.Key}`, size: obj.Size };
63+
if (res.Contents != null) {
64+
for (const obj of res.Contents) {
65+
if (obj.Key == null) continue;
66+
yield { path: `s3://${Bucket}/${obj.Key}`, size: obj.Size };
67+
}
6168
}
6269

6370
// Nothing left to fetch

packages/source-aws/src/type.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ export type UploadReq = Location & {
2121
};
2222
export type UploadRes = unknown;
2323

24-
export type ListReq = { Bucket: string; Prefix?: string; ContinuationToken?: string };
24+
export type ListReq = { Bucket: string; Prefix?: string; ContinuationToken?: string; Delimiter?: string };
2525
export type ListResContents = { Key?: string; Size?: number };
2626
export type ListRes = {
2727
IsTruncated?: boolean;
2828
NextContinuationToken?: string;
2929
Contents?: ListResContents[];
30+
CommonPrefixes?: { Prefix?: string }[];
3031
};
3132

3233
export type HeadReq = Location;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import o from 'ospec';
3+
import path from 'path';
4+
import 'source-map-support/register.js';
5+
import { fileURLToPath } from 'url';
6+
import { FsFile } from '../file.fs.js';
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
10+
async function toArray<T>(generator: AsyncGenerator<T>): Promise<T[]> {
11+
const output: T[] = [];
12+
for await (const o of generator) output.push(o);
13+
return output;
14+
}
15+
16+
o.spec('LocalFileSystem', () => {
17+
const fs = new FsFile();
18+
19+
o('should read a file', async () => {
20+
const buf = await fs.read(path.join(__dirname, 'fs.file.test.js'));
21+
o(buf.toString().includes("import o from 'ospec'")).equals(true);
22+
});
23+
24+
o('should 404 when file not found', async () => {
25+
try {
26+
await fs.read(path.join(__dirname, 'NOT A FILE.js'));
27+
o(true).equals(false); // should have errored
28+
} catch (e: any) {
29+
o(e.code).equals(404);
30+
}
31+
});
32+
33+
o('should head/exists a file', async () => {
34+
const ref = await fs.head(path.join(__dirname, 'fs.file.test.js'));
35+
o(ref).notEquals(null);
36+
});
37+
38+
o('should list files', async () => {
39+
const files = await toArray(fs.list(__dirname));
40+
41+
o(files.length > 3).equals(true);
42+
o(files.find((f) => f.endsWith('__test__/fs.file.test.js'))).notEquals(undefined);
43+
});
44+
45+
o('should list files with details', async () => {
46+
const files = await toArray(fs.details(__dirname));
47+
48+
o(files.length > 3).equals(true);
49+
o(files.find((f) => f.path.endsWith('__test__/fs.file.test.js'))).notEquals(undefined);
50+
o(files.filter((f) => f.isDirectory)).deepEquals([]);
51+
});
52+
53+
o('should list recursively', async () => {
54+
const files = await toArray(fs.details(path.join(__dirname, '..')));
55+
o(files.find((f) => f.path.endsWith('__test__/fs.file.test.js'))).notEquals(undefined);
56+
o(files.filter((f) => f.isDirectory)).deepEquals([]);
57+
});
58+
59+
o('should list folders when not recursive', async () => {
60+
const files = await toArray(fs.details(path.join(__dirname, '..'), { recursive: false }));
61+
// In a sub folder shouldn't find it
62+
o(files.find((f) => f.path.endsWith('__test__/fs.file.test.js'))).equals(undefined);
63+
o(files.filter((f) => f.isDirectory).length).deepEquals(1);
64+
});
65+
});

0 commit comments

Comments
 (0)