@toa.io/extensions.storages 1.0.0-alpha.13 → 1.0.0-alpha.143

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.
Files changed (90) hide show
  1. package/package.json +13 -14
  2. package/readme.md +94 -49
  3. package/schemas/annotation.cos.yaml +1 -0
  4. package/schemas/cloudinary.cos.yaml +30 -0
  5. package/schemas/fs.cos.yaml +2 -0
  6. package/schemas/s3.cos.yaml +1 -0
  7. package/schemas/tmp.cos.yaml +0 -1
  8. package/source/Entry.ts +11 -11
  9. package/source/Factory.ts +16 -9
  10. package/source/Provider.ts +19 -19
  11. package/source/Scanner.ts +57 -24
  12. package/source/Secrets.ts +6 -0
  13. package/source/Storage.test.ts +103 -279
  14. package/source/Storage.ts +55 -204
  15. package/source/deployment.ts +48 -15
  16. package/source/errors.ts +3 -0
  17. package/source/index.ts +1 -1
  18. package/source/manifest.ts +7 -6
  19. package/source/providers/Cloudinary.ts +274 -0
  20. package/source/providers/Declaration.ts +2 -1
  21. package/source/providers/FileSystem.ts +79 -25
  22. package/source/providers/S3.ts +81 -80
  23. package/source/providers/Temporary.ts +2 -3
  24. package/source/providers/Test.ts +4 -4
  25. package/source/providers/index.test.ts +102 -76
  26. package/source/providers/index.ts +9 -4
  27. package/source/schemas.ts +1 -0
  28. package/source/test/.env.example +4 -0
  29. package/source/test/arny.jpg +0 -0
  30. package/source/test/lenna.48x48.jpeg +0 -0
  31. package/source/test/sample.avi +0 -0
  32. package/source/test/sample.svg +4 -0
  33. package/source/test/sample.wav +0 -0
  34. package/source/test/util.ts +54 -12
  35. package/transpiled/Entry.d.ts +12 -10
  36. package/transpiled/Factory.js +11 -5
  37. package/transpiled/Factory.js.map +1 -1
  38. package/transpiled/Provider.d.ts +15 -15
  39. package/transpiled/Provider.js +3 -3
  40. package/transpiled/Provider.js.map +1 -1
  41. package/transpiled/Scanner.d.ts +5 -2
  42. package/transpiled/Scanner.js +49 -22
  43. package/transpiled/Scanner.js.map +1 -1
  44. package/transpiled/Secrets.d.ts +5 -0
  45. package/transpiled/Secrets.js +3 -0
  46. package/transpiled/Secrets.js.map +1 -0
  47. package/transpiled/Storage.d.ts +10 -19
  48. package/transpiled/Storage.js +47 -153
  49. package/transpiled/Storage.js.map +1 -1
  50. package/transpiled/deployment.d.ts +1 -1
  51. package/transpiled/deployment.js +40 -15
  52. package/transpiled/deployment.js.map +1 -1
  53. package/transpiled/errors.d.ts +2 -0
  54. package/transpiled/errors.js +6 -0
  55. package/transpiled/errors.js.map +1 -0
  56. package/transpiled/index.d.ts +1 -1
  57. package/transpiled/manifest.js +5 -2
  58. package/transpiled/manifest.js.map +1 -1
  59. package/transpiled/providers/Cloudinary.d.ts +42 -0
  60. package/transpiled/providers/Cloudinary.js +189 -0
  61. package/transpiled/providers/Cloudinary.js.map +1 -0
  62. package/transpiled/providers/Declaration.d.ts +3 -2
  63. package/transpiled/providers/FileSystem.d.ts +15 -7
  64. package/transpiled/providers/FileSystem.js +64 -24
  65. package/transpiled/providers/FileSystem.js.map +1 -1
  66. package/transpiled/providers/S3.d.ts +14 -14
  67. package/transpiled/providers/S3.js +62 -60
  68. package/transpiled/providers/S3.js.map +1 -1
  69. package/transpiled/providers/Temporary.d.ts +1 -2
  70. package/transpiled/providers/Temporary.js +2 -2
  71. package/transpiled/providers/Temporary.js.map +1 -1
  72. package/transpiled/providers/Test.d.ts +3 -3
  73. package/transpiled/providers/Test.js +2 -2
  74. package/transpiled/providers/Test.js.map +1 -1
  75. package/transpiled/providers/index.d.ts +6 -2
  76. package/transpiled/providers/index.js +2 -2
  77. package/transpiled/providers/index.js.map +1 -1
  78. package/transpiled/schemas.d.ts +1 -0
  79. package/transpiled/schemas.js +2 -1
  80. package/transpiled/schemas.js.map +1 -1
  81. package/transpiled/test/util.d.ts +70 -5
  82. package/transpiled/test/util.js +43 -4
  83. package/transpiled/test/util.js.map +1 -1
  84. package/transpiled/tsconfig.tsbuildinfo +1 -1
  85. package/source/providers/FileSystem.test.ts +0 -5
  86. package/source/providers/Memory.ts +0 -41
  87. package/source/providers/S3.test.ts +0 -133
  88. package/transpiled/providers/Memory.d.ts +0 -13
  89. package/transpiled/providers/Memory.js +0 -60
  90. package/transpiled/providers/Memory.js.map +0 -1
package/source/Storage.ts CHANGED
@@ -1,12 +1,10 @@
1
- import { Readable } from 'node:stream'
2
- import { posix } from 'node:path'
3
- import { decode, encode } from 'msgpackr'
4
- import { buffer, newid } from '@toa.io/generic'
5
- import { Err } from 'error-value'
1
+ import { basename, join } from 'node:path'
2
+ import { randomUUID } from 'node:crypto'
6
3
  import { Scanner } from './Scanner'
4
+ import type { Readable } from 'node:stream'
5
+ import type { Attributes, Entry, Stream } from './Entry'
7
6
  import type { ScanOptions } from './Scanner'
8
7
  import type { Provider } from './Provider'
9
- import type { Entry } from './Entry'
10
8
 
11
9
  export class Storage {
12
10
  private readonly provider: Provider
@@ -17,229 +15,82 @@ export class Storage {
17
15
 
18
16
  public async put (path: string, stream: Readable, options?: Options): Maybe<Entry> {
19
17
  const scanner = new Scanner(options)
20
- const pipe = stream.pipe(scanner)
21
- const tempname = await this.transit(pipe)
22
-
23
- if (scanner.error !== null)
24
- return scanner.error
25
-
26
- const id = scanner.digest()
27
-
28
- await this.persist(tempname, id)
29
-
30
- return await this.create(path, id, scanner.size, scanner.type, options?.meta)
31
- }
32
-
33
- public async get (path: string): Maybe<Entry> {
34
- const metapath = posix.join(ENTRIES, path, META)
35
- const result = await this.provider.get(metapath)
36
-
37
- if (result === null) return ERR_NOT_FOUND
38
- else return decode(await buffer(result))
39
- }
40
-
41
- public async fetch (path: string): Maybe<Readable> {
42
- const { rel, id, variant } = this.parse(path)
43
-
44
- if (variant === null && rel !== '') {
45
- const entry = await this.get(path)
46
-
47
- if (entry instanceof Error)
48
- return entry
18
+ const pipe = stream.pipe(scanner).on('error', () => undefined)
19
+ const id = options?.id ?? randomUUID().replace(/-/g, '')
20
+ const location = this.locate(path, id)
21
+
22
+ /**
23
+ * Provider can return or throw an error.
24
+ * If thrown error is TYPE_MISMATCH from the Scanner, it should be returned.
25
+ */
26
+ const error: Error | undefined = await this.provider.put(location, pipe).catch((error: any) => {
27
+ if (error === scanner.error) return error
28
+ else throw error
29
+ })
30
+
31
+ if (error instanceof Error)
32
+ return error
33
+
34
+ const metadata: Entry = {
35
+ id,
36
+ size: scanner.size,
37
+ type: scanner.type,
38
+ checksum: scanner.digest(),
39
+ created: new Date().toISOString(),
40
+ attributes: options?.attributes ?? {}
49
41
  }
50
42
 
51
- const blob = variant === null
52
- ? posix.join(BLOBs, id)
53
- : posix.join(ENTRIES, rel, id, variant)
54
-
55
- const stream = await this.provider.get(blob)
56
-
57
- if (stream === null) return ERR_NOT_FOUND
58
- else return stream
59
- }
60
-
61
- public async delete (path: string): Maybe<void> {
62
- const entry = await this.get(path)
63
-
64
- if (entry instanceof Error)
65
- return entry
66
-
67
- await this.conceal(path)
68
- await this.provider.delete(posix.join(ENTRIES, path))
69
- }
43
+ if (options?.origin !== undefined)
44
+ metadata.attributes.origin = options.origin
70
45
 
71
- public async list (path: string): Promise<string[]> {
72
- const listfile = posix.join(ENTRIES, path, LIST)
73
- const stream = await this.provider.get(listfile)
46
+ await this.provider.commit(location, metadata)
74
47
 
75
- return stream === null ? [] : decode(await buffer(stream))
48
+ return metadata
76
49
  }
77
50
 
78
- public async permute (path: string, ids: string[]): Maybe<void> {
79
- const unique = new Set(ids)
80
- const dir = posix.join(ENTRIES, path)
81
- const list = await this.list(path)
82
-
83
- if (list.length !== ids.length || unique.size !== ids.length)
84
- return ERR_PERMUTATION_MISMATCH
85
-
86
- for (const id of ids)
87
- if (!list.includes(id))
88
- return ERR_PERMUTATION_MISMATCH
51
+ public async get (path: string): Maybe<Stream> {
52
+ const location = this.locate(path)
89
53
 
90
- await this.provider.put(dir, LIST, Readable.from(encode(ids)))
54
+ return await this.provider.get(location)
91
55
  }
92
56
 
93
- public async conceal (path: string): Maybe<void> {
94
- const { id, rel } = this.parse(path)
95
- const dir = posix.join(ENTRIES, rel)
96
- const list = await this.list(rel)
97
- const index = list.indexOf(id)
98
-
99
- if (index === -1)
100
- return ERR_NOT_FOUND
101
-
102
- list.splice(index, 1)
103
-
104
- await this.provider.put(dir, LIST, Readable.from(encode(list)))
105
- }
106
-
107
- public async reveal (path: string): Maybe<void> {
108
- const { id, rel } = this.parse(path)
109
-
110
- return await this.enroll(rel, id)
111
- }
112
-
113
- public async diversify (path: string, name: string, stream: Readable): Maybe<void> {
114
- const scanner = new Scanner()
115
- const pipe = stream.pipe(scanner)
116
-
117
- await this.provider.put(posix.join(ENTRIES, path), name, pipe)
118
-
119
- if (scanner.error !== null)
120
- return scanner.error
121
-
122
- const { size, type } = scanner
123
- const entry = await this.get(path)
124
-
125
- if (entry instanceof Error)
126
- return entry
57
+ public async head (path: string): Promise<Maybe<Entry>> {
58
+ const id = basename(path)
59
+ const location = this.locate(path)
60
+ const metadata = await this.provider.head(location)
127
61
 
128
- entry.variants = entry.variants.filter((variant) => variant.name !== name)
129
- entry.variants.push({ name, size, type })
62
+ if (metadata instanceof Error)
63
+ return metadata
130
64
 
131
- await this.save(path, entry)
132
- }
133
-
134
- public async annotate (path: string, key: string, value?: unknown): Maybe<void> {
135
- const entry = await this.get(path)
136
-
137
- if (entry instanceof Error)
138
- return entry
139
-
140
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
141
- if (value === undefined) delete entry.meta[key]
142
- else entry.meta[key] = value
143
-
144
- await this.save(path, entry)
145
- }
146
-
147
- private async transit (stream: Readable): Promise<string> {
148
- const tempname = newid()
149
-
150
- await this.provider.put(TEMP, tempname, stream)
151
-
152
- return tempname
153
- }
154
-
155
- private async persist (tempname: string, id: string): Promise<void> {
156
- const temp = posix.join(TEMP, tempname)
157
- const blob = posix.join(BLOBs, id)
158
-
159
- await this.provider.move(temp, blob)
160
- }
161
-
162
- // eslint-disable-next-line max-params
163
- private async create
164
- (path: string, id: string, size: number, type: string, meta: Meta = {}): Promise<Entry> {
165
- const entry: Entry = {
65
+ return {
166
66
  id,
167
- size,
168
- type,
169
- created: Date.now(),
170
- variants: [],
171
- meta
67
+ ...metadata
172
68
  }
173
-
174
- const metafile = posix.join(path, entry.id)
175
- const existing = await this.get(metafile)
176
-
177
- if (existing instanceof Error)
178
- await this.save(metafile, entry)
179
-
180
- await this.enroll(path, id, true)
181
-
182
- return entry
183
69
  }
184
70
 
185
- private async save (rel: string, entry: Entry): Promise<void> {
186
- const buffer = encode(entry)
187
- const stream = Readable.from(buffer)
71
+ public async delete (path: string): Maybe<void> {
72
+ const location = this.locate(path)
188
73
 
189
- await this.provider.put(posix.join(ENTRIES, rel), META, stream)
74
+ return this.provider.delete(location)
190
75
  }
191
76
 
192
- private async enroll (path: string, id: string, addition: boolean = false): Maybe<void> {
193
- const dir = posix.join(ENTRIES, path)
194
- const list = await this.list(path)
195
- const index = list.indexOf(id)
196
-
197
- if (index !== -1)
198
- if (addition) list.splice(index, 1)
199
- else return
200
- else if (!addition) {
201
- const entry = await this.get(posix.join(path, id))
202
-
203
- if (entry instanceof Error)
204
- return entry
205
- }
206
-
207
- list.push(id)
208
-
209
- await this.provider.put(dir, LIST, Readable.from(encode(list)))
77
+ public path (): string | null {
78
+ return this.provider.root ?? null
210
79
  }
211
80
 
212
- private parse (path: string): Path {
213
- const [last, ...segments] = path.split('/').reverse()
214
- const [id, ...rest] = last.split('.')
215
- const variant = rest.length > 0 ? rest.join('.') : null
216
- const rel = segments.reverse().join('/')
217
-
218
- return { rel, id, variant }
81
+ private locate (...rel: string[]): string {
82
+ return join(ENTRIES, ...rel)
219
83
  }
220
84
  }
221
85
 
222
- const ERR_NOT_FOUND = Err('NOT_FOUND')
223
- const ERR_PERMUTATION_MISMATCH = Err('PERMUTATION_MISMATCH')
224
-
225
- const TEMP = '/temp'
226
- const BLOBs = '/blobs'
227
- const ENTRIES = '/entries'
228
- const LIST = '.list'
229
- const META = '.meta'
230
-
231
- type Maybe<T> = Promise<T | Error>
232
-
233
- interface Path {
234
- rel: string
235
- id: string
236
- variant: string | null
237
- }
238
-
239
- type Meta = Record<string, string>
86
+ const ENTRIES = '/'
240
87
 
241
88
  interface Options extends ScanOptions {
242
- meta?: Meta
89
+ id?: string
90
+ origin?: string
91
+ attributes?: Attributes
243
92
  }
244
93
 
94
+ type Maybe<T> = Promise<T | Error>
95
+
245
96
  export type Storages = Record<string, Storage>
@@ -3,21 +3,25 @@ import { encode } from '@toa.io/generic'
3
3
  import { providers } from './providers'
4
4
  import { validateAnnotation } from './Annotation'
5
5
  import type { Annotation } from './Annotation'
6
- import type { Dependency, Variable } from '@toa.io/operations'
6
+ import type { Dependency, Variable, Mounts } from '@toa.io/operations'
7
7
  import type { context } from '@toa.io/norm'
8
8
 
9
- export const SERIALIZATION_PREFIX = 'TOA_STORAGES'
9
+ export const ENV_PREFIX = 'TOA_STORAGES'
10
10
 
11
11
  export function deployment (instances: Instance[], annotation: unknown): Dependency {
12
12
  validate(instances, annotation)
13
13
 
14
14
  const value = encode(annotation)
15
- const pointer: Variable = { name: SERIALIZATION_PREFIX, value }
15
+ const pointer: Variable = { name: ENV_PREFIX, value }
16
16
  const secrets = getSecrets(annotation)
17
+ const mounts = getMounts(instances, annotation)
17
18
 
18
- return {
19
- variables: { global: [pointer, ...secrets] }
20
- }
19
+ const dependency: Dependency = { variables: { global: [pointer, ...secrets] } }
20
+
21
+ if (mounts !== null)
22
+ dependency.mounts = mounts
23
+
24
+ return dependency
21
25
  }
22
26
 
23
27
  function validate (instances: Instance[], annotation: unknown): asserts annotation is Annotation {
@@ -40,18 +44,47 @@ function getSecrets (annotation: Annotation): Variable[] {
40
44
  for (const [name, declaration] of Object.entries(annotation)) {
41
45
  const Provider = providers[declaration.provider]
42
46
 
43
- for (const secret of Provider.SECRETS)
44
- secrets.push({
45
- name: `${SERIALIZATION_PREFIX}_${name}_${secret.name}`.toUpperCase(),
46
- secret: {
47
- name: `toa-storages-${name}`,
48
- key: secret.name,
49
- optional: secret.optional
50
- }
51
- })
47
+ if (Provider.SECRETS !== undefined)
48
+ // eslint-disable-next-line max-depth
49
+ for (const secret of Provider.SECRETS)
50
+ secrets.push({
51
+ name: `${ENV_PREFIX}_${name}_${secret.name}`.toUpperCase(),
52
+ secret: {
53
+ name: `toa-storages-${name}`,
54
+ key: secret.name,
55
+ optional: secret.optional
56
+ }
57
+ })
52
58
  }
53
59
 
54
60
  return secrets
55
61
  }
56
62
 
63
+ function getMounts (instances: Instance[], annotation: Annotation): Mounts | null {
64
+ let mounts: Mounts | null = null
65
+
66
+ for (const { locator, manifest } of instances)
67
+ for (const name of manifest) {
68
+ const declaration = annotation[name]
69
+
70
+ // eslint-disable-next-line max-depth
71
+ if (declaration.provider !== 'fs')
72
+ continue
73
+
74
+ // eslint-disable-next-line max-depth
75
+ if (declaration.claim !== undefined) {
76
+ mounts ??= {}
77
+ mounts[locator.label] ??= []
78
+
79
+ mounts[locator.label].push({
80
+ name,
81
+ path: declaration.path,
82
+ claim: declaration.claim
83
+ })
84
+ }
85
+ }
86
+
87
+ return mounts
88
+ }
89
+
57
90
  export type Instance = context.Dependency<string[]>
@@ -0,0 +1,3 @@
1
+ import { Err } from 'error-value'
2
+
3
+ export const ERR_NOT_FOUND = new Err('NOT_FOUND')
package/source/index.ts CHANGED
@@ -2,4 +2,4 @@ export { Factory } from './Factory'
2
2
  export { deployment } from './deployment'
3
3
  export { manifest } from './manifest'
4
4
 
5
- export type { Entry } from './Entry'
5
+ export type { Entry, Stream } from './Entry'
@@ -1,8 +1,9 @@
1
- import { match } from 'matchacho'
2
-
3
1
  export function manifest (manifest: string | string[] | null): string[] {
4
- return match(manifest,
5
- String, (name: string) => [name],
6
- Array, manifest,
7
- null, [])
2
+ if (manifest === null)
3
+ return []
4
+
5
+ if (typeof manifest === 'string')
6
+ return [manifest]
7
+
8
+ return manifest
8
9
  }
@@ -0,0 +1,274 @@
1
+ import { basename, dirname, join } from 'node:path'
2
+ import { Readable } from 'node:stream'
3
+ import { v2 as cloudinary } from 'cloudinary'
4
+ import { console } from 'openspan'
5
+ import { Provider } from '../Provider'
6
+ import { ERR_NOT_FOUND } from '../errors'
7
+ import type { Maybe } from '@toa.io/types'
8
+ import type { Metadata, Stream } from '../Entry'
9
+ import type { Secret, Secrets } from '../Secrets'
10
+ import type { ReadableStream } from 'node:stream/web'
11
+ import type {
12
+ ConfigOptions,
13
+ ImageTransformationOptions,
14
+ VideoTransformationOptions
15
+ } from 'cloudinary'
16
+
17
+ export type CloudinarySecrets = Secrets<'API_KEY' | 'API_SECRET'>
18
+
19
+ export class Cloudinary extends Provider<CloudinaryOptions> {
20
+ public static override readonly SECRETS: readonly Secret[] = [
21
+ { name: 'API_KEY' },
22
+ { name: 'API_SECRET' }
23
+ ]
24
+
25
+ private readonly type: StorageType
26
+ private readonly transformations: Transformation[] = []
27
+ private readonly config: ConfigOptions
28
+ private readonly prefix: string
29
+
30
+ public constructor (options: CloudinaryOptions, secrets?: CloudinarySecrets) {
31
+ super(options, secrets)
32
+
33
+ this.type = options.type
34
+
35
+ if (options.transformations !== undefined)
36
+ this.transformations = options.transformations.map((transformation) => {
37
+ const { extension, ...rest } = transformation
38
+
39
+ let expression = extension
40
+
41
+ if (!extension.startsWith('^'))
42
+ expression = '^' + extension
43
+
44
+ if (!extension.endsWith('$'))
45
+ expression = extension + '$'
46
+
47
+ return {
48
+ extension: new RegExp(expression),
49
+ ...rest
50
+ }
51
+ })
52
+
53
+ this.config = {
54
+ cloud_name: options.environment,
55
+ api_key: secrets!.API_KEY,
56
+ api_secret: secrets!.API_SECRET
57
+ }
58
+
59
+ this.prefix = options.prefix ?? '/'
60
+ }
61
+
62
+ public async get (path: string): Promise<Maybe<Stream>> {
63
+ const response = await this.fetch(path)
64
+
65
+ if (response instanceof Error)
66
+ return ERR_NOT_FOUND
67
+
68
+ const metadata = this.metadata(response)
69
+
70
+ return {
71
+ stream: Readable.fromWeb(response.body as ReadableStream),
72
+ ...metadata
73
+ }
74
+ }
75
+
76
+ public async head (path: string): Promise<Maybe<Metadata>> {
77
+ const response = await this.fetch(path, 'HEAD')
78
+
79
+ if (response instanceof Error)
80
+ return ERR_NOT_FOUND
81
+
82
+ return this.metadata(response)
83
+ }
84
+
85
+ public async put (path: string, stream: Readable): Promise<void> {
86
+ const id = basename(path)
87
+ const folder = join(this.prefix, dirname(path))
88
+
89
+ console.debug('Uploading to Cloudinary', { path })
90
+
91
+ await new Promise((resolve, reject) => {
92
+ stream.pipe(this.cloudinary().uploader.upload_stream({
93
+ public_id: id,
94
+ folder,
95
+ resource_type: 'auto',
96
+ overwrite: true,
97
+ invalidate: true
98
+ },
99
+ (error, result) => {
100
+ if (error !== undefined) reject(error)
101
+ else resolve(result)
102
+ }))
103
+
104
+ stream.on('error', reject)
105
+ })
106
+ }
107
+
108
+ public async commit (): Promise<void> {
109
+ // metadata is read-only
110
+ }
111
+
112
+ public async delete (path: string): Promise<void> {
113
+ const id = join(this.prefix, path)
114
+
115
+ console.debug('Deleting from Cloudinary', { path: id })
116
+
117
+ await this.cloudinary().uploader.destroy(id,
118
+ { resource_type: this.type, invalidate: true })
119
+ }
120
+
121
+ public async move (from: string, to: string): Promise<void | Error> {
122
+ const source = join(this.prefix, from)
123
+ const target = join(this.prefix, to)
124
+
125
+ try {
126
+ await this.cloudinary().uploader.rename(source, target,
127
+ { resource_type: this.type, overwrite: true })
128
+ } catch (error: any) {
129
+ if (error.http_code === 404)
130
+ return ERR_NOT_FOUND
131
+ else
132
+ throw error
133
+ }
134
+ }
135
+
136
+ private async fetch (path: string, method = 'GET'): Promise<Maybe<Response>> {
137
+ const url = this.url(path)
138
+
139
+ if (url === null)
140
+ return ERR_NOT_FOUND
141
+
142
+ console.debug('Fetching from Cloudinary', {
143
+ method,
144
+ path,
145
+ url
146
+ })
147
+
148
+ const response = await fetch(url, { method }).catch((e) => e)
149
+
150
+ if (response instanceof Error || response.ok === false) {
151
+ console.debug('Failed to fetch from Cloudinary', {
152
+ url,
153
+ message: response.message
154
+ })
155
+
156
+ return ERR_NOT_FOUND
157
+ }
158
+
159
+ return response
160
+ }
161
+
162
+ private url (path: string): string | null {
163
+ const [base, transformation] = this.toTransformation(path)
164
+
165
+ if (base === null)
166
+ return null
167
+
168
+ const id = join(this.prefix, base)
169
+
170
+ return this.cloudinary().url(id, {
171
+ resource_type: this.type,
172
+ transformation,
173
+ version: 2
174
+ })
175
+ }
176
+
177
+ private toTransformation (path: string): [string | null, TransformationOptions[] | undefined] {
178
+ if (this.transformations.length === 0)
179
+ return [path, undefined]
180
+
181
+ const [base, ...extensions] = path.split('.')
182
+ const transformations: TransformationOptions[] = []
183
+
184
+ let t = 0
185
+
186
+ for (const extension of extensions) {
187
+ let found = false
188
+
189
+ for (t; t < this.transformations.length && !found; t++) {
190
+ const { extension: regex, transformation: options, optional } = this.transformations[t]
191
+
192
+ const match = regex.exec(extension)
193
+
194
+ // eslint-disable-next-line max-depth
195
+ if (match === null)
196
+ if (optional === true)
197
+ continue
198
+ else
199
+ return [null, undefined]
200
+
201
+ const transformation = Object.fromEntries(Object.entries(options).map(([key, value]) => {
202
+ if (typeof value !== 'string')
203
+ return [key, value]
204
+
205
+ if (value.startsWith('<') && value.endsWith('>'))
206
+ value = match.groups![value.slice(1, -1)]
207
+
208
+ if (key === 'zoom' && value !== undefined)
209
+ value = Number.parseInt(value as string) / 100
210
+
211
+ if (key === 'fetch_format' && value === 'jpeg')
212
+ value = 'jpg'
213
+
214
+ return [key, value]
215
+ }))
216
+
217
+ transformations.push(transformation)
218
+ found = true
219
+ }
220
+
221
+ if (!found)
222
+ return [null, undefined]
223
+ }
224
+
225
+ for (t; t < this.transformations.length; t++)
226
+ if (this.transformations[t].optional !== true)
227
+ return [null, undefined]
228
+
229
+ return [base, transformations]
230
+ }
231
+
232
+ private metadata (response: Response): Metadata {
233
+ const size = response.headers.get('content-length') === null
234
+ ? 0
235
+ : Number.parseInt(response.headers.get('content-length')!)
236
+
237
+ const created = response.headers.get('date') ?? new Date().toISOString()
238
+ const etag = response.headers.get('etag')
239
+ const checksum = etag === null ? basename(response.url) : etag.slice(1, -1)
240
+
241
+ return {
242
+ type: response.headers.get('content-type')!,
243
+ size,
244
+ checksum,
245
+ created,
246
+ attributes: {}
247
+ }
248
+ }
249
+
250
+ private cloudinary (): typeof cloudinary {
251
+ cloudinary.config(this.config)
252
+
253
+ return cloudinary
254
+ }
255
+ }
256
+
257
+ export interface CloudinaryOptions {
258
+ environment: string
259
+ type: StorageType
260
+ prefix?: string
261
+ transformations?: TransformationDeclaration[]
262
+ }
263
+
264
+ type TransformationDeclaration = Omit<Transformation, 'extension'> & { extension: string }
265
+
266
+ interface Transformation {
267
+ extension: RegExp
268
+ transformation: object
269
+ optional?: boolean
270
+ }
271
+
272
+ type TransformationOptions = ImageTransformationOptions | VideoTransformationOptions
273
+
274
+ type StorageType = 'image' | 'video'