@toa.io/extensions.origins 0.7.2-dev.0 → 0.8.0-dev.1

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 (52) hide show
  1. package/package.json +9 -8
  2. package/readme.md +113 -0
  3. package/source/.deployment/index.js +5 -0
  4. package/source/.deployment/uris.js +35 -0
  5. package/source/.test/constants.js +3 -0
  6. package/source/.test/deployment.fixtures.js +20 -0
  7. package/source/.test/factory.fixtures.js +13 -0
  8. package/source/constants.js +3 -0
  9. package/source/deployment.js +28 -0
  10. package/source/deployment.test.js +158 -0
  11. package/source/env.js +50 -0
  12. package/source/factory.js +46 -0
  13. package/source/factory.test.js +140 -0
  14. package/{src → source}/index.js +2 -0
  15. package/source/manifest.js +25 -0
  16. package/source/manifest.test.js +57 -0
  17. package/source/protocols/amqp/.test/aspect.fixtures.js +15 -0
  18. package/source/protocols/amqp/.test/mock.comq.js +13 -0
  19. package/source/protocols/amqp/aspect.js +77 -0
  20. package/source/protocols/amqp/aspect.test.js +119 -0
  21. package/source/protocols/amqp/deployment.js +63 -0
  22. package/source/protocols/amqp/id.js +3 -0
  23. package/source/protocols/amqp/index.js +11 -0
  24. package/source/protocols/amqp/protocols.js +3 -0
  25. package/source/protocols/http/.aspect/permissions.js +65 -0
  26. package/{test → source/protocols/http/.test}/aspect.fixtures.js +6 -6
  27. package/source/protocols/http/aspect.js +129 -0
  28. package/source/protocols/http/aspect.test.js +239 -0
  29. package/source/protocols/http/id.js +3 -0
  30. package/source/protocols/http/index.js +9 -0
  31. package/source/protocols/http/protocols.js +3 -0
  32. package/source/protocols/index.js +6 -0
  33. package/source/schemas/annotations.cos.yaml +1 -0
  34. package/source/schemas/index.js +8 -0
  35. package/source/schemas/manifest.cos.yaml +1 -0
  36. package/types/amqp.d.ts +9 -0
  37. package/types/deployment.d.ts +7 -0
  38. package/types/http.d.ts +28 -0
  39. package/src/.manifest/index.js +0 -7
  40. package/src/.manifest/normalize.js +0 -17
  41. package/src/.manifest/schema.yaml +0 -13
  42. package/src/.manifest/validate.js +0 -13
  43. package/src/aspect.js +0 -95
  44. package/src/factory.js +0 -14
  45. package/src/manifest.js +0 -13
  46. package/test/aspect.test.js +0 -144
  47. package/test/factory.fixtures.js +0 -5
  48. package/test/factory.test.js +0 -22
  49. package/test/manifest.fixtures.js +0 -11
  50. package/test/manifest.test.js +0 -58
  51. package/types/aspect.ts +0 -19
  52. package/types/declaration.d.ts +0 -11
@@ -0,0 +1,57 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const { PROTOCOLS } = require('./.test/constants')
5
+
6
+ const { manifest } = require('../')
7
+
8
+ it('should be', async () => {
9
+ expect(manifest).toBeInstanceOf(Function)
10
+ })
11
+
12
+ it('should return manifest', async () => {
13
+ const input = { [generate()]: 'http://' + generate() }
14
+ const output = manifest(input)
15
+
16
+ expect(output).toStrictEqual(input)
17
+ })
18
+
19
+ it('should fail if not Record<string, string>', async () => {
20
+ const input = /** @type {toa.origins.Manifest} */ {
21
+ foo: {
22
+ bar: 'dev://null'
23
+ }
24
+ }
25
+
26
+ expect(() => manifest(input)).toThrow()
27
+ })
28
+
29
+ it('should pass if valid', async () => {
30
+ const input = { foo: 'amqp://' + generate() }
31
+
32
+ expect(() => manifest(input)).not.toThrow()
33
+ })
34
+
35
+ it('should fail if not uri', async () => {
36
+ const input = { [generate()]: generate() }
37
+
38
+ expect(() => manifest(input)).toThrow('must match format')
39
+ })
40
+
41
+ it('should throw if protocol is not supported', async () => {
42
+ const input = { foo: 'wat://' + generate() }
43
+
44
+ expect(() => manifest(input)).toThrow('is not supported')
45
+ })
46
+
47
+ it('should convert null to {}', async () => {
48
+ const output = manifest(null)
49
+
50
+ expect(output).toStrictEqual({})
51
+ })
52
+
53
+ it.each(PROTOCOLS)('should support %s protocol', async (protocol) => {
54
+ const input = { foo: protocol + '//' + generate() }
55
+
56
+ expect(() => manifest(input)).not.toThrow()
57
+ })
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const { random } = require('@toa.io/generic')
5
+
6
+ const manifest = {}
7
+ const originCount = random(5) + 2
8
+
9
+ for (let j = 0; j < originCount; j++) {
10
+ const origin = generate()
11
+
12
+ manifest[origin] = 'amqp://' + generate()
13
+ }
14
+
15
+ exports.manifest = manifest
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+
5
+ const connect = jest.fn(async () => ({
6
+ emit: jest.fn(async () => undefined),
7
+ request: jest.fn(async () => generate()),
8
+ reply: jest.fn(async () => undefined),
9
+ consume: jest.fn(async () => undefined),
10
+ close: jest.fn(async () => undefined)
11
+ }))
12
+
13
+ exports.connect = connect
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+
3
+ const { connect } = require('comq')
4
+ const { Connector } = require('@toa.io/core')
5
+ const { shards } = require('@toa.io/generic')
6
+
7
+ const { id } = require('./id')
8
+
9
+ /**
10
+ * @implements {toa.origins.amqp.Aspect}
11
+ */
12
+ class Aspect extends Connector {
13
+ name = id
14
+ /** @type {toa.origins.Manifest} */
15
+ #manifest
16
+
17
+ /** @type {Record<string, Partial<comq.IO>>} */
18
+ #origins = {}
19
+
20
+ /**
21
+ * @param {toa.origins.Manifest} manifest
22
+ */
23
+ constructor (manifest) {
24
+ super()
25
+
26
+ this.#manifest = manifest
27
+ }
28
+
29
+ async open () {
30
+ const promises = Object.entries(this.#manifest).map(this.#open)
31
+
32
+ await Promise.all(promises)
33
+ }
34
+
35
+ async close () {
36
+ const promises = Object.values(this.#origins).map(this.#close)
37
+
38
+ await Promise.all(promises)
39
+ }
40
+
41
+ async invoke (origin, method, ...args) {
42
+ return this.#origins[origin][method](...args)
43
+ }
44
+
45
+ #open = async ([origin, reference]) => {
46
+ const references = shards(reference)
47
+ const io = await connect(...references)
48
+
49
+ this.#origins[origin] = restrict(io)
50
+ }
51
+
52
+ #close = async (io) => {
53
+ await io.close()
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {comq.IO} io
59
+ * @return {Partial<comq.IO>}
60
+ */
61
+ function restrict (io) {
62
+ // noinspection JSUnresolvedReference
63
+ return {
64
+ request: (...args) => io.request(...args),
65
+ emit: (...args) => io.emit(...args),
66
+ close: () => io.close()
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {toa.origins.Manifest} manifest
72
+ */
73
+ function create (manifest) {
74
+ return new Aspect(manifest)
75
+ }
76
+
77
+ exports.create = create
@@ -0,0 +1,119 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const { Connector } = require('@toa.io/core')
5
+ const { sample } = require('@toa.io/generic')
6
+
7
+ const comq = require('./.test/mock.comq')
8
+ const mock = { comq }
9
+
10
+ jest.mock('comq', () => mock.comq)
11
+
12
+ const fixtures = require('./.test/aspect.fixtures')
13
+ const { create } = require('./aspect')
14
+
15
+ it('should be', async () => {
16
+ expect(create).toBeInstanceOf(Function)
17
+ })
18
+
19
+ /** @type {toa.origins.amqp.Aspect} */
20
+ let aspect
21
+
22
+ /** @type {toa.origins.Manifest} */
23
+ let manifest
24
+
25
+ beforeEach(() => {
26
+ jest.clearAllMocks()
27
+
28
+ manifest = fixtures.manifest
29
+ aspect = create(manifest)
30
+ })
31
+
32
+ it('should be instance of Connector', async () => {
33
+ expect(aspect).toBeInstanceOf(Connector)
34
+ })
35
+
36
+ it('should expose name', async () => {
37
+ expect(aspect.name).toStrictEqual('amqp')
38
+ })
39
+
40
+ it('should connect', async () => {
41
+ await aspect.open()
42
+
43
+ for (const reference of Object.values(manifest)) {
44
+ expect(comq.connect).toHaveBeenCalledWith(reference)
45
+ }
46
+ })
47
+
48
+ it('should connect to shards', async () => {
49
+ jest.clearAllMocks()
50
+
51
+ manifest = {
52
+ test: 'amqps://host{0-2}.domain.com'
53
+ }
54
+
55
+ aspect = create(manifest)
56
+
57
+ const shards = []
58
+
59
+ for (let i = 0; i < 3; i++) shards.push(`amqps://host${i}.domain.com`)
60
+
61
+ await aspect.open()
62
+
63
+ expect(comq.connect).toHaveBeenCalledWith(...shards)
64
+ })
65
+
66
+ it('should disconnect', async () => {
67
+ await aspect.open()
68
+ await aspect.close()
69
+
70
+ for (const reference of Object.values(manifest)) {
71
+ const index = comq.connect.mock.calls.findIndex((call) => call[0] === reference)
72
+ const io = await comq.connect.mock.results[index].value
73
+
74
+ expect(io.close).toHaveBeenCalled()
75
+ }
76
+ })
77
+
78
+ describe('invoke', () => {
79
+ /** @type {jest.MockedObject<comq.IO>} */
80
+ let io
81
+
82
+ /** @type {string} */
83
+ let origin
84
+
85
+ let args
86
+
87
+ beforeEach(async () => {
88
+ await aspect.open()
89
+
90
+ const origins = Object.keys(manifest)
91
+
92
+ origin = sample(origins)
93
+
94
+ const reference = manifest[origin]
95
+ const index = comq.connect.mock.calls.findIndex((call) => call[0] === reference)
96
+
97
+ io = await comq.connect.mock.results[index].value
98
+ args = [generate(), generate(), generate()]
99
+ })
100
+
101
+ it('should be', async () => {
102
+ expect(aspect.invoke).toBeInstanceOf(Function)
103
+ })
104
+
105
+ it.each(['emit', 'request'])('should %s', async (method) => {
106
+ await aspect.invoke(origin, method, ...args)
107
+
108
+ expect(io[method]).toHaveBeenCalledWith(...args)
109
+ })
110
+
111
+ it.each(['reply', 'consume', generate()])('should not expose %s',
112
+ async (method) => {
113
+ await expect(aspect.invoke(origin, method)).rejects.toThrow()
114
+ })
115
+
116
+ it('should throw if unknown origin', async () => {
117
+ await expect(aspect.invoke(generate(), 'emit')).rejects.toThrow()
118
+ })
119
+ })
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+
3
+ const { letters: { up } } = require('@toa.io/generic')
4
+ const protocols = require('./protocols')
5
+
6
+ /**
7
+ * @param {toa.norm.context.dependencies.Instance[]} instances
8
+ * @returns {toa.deployment.dependency.Variables}
9
+ */
10
+ function deployment (instances) {
11
+ /** @type {toa.deployment.dependency.Variables} */
12
+ const variables = {}
13
+
14
+ for (const { locator, manifest } of instances) {
15
+ const secrets = []
16
+
17
+ for (const [origin, reference] of Object.entries(manifest)) {
18
+ const url = new URL(reference)
19
+
20
+ if (protocols.includes(url.protocol)) {
21
+ const originSecrets = createSecrets(locator, origin)
22
+
23
+ secrets.push(...originSecrets)
24
+ }
25
+ }
26
+
27
+ variables[locator.label] = secrets
28
+ }
29
+
30
+ return variables
31
+ }
32
+
33
+ /**
34
+ * @param {toa.core.Locator} locator
35
+ * @param {string} origin
36
+ * @return {toa.deployment.dependency.Variable[]}
37
+ */
38
+ function createSecrets (locator, origin) {
39
+ const properties = ['username', 'password']
40
+
41
+ return properties.map((property) => createSecret(locator, origin, property))
42
+ }
43
+
44
+ /**
45
+ * @param {toa.core.Locator} locator
46
+ * @param {string} origin
47
+ * @param {string} property
48
+ * @return {toa.deployment.dependency.Variable}
49
+ */
50
+ function createSecret (locator, origin, property) {
51
+ const variable = `TOA_ORIGINS_${locator.uppercase}_${up(origin)}_${up(property)}`
52
+ const secret = `toa-origins-${locator.label}-${origin}`
53
+
54
+ return {
55
+ name: variable,
56
+ secret: {
57
+ name: secret,
58
+ key: property
59
+ }
60
+ }
61
+ }
62
+
63
+ exports.deployment = deployment
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ exports.id = 'amqp'
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+
3
+ const protocols = require('./protocols')
4
+ const { id } = require('./id')
5
+ const { create } = require('./aspect')
6
+ const { deployment } = require('./deployment')
7
+
8
+ exports.protocols = protocols
9
+ exports.id = id
10
+ exports.create = create
11
+ exports.deployment = deployment
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ module.exports = ['amqp:', 'amqps:']
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @implements {toa.origins.http.Permissions}
5
+ */
6
+ class Permissions {
7
+ #default = process.env.TOA_DEV === '1'
8
+
9
+ /** @type {RegExp[]} */
10
+ #allowances = []
11
+
12
+ /** @type {RegExp[]} */
13
+ #denials = []
14
+
15
+ /**
16
+ * @param {toa.origins.http.Properties} properties
17
+ */
18
+ constructor (properties) {
19
+ if (properties !== undefined) this.#parse(properties)
20
+ }
21
+
22
+ test (url) {
23
+ const denial = this.#denials.findIndex((regexp) => regexp.test(url))
24
+
25
+ if (denial !== -1) return false
26
+
27
+ const allowance = this.#allowances.findIndex((regexp) => regexp.test(url))
28
+
29
+ if (allowance !== -1) return true
30
+
31
+ return this.#default
32
+ }
33
+
34
+ #parse (properties) {
35
+ if ('null' in properties) {
36
+ const always = /** @type {RegExp} */ { test: () => true }
37
+
38
+ this.#addRule(always, properties.null)
39
+ delete properties.null
40
+ }
41
+
42
+ for (const [key, rule] of Object.entries(properties)) {
43
+ const match = key.match(EXPRESSION)
44
+
45
+ if (match === null) throw new Error(`'${key}' is not a regular expression`)
46
+
47
+ const regex = new RegExp(match.groups.expression)
48
+
49
+ this.#addRule(regex, rule)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {RegExp} regex
55
+ * @param {boolean} rule
56
+ */
57
+ #addRule (regex, rule) {
58
+ if (rule === true) this.#allowances.push(regex)
59
+ else this.#denials.push(regex)
60
+ }
61
+ }
62
+
63
+ const EXPRESSION = /^\/(?<expression>.+)\/$/
64
+
65
+ exports.Permissions = Permissions
@@ -2,11 +2,11 @@
2
2
 
3
3
  const { generate } = require('randomstring')
4
4
 
5
- const declaration = {
6
- origins: {
7
- foo: 'https://' + generate().toLowerCase(),
8
- amazon: 'https://*.*.amazon.com:*'
9
- }
5
+ /** @type {toa.origins.Manifest} */
6
+ const manifest = {
7
+ foo: 'https://' + generate().toLowerCase(),
8
+ amazon: 'https://*.*.amazon.com:*',
9
+ deep: 'http://www.domain.com/some/path/'
10
10
  }
11
11
 
12
12
  const responses = []
@@ -30,5 +30,5 @@ fetch.reset = () => {
30
30
  responses.length = 0
31
31
  }
32
32
 
33
- exports.declaration = declaration
33
+ exports.manifest = manifest
34
34
  exports.mock = { fetch }
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+
3
+ const fetch = require('node-fetch')
4
+
5
+ const { Connector } = require('@toa.io/core')
6
+ const { retry } = require('@toa.io/generic')
7
+
8
+ const { Permissions } = require('./.aspect/permissions')
9
+ const { id } = require('./id')
10
+ const protocols = require('./protocols')
11
+
12
+ /**
13
+ * @implements {toa.origins.http.Aspect}
14
+ */
15
+ class Aspect extends Connector {
16
+ /** @readonly */
17
+ name = id
18
+
19
+ /** @type {toa.origins.Manifest} */
20
+ #origins
21
+
22
+ /** @type {toa.origins.http.Permissions} */
23
+ #permissions
24
+
25
+ /**
26
+ * @param {toa.origins.Manifest} manifest
27
+ * @param {toa.origins.http.Permissions} permissions
28
+ */
29
+ constructor (manifest, permissions) {
30
+ super()
31
+
32
+ this.#origins = manifest
33
+ this.#permissions = permissions
34
+ }
35
+
36
+ async invoke (name, path, request, options) {
37
+ let origin = this.#origins[name]
38
+
39
+ if (origin === undefined) {
40
+ if (isAbsoluteURL(/** @type {string} */ name)) {
41
+ return this.#invokeURL(
42
+ /** @type {string} */ name,
43
+ /** @type {import('node-fetch').RequestInit} */ path
44
+ )
45
+ } else throw new Error(`Origin '${name}' is not defined`)
46
+ }
47
+
48
+ // absolute urls are forbidden when using origins
49
+ if (typeof path === 'string' && isAbsoluteURL(path)) throw new Error(`Absolute URLs are forbidden (${path})`)
50
+
51
+ if (options?.substitutions !== undefined) origin = substitute(origin, options.substitutions)
52
+
53
+ const url = path === undefined ? new URL(origin) : new URL(path, origin)
54
+
55
+ return this.#request(url.href, request, options?.retry)
56
+ }
57
+
58
+ /**
59
+ * @param {string} url
60
+ * @param {import('node-fetch').RequestInit} request
61
+ * @return {Promise<void>}
62
+ */
63
+ async #invokeURL (url, request) {
64
+ if (this.#permissions.test(url) === false) throw new Error(`URL '${url}' is not allowed`)
65
+
66
+ return this.#request(url, request)
67
+ }
68
+
69
+ /**
70
+ * @param {string} url
71
+ * @param {import('node-fetch').RequestInit} request
72
+ * @param {toa.generic.retry.Options} [options]
73
+ * @return {Promise<import('node-fetch').Response>}
74
+ */
75
+ async #request (url, request, options) {
76
+ const call = () => fetch(url, request)
77
+
78
+ if (options === undefined) return call()
79
+ else return this.#retry(call, options)
80
+ }
81
+
82
+ /**
83
+ * @param {Function} call
84
+ * @param {toa.generic.retry.Options} options
85
+ * @return {any}
86
+ */
87
+ #retry (call, options) {
88
+ return retry(async (retry) => {
89
+ const response = await call()
90
+
91
+ if (Math.floor(response.status / 100) !== 2) return retry()
92
+
93
+ return response
94
+ }, options)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * @param {string} origin
100
+ * @param {string[]} substitutions
101
+ * @returns {string}
102
+ */
103
+ function substitute (origin, substitutions) {
104
+ const replace = () => substitutions.shift()
105
+
106
+ return origin.replace(PLACEHOLDER, replace)
107
+ }
108
+
109
+ /**
110
+ * @param {string} path
111
+ * @returns {boolean}
112
+ */
113
+ function isAbsoluteURL (path) {
114
+ return protocols.findIndex((protocol) => path.indexOf(protocol) === 0) !== -1
115
+ }
116
+
117
+ const PLACEHOLDER = /\*/g
118
+
119
+ /**
120
+ * @param {toa.origins.Manifest} manifest
121
+ * @param {toa.origins.http.Properties} [properties]
122
+ */
123
+ function create (manifest, properties) {
124
+ const permissions = new Permissions(properties)
125
+
126
+ return new Aspect(manifest, permissions)
127
+ }
128
+
129
+ exports.create = create