@toa.io/extensions.storages 1.0.0-alpha.0 → 1.0.0-alpha.2
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/package.json +12 -11
- package/readme.md +63 -19
- package/schemas/annotation.cos.yaml +10 -0
- package/schemas/fs.cos.yaml +9 -0
- package/schemas/mem.cos.yaml +6 -0
- package/schemas/s3.cos.yaml +16 -0
- package/schemas/test.cos.yaml +8 -0
- package/schemas/tmp.cos.yaml +9 -0
- package/source/Annotation.ts +39 -0
- package/source/Aspect.ts +6 -4
- package/source/Factory.ts +31 -28
- package/source/Provider.ts +30 -5
- package/source/Scanner.ts +3 -3
- package/source/Storage.test.ts +110 -105
- package/source/Storage.ts +13 -6
- package/source/deployment.ts +21 -29
- package/source/providers/Declaration.ts +10 -0
- package/source/providers/FileSystem.test.ts +1 -9
- package/source/providers/FileSystem.ts +20 -15
- package/source/providers/Memory.ts +41 -0
- package/source/providers/S3.test.ts +133 -0
- package/source/providers/S3.ts +114 -39
- package/source/providers/Temporary.ts +8 -6
- package/source/providers/Test.ts +8 -8
- package/source/providers/index.test.ts +24 -19
- package/source/providers/index.ts +10 -9
- package/source/providers/readme.md +1 -1
- package/source/schemas.test.ts +58 -0
- package/source/schemas.ts +15 -0
- package/source/test/util.ts +25 -54
- package/transpiled/Annotation.d.ts +3 -0
- package/transpiled/Annotation.js +57 -0
- package/transpiled/Annotation.js.map +1 -0
- package/transpiled/Aspect.d.ts +8 -0
- package/transpiled/Aspect.js +25 -0
- package/transpiled/Aspect.js.map +1 -0
- package/transpiled/Entry.d.ts +14 -0
- package/transpiled/Entry.js +3 -0
- package/transpiled/Entry.js.map +1 -0
- package/transpiled/Factory.d.ts +9 -0
- package/transpiled/Factory.js +53 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Provider.d.ts +20 -0
- package/transpiled/Provider.js +36 -0
- package/transpiled/Provider.js.map +1 -0
- package/transpiled/Scanner.d.ts +26 -0
- package/transpiled/Scanner.js +98 -0
- package/transpiled/Scanner.js.map +1 -0
- package/transpiled/Storage.d.ts +32 -0
- package/transpiled/Storage.js +176 -0
- package/transpiled/Storage.js.map +1 -0
- package/transpiled/deployment.d.ts +5 -0
- package/transpiled/deployment.js +68 -0
- package/transpiled/deployment.js.map +1 -0
- package/transpiled/index.d.ts +4 -0
- package/transpiled/index.js +10 -0
- package/transpiled/index.js.map +1 -0
- package/transpiled/manifest.d.ts +1 -0
- package/transpiled/manifest.js +9 -0
- package/transpiled/manifest.js.map +1 -0
- package/transpiled/providers/Declaration.d.ts +14 -0
- package/transpiled/providers/Declaration.js +3 -0
- package/transpiled/providers/Declaration.js.map +1 -0
- package/transpiled/providers/FileSystem.d.ts +15 -0
- package/transpiled/providers/FileSystem.js +44 -0
- package/transpiled/providers/FileSystem.js.map +1 -0
- package/transpiled/providers/Memory.d.ts +13 -0
- package/transpiled/providers/Memory.js +60 -0
- package/transpiled/providers/Memory.js.map +1 -0
- package/transpiled/providers/S3.d.ts +27 -0
- package/transpiled/providers/S3.js +154 -0
- package/transpiled/providers/S3.js.map +1 -0
- package/transpiled/providers/Temporary.d.ts +8 -0
- package/transpiled/providers/Temporary.js +14 -0
- package/transpiled/providers/Temporary.js.map +1 -0
- package/transpiled/providers/Test.d.ts +6 -0
- package/transpiled/providers/Test.js +15 -0
- package/transpiled/providers/Test.js.map +1 -0
- package/transpiled/providers/index.d.ts +13 -0
- package/transpiled/providers/index.js +16 -0
- package/transpiled/providers/index.js.map +1 -0
- package/transpiled/schemas.d.ts +9 -0
- package/transpiled/schemas.js +14 -0
- package/transpiled/schemas.js.map +1 -0
- package/transpiled/test/util.d.ts +29 -0
- package/transpiled/test/util.js +38 -0
- package/transpiled/test/util.js.map +1 -0
- package/transpiled/tsconfig.tsbuildinfo +1 -0
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { FileSystem } from './FileSystem'
|
|
2
2
|
|
|
3
3
|
it('should be ok', async () => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
expect(() => new FileSystem(url)).not.toThrow()
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
it('should throw if url contains host', async () => {
|
|
10
|
-
const url = new URL('file://host/path')
|
|
11
|
-
|
|
12
|
-
expect(() => new FileSystem(url)).toThrow()
|
|
4
|
+
expect(() => new FileSystem({ path: 'path' })).not.toThrow()
|
|
13
5
|
})
|
|
@@ -1,46 +1,51 @@
|
|
|
1
1
|
import { type Readable } from 'node:stream'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import { createReadStream } from 'node:fs'
|
|
4
3
|
import fs from 'node:fs/promises'
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
4
|
+
import { Provider } from '../Provider'
|
|
5
|
+
import type { ProviderSecrets } from '../Provider'
|
|
7
6
|
|
|
8
|
-
export
|
|
7
|
+
export interface FileSystemOptions {
|
|
8
|
+
path: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class FileSystem extends Provider<FileSystemOptions> {
|
|
9
12
|
protected readonly path: string
|
|
10
13
|
|
|
11
|
-
public constructor (
|
|
12
|
-
|
|
13
|
-
throw new Error('File system URL must not contain host')
|
|
14
|
+
public constructor (options: FileSystemOptions, secrets?: ProviderSecrets) {
|
|
15
|
+
super(options, secrets)
|
|
14
16
|
|
|
15
|
-
this.path =
|
|
17
|
+
this.path = options.path
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
public async get (path: string): Promise<Readable | null> {
|
|
19
|
-
|
|
21
|
+
try {
|
|
22
|
+
const fd = await fs.open(join(this.path, path))
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
return fd.createReadStream()
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return null
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
throw err
|
|
29
|
+
}
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
public async put (rel: string, filename: string, stream: Readable): Promise<void> {
|
|
28
33
|
const dir = join(this.path, rel)
|
|
29
34
|
const path = join(dir, filename)
|
|
30
35
|
|
|
31
|
-
await
|
|
36
|
+
await fs.mkdir(dirname(path), { recursive: true })
|
|
32
37
|
await fs.writeFile(path, stream)
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
public async delete (path: string): Promise<void> {
|
|
36
|
-
await
|
|
41
|
+
await fs.rm(join(this.path, path), { recursive: true, force: true })
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
public async move (from: string, to: string): Promise<void> {
|
|
40
45
|
from = join(this.path, from)
|
|
41
46
|
to = join(this.path, to)
|
|
42
47
|
|
|
43
|
-
await
|
|
48
|
+
await fs.mkdir(dirname(to), { recursive: true })
|
|
44
49
|
await fs.rename(from, to)
|
|
45
50
|
}
|
|
46
51
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { buffer } from 'node:stream/consumers'
|
|
4
|
+
import * as assert from 'node:assert'
|
|
5
|
+
|
|
6
|
+
import { Provider } from '../Provider'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory provider
|
|
10
|
+
*/
|
|
11
|
+
export class InMemory extends Provider {
|
|
12
|
+
private readonly storage = new Map<string, Buffer>()
|
|
13
|
+
|
|
14
|
+
public override async get (path: string): Promise<Readable | null> {
|
|
15
|
+
const data = this.storage.get(path)
|
|
16
|
+
|
|
17
|
+
if (data === undefined) return null
|
|
18
|
+
|
|
19
|
+
return Readable.from(data)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public override async put (path: string, filename: string, stream: Readable): Promise<void> {
|
|
23
|
+
this.storage.set(join(path, filename), await buffer(stream))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public override async delete (path: string): Promise<void> {
|
|
27
|
+
for (const f of this.storage.keys())
|
|
28
|
+
if (f.startsWith(path)) this.storage.delete(f)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public override async move (from: string, to: string): Promise<void> {
|
|
32
|
+
assert.notEqual(from, to, 'Source and destination are the same')
|
|
33
|
+
|
|
34
|
+
const buf = this.storage.get(from)
|
|
35
|
+
|
|
36
|
+
assert.ok(buf !== undefined, `File not found: ${from}`)
|
|
37
|
+
|
|
38
|
+
this.storage.set(to, buf)
|
|
39
|
+
this.storage.delete(from)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import streamConsumers from 'node:stream/consumers'
|
|
4
|
+
import { http, HttpResponse } from 'msw'
|
|
5
|
+
import { setupServer } from 'msw/node'
|
|
6
|
+
|
|
7
|
+
import { S3 } from './S3'
|
|
8
|
+
|
|
9
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion -- jest expect is not type guard */
|
|
10
|
+
|
|
11
|
+
describe('S3 storage provider', () => {
|
|
12
|
+
let provider: S3
|
|
13
|
+
let s3endpoint: ReturnType<typeof setupServer>
|
|
14
|
+
|
|
15
|
+
const AWS_REGION = 'us-east-1'
|
|
16
|
+
|
|
17
|
+
const TEST_BUCKET_NAME = 'test-bucket'
|
|
18
|
+
const S3_ENDPOINT = `https://${TEST_BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com`
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
jest.useRealTimers()
|
|
22
|
+
s3endpoint = setupServer()
|
|
23
|
+
s3endpoint.listen({ onUnhandledRequest: 'error' })
|
|
24
|
+
|
|
25
|
+
provider = new S3({
|
|
26
|
+
bucket: TEST_BUCKET_NAME,
|
|
27
|
+
region: AWS_REGION
|
|
28
|
+
}, {
|
|
29
|
+
ACCESS_KEY_ID: 'test-key',
|
|
30
|
+
SECRET_ACCESS_KEY: 'test-key-secret'
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
s3endpoint.resetHandlers()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
s3endpoint.close()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('should be able to get file from S3 handling leading slash', async () => {
|
|
43
|
+
const testBody = randomUUID().repeat(10)
|
|
44
|
+
|
|
45
|
+
s3endpoint.use(http.get(`${S3_ENDPOINT}/some/absolute/path/filename`,
|
|
46
|
+
() => HttpResponse.text(testBody)))
|
|
47
|
+
|
|
48
|
+
const body = await provider.get('/some/absolute/path/filename')
|
|
49
|
+
|
|
50
|
+
expect(body).toBeInstanceOf(Readable)
|
|
51
|
+
expect((await streamConsumers.text(body!))).toBe(testBody)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('should remove folder with all files', async () => {
|
|
55
|
+
const headHandler = jest.fn().mockReturnValue(HttpResponse.xml('', { status: 404 }))
|
|
56
|
+
|
|
57
|
+
const listHandler = jest.fn().mockReturnValueOnce(HttpResponse.xml(`
|
|
58
|
+
<ListBucketResult>
|
|
59
|
+
<NextContinuationToken>someToken</NextContinuationToken>
|
|
60
|
+
<KeyCount>1001</KeyCount>
|
|
61
|
+
<MaxKeys>1000</MaxKeys>
|
|
62
|
+
<IsTruncated>true</IsTruncated>
|
|
63
|
+
<Contents>
|
|
64
|
+
<Key>happy_face1.jpg</Key>
|
|
65
|
+
</Contents>
|
|
66
|
+
</ListBucketResult>
|
|
67
|
+
`, {
|
|
68
|
+
status: 200
|
|
69
|
+
})).mockReturnValueOnce(HttpResponse.xml(`
|
|
70
|
+
<ListBucketResult>
|
|
71
|
+
<KeyCount>1001</KeyCount>
|
|
72
|
+
<MaxKeys>1000</MaxKeys>
|
|
73
|
+
<Contents>
|
|
74
|
+
<Key>happy_face2.jpg</Key>
|
|
75
|
+
</Contents>
|
|
76
|
+
<Contents>
|
|
77
|
+
<Key>happy_face3.jpg</Key>
|
|
78
|
+
</Contents>
|
|
79
|
+
</ListBucketResult>
|
|
80
|
+
`, {
|
|
81
|
+
status: 200
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const deleteHandler = jest.fn().mockReturnValue(HttpResponse.xml(`
|
|
85
|
+
<DeleteResult>
|
|
86
|
+
<Deleted>
|
|
87
|
+
<Key>happy_face1.jpg</Key>
|
|
88
|
+
</Deleted>
|
|
89
|
+
<Deleted>
|
|
90
|
+
<Key>happy_face2.jpg</Key>
|
|
91
|
+
</Deleted>
|
|
92
|
+
<Deleted>
|
|
93
|
+
<Key>happy_face3.jpg</Key>
|
|
94
|
+
</Deleted>
|
|
95
|
+
</DeleteResult>
|
|
96
|
+
`, {
|
|
97
|
+
status: 200
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
s3endpoint.use(http.head(`${S3_ENDPOINT}/some/absolute/path_to_remove`,
|
|
101
|
+
headHandler),
|
|
102
|
+
http.get(`${S3_ENDPOINT}/`,
|
|
103
|
+
listHandler),
|
|
104
|
+
http.post(`${S3_ENDPOINT}/`,
|
|
105
|
+
deleteHandler))
|
|
106
|
+
|
|
107
|
+
await provider.delete('/some/absolute/path_to_remove')
|
|
108
|
+
expect(headHandler).toHaveBeenCalledTimes(1)
|
|
109
|
+
expect(listHandler).toHaveBeenCalledTimes(2)
|
|
110
|
+
expect(deleteHandler).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('should be able to upload a file to S3', async () => {
|
|
114
|
+
const body = Readable.from('test content')
|
|
115
|
+
|
|
116
|
+
const handler = jest.fn().mockReturnValue(HttpResponse.xml('', {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: {
|
|
119
|
+
ETag: 'test-etag',
|
|
120
|
+
'x-amz-id-2': 'test_id_2',
|
|
121
|
+
'x-amz-request-id': 'test_request_id',
|
|
122
|
+
Server: 'AmazonS3'
|
|
123
|
+
}
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
s3endpoint.use(http.put(`${S3_ENDPOINT}/some/absolute/path/filename`,
|
|
127
|
+
handler))
|
|
128
|
+
|
|
129
|
+
await provider.put('/some/absolute/path', 'filename', body)
|
|
130
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
131
|
+
expect(body.readableEnded).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
})
|
package/source/providers/S3.ts
CHANGED
|
@@ -1,91 +1,166 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { Blob } from 'node:buffer'
|
|
2
3
|
import { join } from 'node:path/posix'
|
|
4
|
+
import assert from 'node:assert'
|
|
3
5
|
import { Upload } from '@aws-sdk/lib-storage'
|
|
4
6
|
import {
|
|
5
7
|
S3Client,
|
|
6
8
|
GetObjectCommand,
|
|
7
9
|
CopyObjectCommand,
|
|
8
|
-
ListObjectsV2Command,
|
|
9
10
|
DeleteObjectsCommand,
|
|
10
|
-
|
|
11
|
+
paginateListObjectsV2,
|
|
12
|
+
HeadObjectCommand,
|
|
13
|
+
NotFound,
|
|
14
|
+
DeleteObjectCommand,
|
|
15
|
+
NoSuchKey,
|
|
16
|
+
type S3ClientConfigType,
|
|
17
|
+
type ObjectIdentifier
|
|
11
18
|
} from '@aws-sdk/client-s3'
|
|
12
|
-
import
|
|
19
|
+
import * as nodeNativeFetch from 'smithy-node-native-fetch'
|
|
20
|
+
import { Provider, type ProviderSecret, type ProviderSecrets } from '../Provider'
|
|
21
|
+
import type { ReadableStream } from 'node:stream/web'
|
|
22
|
+
|
|
23
|
+
export interface S3Options {
|
|
24
|
+
bucket: string
|
|
25
|
+
region?: string
|
|
26
|
+
prefix?: string
|
|
27
|
+
endpoint?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type S3Secrets = ProviderSecrets<'ACCESS_KEY_ID' | 'SECRET_ACCESS_KEY'>
|
|
31
|
+
|
|
32
|
+
export class S3 extends Provider<S3Options> {
|
|
33
|
+
public static override readonly SECRETS: readonly ProviderSecret[] = [
|
|
34
|
+
{ name: 'ACCESS_KEY_ID', optional: true },
|
|
35
|
+
{ name: 'SECRET_ACCESS_KEY', optional: true }
|
|
36
|
+
]
|
|
13
37
|
|
|
14
|
-
export class S3 implements Provider {
|
|
15
38
|
protected readonly bucket: string
|
|
16
39
|
protected readonly client: S3Client
|
|
17
40
|
|
|
18
|
-
public constructor (
|
|
19
|
-
|
|
41
|
+
public constructor (options: S3Options, secrets?: S3Secrets) {
|
|
42
|
+
super(options)
|
|
20
43
|
|
|
21
|
-
|
|
22
|
-
throw new Error('S3 bucket not specified')
|
|
44
|
+
this.bucket = options.bucket
|
|
23
45
|
|
|
24
46
|
const s3Config: S3ClientConfigType = {
|
|
25
|
-
|
|
47
|
+
retryMode: 'adaptive',
|
|
48
|
+
...nodeNativeFetch
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (options.endpoint !== undefined) {
|
|
52
|
+
s3Config.forcePathStyle = options.endpoint.startsWith('http://')
|
|
53
|
+
s3Config.endpoint = options.endpoint
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (options.region !== undefined) s3Config.region = options.region
|
|
57
|
+
|
|
58
|
+
if (typeof secrets?.ACCESS_KEY_ID === 'string') {
|
|
59
|
+
assert.ok(secrets.SECRET_ACCESS_KEY !== undefined,
|
|
60
|
+
'SECRET_ACCESS_KEY is required if ACCESS_KEY_ID is provided')
|
|
61
|
+
|
|
62
|
+
s3Config.credentials = {
|
|
26
63
|
accessKeyId: secrets.ACCESS_KEY_ID,
|
|
27
64
|
secretAccessKey: secrets.SECRET_ACCESS_KEY
|
|
28
|
-
}
|
|
29
|
-
region: url.host,
|
|
30
|
-
endpoint: url.searchParams.get('endpoint') ?? undefined
|
|
65
|
+
}
|
|
31
66
|
}
|
|
32
67
|
|
|
33
68
|
this.client = new S3Client(s3Config)
|
|
69
|
+
|
|
70
|
+
this.client.middlewareStack.add((next, _context) => async (args) => {
|
|
71
|
+
if ('Key' in args.input && typeof args.input.Key === 'string')
|
|
72
|
+
// removes leading slash
|
|
73
|
+
args.input.Key = args.input.Key.replace(/^\//, '')
|
|
74
|
+
|
|
75
|
+
if ('Prefix' in args.input && typeof args.input.Prefix === 'string')
|
|
76
|
+
// removes leading slash and ensures finishing slash
|
|
77
|
+
args.input.Prefix = args.input.Prefix.replace(/^\/|\/$/g, '') + '/'
|
|
78
|
+
|
|
79
|
+
return next(args)
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
step: 'initialize',
|
|
83
|
+
priority: 'high',
|
|
84
|
+
name: 'normalizesSlashesInPath'
|
|
85
|
+
})
|
|
34
86
|
}
|
|
35
87
|
|
|
36
|
-
public async get (
|
|
88
|
+
public async get (Key: string): Promise<Readable | null> {
|
|
37
89
|
try {
|
|
38
90
|
const fileResponse = await this.client.send(new GetObjectCommand({
|
|
39
91
|
Bucket: this.bucket,
|
|
40
|
-
Key
|
|
92
|
+
Key
|
|
41
93
|
}))
|
|
42
94
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
95
|
+
if (fileResponse.Body === undefined) return null // should never happen
|
|
96
|
+
|
|
97
|
+
if (fileResponse.Body instanceof Readable) return fileResponse.Body
|
|
98
|
+
|
|
99
|
+
return Readable.fromWeb((fileResponse.Body instanceof Blob
|
|
100
|
+
? fileResponse.Body.stream()
|
|
101
|
+
: fileResponse.Body) as ReadableStream) // types mismatch between Node 20 and aws-sdk
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err instanceof NotFound || err instanceof NoSuchKey) return null
|
|
46
104
|
else throw err
|
|
47
105
|
}
|
|
48
106
|
}
|
|
49
107
|
|
|
50
108
|
public async put (path: string, filename: string, stream: Readable): Promise<void> {
|
|
51
|
-
const key = join(path, filename)
|
|
52
|
-
|
|
53
109
|
await new Upload({
|
|
54
110
|
client: this.client,
|
|
55
111
|
params: {
|
|
56
112
|
Bucket: this.bucket,
|
|
57
|
-
Key:
|
|
113
|
+
Key: join(path, filename),
|
|
58
114
|
Body: stream
|
|
59
115
|
}
|
|
60
116
|
}).done()
|
|
61
117
|
}
|
|
62
118
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Deletes either a single object or "directory" - all objects
|
|
121
|
+
* with given prefix (prefix will be enforced to finish with `/`)
|
|
122
|
+
* @param Key - key name or path prefix
|
|
123
|
+
*/
|
|
124
|
+
public async delete (Key: string): Promise<void> {
|
|
125
|
+
const { client, bucket: Bucket } = this
|
|
68
126
|
|
|
69
|
-
if
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
}))
|
|
76
|
-
}
|
|
127
|
+
// checking if given key is a single file
|
|
128
|
+
if (!Key.endsWith('/'))
|
|
129
|
+
try {
|
|
130
|
+
// DeleteObject on S3 returns no error if object does not exist
|
|
131
|
+
await client.send(new HeadObjectCommand({ Bucket, Key }))
|
|
132
|
+
await client.send(new DeleteObjectCommand({ Bucket, Key }))
|
|
77
133
|
|
|
78
|
-
|
|
79
|
-
|
|
134
|
+
return
|
|
135
|
+
} catch (err) {
|
|
136
|
+
assert.ok(err instanceof NotFound || err instanceof NoSuchKey, err as Error)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const objectsToRemove: ObjectIdentifier[] = []
|
|
80
140
|
|
|
81
|
-
await
|
|
141
|
+
for await (const page of paginateListObjectsV2({ client }, { Bucket, Prefix: Key }))
|
|
142
|
+
for (const { Key } of page.Contents ?? []) objectsToRemove.push({ Key })
|
|
82
143
|
|
|
144
|
+
// Removing all objects in parallel in batches
|
|
145
|
+
await Promise.all((function * () {
|
|
146
|
+
while (objectsToRemove.length > 0)
|
|
147
|
+
yield client.send(new DeleteObjectsCommand({
|
|
148
|
+
Bucket,
|
|
149
|
+
Delete: {
|
|
150
|
+
Objects: objectsToRemove.splice(0,
|
|
151
|
+
1000 /* max batch size for DeleteObjects */)
|
|
152
|
+
}
|
|
153
|
+
}))
|
|
154
|
+
})())
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public async move (from: string, keyTo: string): Promise<void> {
|
|
83
158
|
await this.client.send(new CopyObjectCommand({
|
|
84
159
|
Bucket: this.bucket,
|
|
85
160
|
Key: keyTo,
|
|
86
|
-
CopySource:
|
|
161
|
+
CopySource: join(this.bucket, from)
|
|
87
162
|
}))
|
|
88
163
|
|
|
89
|
-
await this.
|
|
164
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: from }))
|
|
90
165
|
}
|
|
91
166
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { tmpdir } from 'node:os'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
|
|
4
3
|
import { FileSystem } from './FileSystem'
|
|
4
|
+
import type { ProviderSecrets } from '../Provider'
|
|
5
5
|
|
|
6
|
-
export
|
|
7
|
-
|
|
6
|
+
export interface TemporaryOptions {
|
|
7
|
+
directory: string
|
|
8
|
+
}
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
export class Temporary extends FileSystem {
|
|
11
|
+
public constructor (options: TemporaryOptions, secrets?: ProviderSecrets) {
|
|
12
|
+
const path = join(tmpdir(), options.directory)
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
super({ path }, secrets)
|
|
13
15
|
}
|
|
14
16
|
}
|
package/source/providers/Test.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { Temporary, type TemporaryOptions } from './Temporary'
|
|
2
|
+
import type { ProviderSecret, ProviderSecrets } from '../Provider'
|
|
3
3
|
|
|
4
4
|
export class Test extends Temporary {
|
|
5
|
-
public static readonly SECRETS = [
|
|
5
|
+
public static override readonly SECRETS: readonly ProviderSecret[] = [
|
|
6
|
+
{ name: 'USERNAME' },
|
|
7
|
+
{ name: 'PASSWORD' }
|
|
8
|
+
]
|
|
6
9
|
|
|
7
|
-
public constructor (
|
|
8
|
-
super(
|
|
9
|
-
|
|
10
|
-
assert(secrets.USERNAME !== undefined, 'Missing USERNAME')
|
|
11
|
-
assert(secrets.PASSWORD !== undefined, 'Missing PASSWORD')
|
|
10
|
+
public constructor (options: TemporaryOptions, secrets?: ProviderSecrets) {
|
|
11
|
+
super(options, secrets)
|
|
12
12
|
}
|
|
13
13
|
}
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { type Readable } from 'node:stream'
|
|
2
|
-
import { buffer } from '
|
|
3
|
-
import {
|
|
2
|
+
import { buffer } from 'node:stream/consumers'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { createReadStream } from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { suites } from '../test/util'
|
|
4
8
|
import { providers } from './index'
|
|
9
|
+
import type { ProviderConstructor } from '../Provider'
|
|
5
10
|
|
|
6
|
-
describe.each(
|
|
7
|
-
const
|
|
8
|
-
const Provider = providers[
|
|
9
|
-
const provider = new Provider(
|
|
11
|
+
describe.each(suites)('$provider', (suite) => {
|
|
12
|
+
const it = suite.run ? global.it : global.it.skip
|
|
13
|
+
const Provider: ProviderConstructor = providers[suite.provider]
|
|
14
|
+
const provider = new Provider(suite.options, suite.secrets)
|
|
10
15
|
|
|
11
16
|
let dir: string
|
|
12
17
|
|
|
13
18
|
beforeAll(async () => {
|
|
14
|
-
|
|
19
|
+
process.chdir(path.resolve(__dirname, '../test/'))
|
|
15
20
|
})
|
|
16
21
|
|
|
17
22
|
beforeEach(() => {
|
|
18
|
-
dir = '/' +
|
|
23
|
+
dir = '/' + randomUUID()
|
|
19
24
|
})
|
|
20
25
|
|
|
21
26
|
it('should be', async () => {
|
|
@@ -23,39 +28,39 @@ describe.each(cases)('%s', (...suite) => {
|
|
|
23
28
|
})
|
|
24
29
|
|
|
25
30
|
it('should return null if file not found', async () => {
|
|
26
|
-
const result = await provider.get(
|
|
31
|
+
const result = await provider.get(randomUUID())
|
|
27
32
|
|
|
28
33
|
expect(result).toBeNull()
|
|
29
34
|
})
|
|
30
35
|
|
|
31
36
|
it('should create entry', async () => {
|
|
32
|
-
const stream =
|
|
37
|
+
const stream = createReadStream('lenna.png')
|
|
33
38
|
|
|
34
39
|
await provider.put(dir, 'lenna.png', stream)
|
|
35
40
|
|
|
36
41
|
const readable = await provider.get(dir + '/lenna.png') as Readable
|
|
37
42
|
const output = await buffer(readable)
|
|
38
|
-
const lenna = await
|
|
43
|
+
const lenna = await readFile('lenna.png')
|
|
39
44
|
|
|
40
45
|
expect(output.compare(lenna)).toBe(0)
|
|
41
46
|
})
|
|
42
47
|
|
|
43
48
|
it('should overwrite existing entry', async () => {
|
|
44
|
-
const stream0 =
|
|
45
|
-
const stream1 =
|
|
49
|
+
const stream0 = createReadStream('lenna.png')
|
|
50
|
+
const stream1 = createReadStream('albert.jpg')
|
|
46
51
|
|
|
47
52
|
await provider.put(dir, 'lenna.png', stream0)
|
|
48
53
|
await provider.put(dir, 'lenna.png', stream1)
|
|
49
54
|
|
|
50
55
|
const readable = await provider.get(dir + '/lenna.png') as Readable
|
|
51
56
|
const output = await buffer(readable)
|
|
52
|
-
const albert = await
|
|
57
|
+
const albert = await readFile('albert.jpg')
|
|
53
58
|
|
|
54
59
|
expect(output.compare(albert)).toBe(0)
|
|
55
60
|
})
|
|
56
61
|
|
|
57
62
|
it('should get by path', async () => {
|
|
58
|
-
const stream =
|
|
63
|
+
const stream = createReadStream('lenna.png')
|
|
59
64
|
|
|
60
65
|
await provider.put(dir, 'lenna.png', stream)
|
|
61
66
|
|
|
@@ -77,7 +82,7 @@ describe.each(cases)('%s', (...suite) => {
|
|
|
77
82
|
*/
|
|
78
83
|
|
|
79
84
|
it('should delete entry', async () => {
|
|
80
|
-
const stream =
|
|
85
|
+
const stream = createReadStream('lenna.png')
|
|
81
86
|
|
|
82
87
|
await provider.put(dir, 'lenna.png', stream)
|
|
83
88
|
await provider.delete(dir + '/lenna.png')
|
|
@@ -92,7 +97,7 @@ describe.each(cases)('%s', (...suite) => {
|
|
|
92
97
|
})
|
|
93
98
|
|
|
94
99
|
it('should delete directory', async () => {
|
|
95
|
-
const stream =
|
|
100
|
+
const stream = createReadStream('lenna.png')
|
|
96
101
|
|
|
97
102
|
await provider.put(dir, 'lenna.png', stream)
|
|
98
103
|
await provider.delete(dir)
|
|
@@ -103,8 +108,8 @@ describe.each(cases)('%s', (...suite) => {
|
|
|
103
108
|
})
|
|
104
109
|
|
|
105
110
|
it('should move an entry', async () => {
|
|
106
|
-
const stream =
|
|
107
|
-
const dir2 = '/' +
|
|
111
|
+
const stream = createReadStream('lenna.png')
|
|
112
|
+
const dir2 = '/' + randomUUID()
|
|
108
113
|
|
|
109
114
|
await provider.put(dir, 'lenna.png', stream)
|
|
110
115
|
await provider.move(dir + '/lenna.png', dir2 + '/lenna2.png')
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { type Provider } from '../Provider'
|
|
2
1
|
import { FileSystem } from './FileSystem'
|
|
3
2
|
import { S3 } from './S3'
|
|
4
3
|
import { Temporary } from './Temporary'
|
|
5
4
|
import { Test } from './Test'
|
|
5
|
+
import { InMemory } from './Memory'
|
|
6
|
+
import type { ProviderConstructor } from '../Provider'
|
|
6
7
|
|
|
7
|
-
export const providers
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
export const providers = {
|
|
9
|
+
s3: S3,
|
|
10
|
+
fs: FileSystem,
|
|
11
|
+
tmp: Temporary,
|
|
12
|
+
mem: InMemory,
|
|
13
|
+
test: Test
|
|
14
|
+
} as const satisfies Record<string, ProviderConstructor>
|
|
13
15
|
|
|
14
|
-
export type
|
|
15
|
-
(new (url: URL, secrets: Record<string, string>) => Provider) & { SECRETS?: string[] }
|
|
16
|
+
export type { Declaration } from './Declaration'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Developing a provider
|
|
2
2
|
|
|
3
|
-
1. Add an
|
|
3
|
+
1. Add an implementation of the [Provider](../Provider.ts) interface under this directory.
|
|
4
4
|
2. Add an entry to the provider map in [`index.ts`](./index.ts).
|
|
5
5
|
3. Add a suite to the `suites` object in [`util.ts`](../.test/util.ts).
|
|
6
6
|
4. Run `$ npm test` in the [`storages` directory](../..).
|