@toa.io/extensions.storages 0.20.0-alpha.3

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.
@@ -0,0 +1,61 @@
1
+ import { encode } from 'msgpackr'
2
+ import { providers } from './providers'
3
+ import type { Dependency, Variable } from '@toa.io/operations'
4
+ import type { context } from '@toa.io/norm'
5
+
6
+ export function deployment (instances: Instance[], annotation: Annotation): Dependency {
7
+ validate(instances, annotation)
8
+
9
+ const value = encode(annotation).toString('base64')
10
+ const pointer: Variable = { name: 'TOA_STORAGES', value }
11
+ const secrets = getSecrets(annotation)
12
+
13
+ return {
14
+ variables: { global: [pointer, ...secrets] }
15
+ }
16
+ }
17
+
18
+ function validate (instances: Instance[], annotation: Annotation): void {
19
+ for (const instance of instances)
20
+ contains(instance, annotation)
21
+
22
+ for (const ref of Object.values(annotation)) {
23
+ const url = new URL(ref)
24
+
25
+ if (!(url.protocol in providers))
26
+ throw new Error(`Unknown storage provider '${url.protocol}'`)
27
+ }
28
+ }
29
+
30
+ function contains (instance: Instance, annotation: Annotation): void {
31
+ for (const name of instance.manifest)
32
+ if (!(name in annotation))
33
+ throw new Error(`Missing '${name}' storage annotation ` +
34
+ `declared in '${instance.component.locator.id}'`)
35
+ }
36
+
37
+ function getSecrets (annotation: Annotation): Variable[] {
38
+ const secrets: Variable[] = []
39
+
40
+ for (const [storage, ref] of Object.entries(annotation)) {
41
+ const url = new URL(ref)
42
+ const Provider = providers[url.protocol]
43
+
44
+ if (Provider.SECRETS === undefined)
45
+ continue
46
+
47
+ for (const secret of Provider.SECRETS)
48
+ secrets.push({
49
+ name: `TOA_STORAGES_${storage.toUpperCase()}_${secret.toUpperCase()}`,
50
+ secret: {
51
+ name: `toa-storages-${storage}`,
52
+ key: secret
53
+ }
54
+ } satisfies Variable)
55
+ }
56
+
57
+ return secrets
58
+ }
59
+
60
+ type Annotation = Record<string, string>
61
+ export type Instance = context.Dependency<string[]>
@@ -0,0 +1,3 @@
1
+ export { Factory } from './Factory'
2
+ export { deployment } from './deployment'
3
+ export { manifest } from './manifest'
@@ -0,0 +1,8 @@
1
+ import { match } from '@toa.io/match'
2
+
3
+ export function manifest (manifest: string | string[] | null): string[] {
4
+ return match(manifest,
5
+ () => typeof manifest === 'string', (name: string) => [name],
6
+ Array, manifest,
7
+ null, [])
8
+ }
@@ -0,0 +1,13 @@
1
+ import { FileSystem } from './FileSystem'
2
+
3
+ it('should be ok', async () => {
4
+ const url = new URL('file:///path')
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()
13
+ })
@@ -0,0 +1,46 @@
1
+ import { type Readable } from 'node:stream'
2
+ import { dirname, join } from 'node:path'
3
+ import { createReadStream } from 'node:fs'
4
+ import fs from 'node:fs/promises'
5
+ import fse from 'fs-extra'
6
+ import { type Provider } from '../Provider'
7
+
8
+ export class FileSystem implements Provider {
9
+ protected readonly path: string
10
+
11
+ public constructor (url: URL) {
12
+ if (url.host !== '')
13
+ throw new Error('File system URL must not contain host')
14
+
15
+ this.path = url.pathname
16
+ }
17
+
18
+ public async get (path: string): Promise<Readable | null> {
19
+ path = join(this.path, path)
20
+
21
+ if (!await fse.exists(path))
22
+ return null
23
+
24
+ return createReadStream(path)
25
+ }
26
+
27
+ public async put (rel: string, filename: string, stream: Readable): Promise<void> {
28
+ const dir = join(this.path, rel)
29
+ const path = join(dir, filename)
30
+
31
+ await fse.ensureDir(dir)
32
+ await fs.writeFile(path, stream)
33
+ }
34
+
35
+ public async delete (path: string): Promise<void> {
36
+ await fse.remove(join(this.path, path))
37
+ }
38
+
39
+ public async move (from: string, to: string): Promise<void> {
40
+ from = join(this.path, from)
41
+ to = join(this.path, to)
42
+
43
+ await fse.ensureDir(dirname(to))
44
+ await fs.rename(from, to)
45
+ }
46
+ }
@@ -0,0 +1,14 @@
1
+ import { tmpdir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ import { FileSystem } from './FileSystem'
5
+
6
+ export class Temporary extends FileSystem {
7
+ protected override readonly path: string
8
+
9
+ public constructor (url: URL) {
10
+ super(url)
11
+
12
+ this.path = join(tmpdir(), url.pathname)
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert'
2
+ import { Temporary } from './Temporary'
3
+
4
+ export class Test extends Temporary {
5
+ public static readonly SECRETS = ['USERNAME', 'PASSWORD']
6
+
7
+ public constructor (url: URL, secrets: Record<string, string>) {
8
+ super(url)
9
+
10
+ assert(secrets.USERNAME !== undefined, 'Missing USERNAME')
11
+ assert(secrets.PASSWORD !== undefined, 'Missing PASSWORD')
12
+ }
13
+ }
@@ -0,0 +1,116 @@
1
+ import { type Readable } from 'node:stream'
2
+ import { buffer } from '@toa.io/streams'
3
+ import { cases, open, rnd, read } from '../.test/util'
4
+ import { providers } from './index'
5
+
6
+ describe.each(cases)('%s', (protocol, url, secrets) => {
7
+ const Provider = providers[protocol]
8
+ const provider = new Provider(url, secrets)
9
+
10
+ let dir: string
11
+
12
+ beforeEach(() => {
13
+ dir = '/' + rnd()
14
+ })
15
+
16
+ it('should be', async () => {
17
+ expect(provider).toBeInstanceOf(Provider)
18
+ })
19
+
20
+ it('should return null if file not found', async () => {
21
+ const result = await provider.get(rnd())
22
+
23
+ expect(result).toBeNull()
24
+ })
25
+
26
+ it('should create entry', async () => {
27
+ const stream = await open('lenna.png')
28
+
29
+ await provider.put(dir, 'lenna.png', stream)
30
+
31
+ const readable = await provider.get(dir + '/lenna.png') as Readable
32
+ const output = await buffer(readable)
33
+ const lenna = await read('lenna.png')
34
+
35
+ expect(output.compare(lenna)).toBe(0)
36
+ })
37
+
38
+ it('should overwrite existing entry', async () => {
39
+ const stream0 = await open('lenna.png')
40
+ const stream1 = await open('albert.jpg')
41
+
42
+ await provider.put(dir, 'lenna.png', stream0)
43
+ await provider.put(dir, 'lenna.png', stream1)
44
+
45
+ const readable = await provider.get(dir + '/lenna.png') as Readable
46
+ const output = await buffer(readable)
47
+ const albert = await read('albert.jpg')
48
+
49
+ expect(output.compare(albert)).toBe(0)
50
+ })
51
+
52
+ it('should get by path', async () => {
53
+ const stream = await open('lenna.png')
54
+
55
+ await provider.put(dir, 'lenna.png', stream)
56
+
57
+ const result = await provider.get('/bar/lenna.png')
58
+
59
+ expect(result).toBeNull()
60
+ })
61
+
62
+ describe('danger', () => {
63
+ /*
64
+
65
+ WHEN MAKING CHANGES TO DELETION,
66
+ ALWAYS RUN TESTS IN STEP-BY-STEP DEBUGGING MODE
67
+
68
+ YOU MAY EVENTUALLY DELETE YOUR ENTIRE FILE SYSTEM
69
+
70
+ Sincerely yours, Murphy
71
+
72
+ */
73
+
74
+ it('should delete entry', async () => {
75
+ const stream = await open('lenna.png')
76
+
77
+ await provider.put(dir, 'lenna.png', stream)
78
+ await provider.delete(dir + '/lenna.png')
79
+
80
+ const result = await provider.get(dir + '/lenna.png')
81
+
82
+ expect(result).toBeNull()
83
+ })
84
+
85
+ it('should not throw if path does not exists', async () => {
86
+ await expect(provider.delete(dir + '/whatever')).resolves.not.toThrow()
87
+ })
88
+
89
+ it('should delete directory', async () => {
90
+ const stream = await open('lenna.png')
91
+
92
+ await provider.put(dir, 'lenna.png', stream)
93
+ await provider.delete(dir)
94
+
95
+ const result = await provider.get(dir + '/lenna.png')
96
+
97
+ expect(result).toBeNull()
98
+ })
99
+
100
+ it('should move an entry', async () => {
101
+ const stream = await open('lenna.png')
102
+ const dir2 = '/' + rnd()
103
+
104
+ await provider.put(dir, 'lenna.png', stream)
105
+ await provider.move(dir + '/lenna.png', dir2 + '/lenna2.png')
106
+
107
+ const result = await provider.get(dir2 + '/lenna2.png') as Readable
108
+
109
+ expect(result).not.toBeNull()
110
+
111
+ const nope = await provider.get(dir + '/lenna.png')
112
+
113
+ expect(nope).toBeNull()
114
+ })
115
+ })
116
+ })
@@ -0,0 +1,13 @@
1
+ import { type Provider } from '../Provider'
2
+ import { FileSystem } from './FileSystem'
3
+ import { Temporary } from './Temporary'
4
+ import { Test } from './Test'
5
+
6
+ export const providers: Record<string, ProviderClass> = {
7
+ 'file:': FileSystem,
8
+ 'tmp:': Temporary,
9
+ 'test:': Test
10
+ }
11
+
12
+ export type ProviderClass =
13
+ (new (url: URL, secrets: Record<string, string>) => Provider) & { SECRETS?: string[] }
@@ -0,0 +1,20 @@
1
+ # Developing a provider
2
+
3
+ 1. Add an implementaion of the [Provider](../Provider.ts) interface under this directory.
4
+ 2. Add an entry to the provider map in [`index.ts`](./index.ts).
5
+ 3. Add a suite to the `suites` object in [`util.ts`](../.test/util.ts).
6
+ 4. Run `$ npm test` in the [`storages` directory](../..).
7
+
8
+ Provider's constructor must have the following signature:
9
+
10
+ `constructor(url: URL)`
11
+
12
+ ## Secrets
13
+
14
+ Provider class may have static `SECRETS` property of type `string[]` that lists the names of the secrets that it
15
+ requires.
16
+ The secrets are passed to the constructor as the second argument.
17
+
18
+ `constructor(url: URL, secrets: Record<string, string>)`
19
+
20
+ See [`Test` provider](./Test.ts) for an example.
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./transpiled"
5
+ },
6
+ "include": [
7
+ "source"
8
+ ],
9
+ "exclude": [
10
+ "**/*.test.ts"
11
+ ]
12
+ }