@toa.io/extensions.origins 0.8.0-dev.0 → 0.8.0-dev.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.origins",
3
- "version": "0.8.0-dev.0",
3
+ "version": "0.8.0-dev.2",
4
4
  "description": "Toa Origins",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -19,15 +19,15 @@
19
19
  "test": "echo \"Error: run tests from root\" && exit 1"
20
20
  },
21
21
  "dependencies": {
22
- "@toa.io/core": "0.8.0-dev.0",
23
- "@toa.io/generic": "0.8.0-dev.0",
24
- "@toa.io/schemas": "0.8.0-dev.0",
25
- "@toa.io/yaml": "0.7.2-dev.1",
22
+ "@toa.io/core": "0.8.0-dev.2",
23
+ "@toa.io/generic": "0.8.0-dev.2",
24
+ "@toa.io/schemas": "0.8.0-dev.2",
25
+ "@toa.io/yaml": "0.7.2-dev.3",
26
26
  "comq": "0.6.0",
27
27
  "node-fetch": "2.6.7"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node-fetch": "2.6.2"
31
31
  },
32
- "gitHead": "5f1bfd167bb7919492d249f78bbcc6bd9fc645a4"
32
+ "gitHead": "85a6e6ce4f5d90af087507e3db65b94f32e9a818"
33
33
  }
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Toa Origins
2
2
 
3
- Origins extension enables external communications over supported protocols (`HTTP` and `AMQP`).
3
+ Enables external communications over supported protocols (HTTP and AMQP).
4
4
 
5
5
  ## TL;DR
6
6
 
@@ -10,15 +10,19 @@ name: dummy
10
10
  namespace: dummies
11
11
 
12
12
  origins:
13
- website: http://www.domain.com/docs/
14
- messages: amqps://amqp.amazon.com
13
+ docs: http://www.domain.com/docs/
14
+ amazon: amqps://amqp.amazon.com
15
15
  ```
16
16
 
17
17
  ```javascript
18
18
  // Node.js bridge
19
19
  async function transition (input, object, context) {
20
- await context.http.example.get() // GET http://www.domain.com/docs/example
21
- await context.amqp.emit('something_happened', { really: true })
20
+ // direct Aspect invocation
21
+ await context.aspects.http('docs', './example', { method: 'GET' })
22
+
23
+ // shortcuts
24
+ await context.http.docs.example.get() // GET http://www.domain.com/docs/example
25
+ await context.amqp.amazon.emit('something_happened', { really: true })
22
26
  }
23
27
  ```
24
28
 
@@ -26,25 +30,92 @@ async function transition (input, object, context) {
26
30
  # context.toa.yaml
27
31
  origins:
28
32
  dummies.dummy:
29
- messages: amqps://amqp.azure.com
30
- messages@staging: amqp://amqp.stage
33
+ amazon: amqps://amqp.azure.com
34
+ amazon@staging: amqp://amqp.stage
31
35
  ```
32
36
 
33
- ## Declaration
37
+ ## Manifest
38
+
39
+ `origins` manifest is an object conforming declaring origin names as keys an origin URLs as values.
40
+ Component's `origins` manifest can be overridden by the Context `origins` annotation.
41
+
42
+ ### Sharded Connections
43
+
44
+ Origin value may contain [shards](/libraries/generic/readme.md#shards) placeholders.
45
+
46
+ ### Environment Variables
34
47
 
35
- Origins extension declaration is a [Pointer](/libraries/pointer). Declarations can be overridden by
36
- the context annotations.
48
+ Origin value may contain environment variable placeholders.
49
+
50
+ ```yaml
51
+ # manifest.toa.yaml
52
+ origins:
53
+ foo@dev: stage${STAGE_NUMBER}.stages.com
54
+ ```
37
55
 
38
- ## HTTP
56
+ ## HTTP Aspect
39
57
 
40
58
  Uses [node-fetch](https://github.com/node-fetch/node-fetch) and returns its result.
41
59
 
42
- ## AMQP
60
+ Aspect invocation function
61
+ signature: `async (origin: string, rel: string, reuest: fetch.Request): fetch.Response`
62
+
63
+ - `origin`: name of the origin in the manifest
64
+ - `rel`: relative reference to a resource
65
+ - `request`: `Request` form `node-fetch`
66
+
67
+ ### Absolute URLs
68
+
69
+ Requests to arbitrary URLs can be implemented with overloaded direct Aspect invocation.
70
+
71
+ `async (url: string, request: fetch.Request): fetch.Response`
72
+
73
+ By default, requests to arbitrary URLs are not allowed and must be explicitly permitted by setting
74
+ permissions in the Origins Annotation.
75
+
76
+ The Rules object is stored in the `.http` property of the corresponding component. Each key in the
77
+ Rules object is a regular expression that URLs will be tested against, and each value is a
78
+ permission — either `true` to allow the URL or `false` to deny it. In cases where a URL matches
79
+ multiple rules, denial takes priority.
80
+
81
+ > The `null` key is a special case that represents "any URL".
82
+
83
+ #### Example
84
+
85
+ ```yaml
86
+ # context.toa.yaml
87
+ origins:
88
+ dummies.dummy:
89
+ .http:
90
+ /^https?:\/\/api.domain.com/: true
91
+ /^http:\/\/sandbox.domain.com/@staging: true # staging environment
92
+ /.*hackers.*/: false # deny rule
93
+ ~: true # allow any URL
94
+ ```
95
+
96
+ ```javascript
97
+ // Node.js bridge
98
+ async function transition (input, object, context) {
99
+ await context.aspects.http('https://api.domain.com/example', { method: 'POST' })
100
+ }
101
+ ```
102
+
103
+ #### `null` origins
104
+
105
+ To enable the extension for a component that uses arbitrary URLs without any specific origins to
106
+ declare, the Origins manifest should be set to `null`.
107
+
108
+ ```yaml
109
+ # manifest.toa.yaml
110
+ origins: ~
111
+ ```
112
+
113
+ ## AMQP Aspect
43
114
 
44
115
  Uses [ComQ](https://github.com/toa-io/comq), thus, provides interface of `comq.IO` restricted
45
116
  to `emit` and `request` methods.
46
117
 
47
- AMQP origins require credential secrets to be deployed. Secret's name must
118
+ AMQP origins can have credential secrets deployed. Secret's name must
48
119
  follow `toa-origins-{namespace}-{component}-{origin}` and it must have keys `username`
49
120
  and `password`.
50
121
 
@@ -131,12 +131,11 @@ describe('amqp', () => {
131
131
 
132
132
  expect(variables).toBeDefined()
133
133
 
134
- const envPrefix = `TOA_ORIGINS_${component.locator.uppercase}_`
135
- const secPrefix = `toa-origins-${component.locator.label}-`
134
+ const envPrefix = `TOA_ORIGINS_${component.locator.uppercase}_${up(origin)}_`
135
+ const secretName = `toa-origins-${component.locator.label}-${origin}`
136
136
 
137
137
  for (const property of ['username', 'password']) {
138
138
  const variableName = envPrefix + up(property)
139
- const secretName = secPrefix + property
140
139
  const variable = findVariable(variables, variableName)
141
140
 
142
141
  expect(variable).toBeDefined()
package/source/factory.js CHANGED
@@ -3,12 +3,14 @@
3
3
  const protocols = require('./protocols')
4
4
  const env = require('./env')
5
5
 
6
- /**
7
- * @implements {toa.core.extensions.Factory}
8
- */
9
6
  class Factory {
7
+ /**
8
+ * @param {toa.core.Locator} locator
9
+ * @param {toa.origins.Manifest} manifest
10
+ * @return {toa.core.extensions.Aspect[]}
11
+ */
10
12
  aspect (locator, manifest) {
11
- env.apply(locator, /** @type {toa.origins.Manifest} */ manifest)
13
+ env.apply(locator, manifest)
12
14
 
13
15
  return protocols.map((protocol) => this.#createAspect(protocol, manifest))
14
16
  }
@@ -21,13 +23,23 @@ class Factory {
21
23
  #createAspect (protocol, manifest) {
22
24
  const protocolManifest = {}
23
25
 
26
+ let properties
27
+
28
+ // let properties
29
+
24
30
  for (const [origin, reference] of Object.entries(manifest)) {
31
+ if (origin[0] === '.') {
32
+ if (origin.substring(1) === protocol.id) properties = reference
33
+
34
+ continue
35
+ }
36
+
25
37
  const url = new URL(reference)
26
38
 
27
39
  if (protocol.protocols.includes(url.protocol)) protocolManifest[origin] = reference
28
40
  }
29
41
 
30
- return protocol.create(protocolManifest)
42
+ return protocol.create(protocolManifest, properties)
31
43
  }
32
44
  }
33
45
 
@@ -13,7 +13,6 @@ const amqp = require('./protocols/amqp/aspect')
13
13
  const fixtures = require('./.test/factory.fixtures')
14
14
  const { Factory } = require('../')
15
15
 
16
- /** @type {toa.core.extensions.Factory} */
17
16
  let factory
18
17
 
19
18
  beforeEach(() => {
@@ -28,8 +27,8 @@ it('should create aspects', () => {
28
27
  const httpManifest = filterManifest(fixtures.manifest, 'http')
29
28
  const amqpManifest = filterManifest(fixtures.manifest, 'amqp')
30
29
 
31
- expect(http.create).toHaveBeenCalledWith(httpManifest)
32
- expect(amqp.create).toHaveBeenCalledWith(amqpManifest)
30
+ expect(http.create).toHaveBeenCalledWith(httpManifest, undefined)
31
+ expect(amqp.create).toHaveBeenCalledWith(amqpManifest, undefined)
33
32
  })
34
33
 
35
34
  describe('env', () => {
@@ -47,7 +46,7 @@ describe('env', () => {
47
46
 
48
47
  const expected = overwrite(httpManifest, override)
49
48
 
50
- expect(http.create).toHaveBeenCalledWith(expected)
49
+ expect(http.create.mock.calls[0][0]).toStrictEqual(expected)
51
50
  })
52
51
 
53
52
  describe('amqp', () => {
@@ -104,6 +103,25 @@ describe('env', () => {
104
103
  expect(url.password).toStrictEqual(password)
105
104
  })
106
105
  })
106
+
107
+ describe('http', () => {
108
+ it('should read properties', async () => {
109
+ const properties = {
110
+ '.http': {
111
+ [generate()]: generate()
112
+ }
113
+ }
114
+
115
+ const locator = new Locator(generate(), generate())
116
+ const json = JSON.stringify(properties)
117
+
118
+ process.env['TOA_ORIGINS_' + locator.uppercase] = btoa(json)
119
+
120
+ factory.aspect(locator, fixtures.manifest)
121
+
122
+ expect(http.create).toHaveBeenCalledWith(expect.anything(), properties['.http'])
123
+ })
124
+ })
107
125
  })
108
126
 
109
127
  /**
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { remap, echo, shards } = require('@toa.io/generic')
3
4
  const schemas = require('./schemas')
4
5
  const protocols = require('./protocols')
5
6
 
@@ -8,16 +9,30 @@ const protocols = require('./protocols')
8
9
  * @returns {toa.origins.Manifest}
9
10
  */
10
11
  function manifest (manifest) {
11
- schemas.manifest.validate(manifest)
12
+ if (manifest === null) return {}
13
+
14
+ manifest = remap(manifest, echo)
15
+ validate(manifest)
12
16
 
13
- for (const uri of Object.values(manifest)) {
14
- const protocol = new URL(uri).protocol
15
- const supported = protocols.find((provider) => provider.protocols.includes(protocol))
17
+ for (const url of Object.values(manifest)) {
18
+ const supported = protocols.find((provider) => supports(provider, url))
16
19
 
17
- if (supported === undefined) throw new Error(`'${protocol}' protocol is not supported`)
20
+ if (supported === undefined) throw new Error(`'${url}' protocol is not supported`)
18
21
  }
19
22
 
20
23
  return manifest
21
24
  }
22
25
 
26
+ /**
27
+ * @param {toa.origins.Manifest} manifest
28
+ */
29
+ function validate (manifest) {
30
+ manifest = remap(manifest, (value) => shards(value)[0])
31
+ schemas.manifest.validate(manifest)
32
+ }
33
+
34
+ function supports (provider, url) {
35
+ return provider.protocols.findIndex((protocol) => url.substring(0, protocol.length) === protocol) !== -1
36
+ }
37
+
23
38
  exports.manifest = manifest
@@ -44,8 +44,32 @@ it('should throw if protocol is not supported', async () => {
44
44
  expect(() => manifest(input)).toThrow('is not supported')
45
45
  })
46
46
 
47
+ it('should convert null to {}', async () => {
48
+ const output = manifest(null)
49
+
50
+ expect(output).toStrictEqual({})
51
+ })
52
+
47
53
  it.each(PROTOCOLS)('should support %s protocol', async (protocol) => {
48
54
  const input = { foo: protocol + '//' + generate() }
49
55
 
50
56
  expect(() => manifest(input)).not.toThrow()
51
57
  })
58
+
59
+ it('should handle placeholders', async () => {
60
+ const input = { foo: 'http://${FOO}' + generate() + ':${BAR}/' } // eslint-disable-line no-template-curly-in-string
61
+
62
+ expect(() => manifest(input)).not.toThrow()
63
+ })
64
+
65
+ it('should handle host shards', async () => {
66
+ const input = { foo: 'http://{0-3}' + generate() }
67
+
68
+ expect(() => manifest(input)).not.toThrow()
69
+ })
70
+
71
+ it('should handle port shards', async () => {
72
+ const input = { foo: 'http://' + generate() + ':888{0-9}' }
73
+
74
+ expect(() => manifest(input)).not.toThrow()
75
+ })
@@ -4,11 +4,13 @@ const { connect } = require('comq')
4
4
  const { Connector } = require('@toa.io/core')
5
5
  const { shards } = require('@toa.io/generic')
6
6
 
7
+ const { id } = require('./id')
8
+
7
9
  /**
8
10
  * @implements {toa.origins.amqp.Aspect}
9
11
  */
10
12
  class Aspect extends Connector {
11
- name = 'amqp'
13
+ name = id
12
14
  /** @type {toa.origins.Manifest} */
13
15
  #manifest
14
16
 
@@ -12,13 +12,19 @@ function deployment (instances) {
12
12
  const variables = {}
13
13
 
14
14
  for (const { locator, manifest } of instances) {
15
+ const secrets = []
16
+
15
17
  for (const [origin, reference] of Object.entries(manifest)) {
16
18
  const url = new URL(reference)
17
19
 
18
20
  if (protocols.includes(url.protocol)) {
19
- variables[locator.label] = secrets(locator, origin)
21
+ const originSecrets = createSecrets(locator, origin)
22
+
23
+ secrets.push(...originSecrets)
20
24
  }
21
25
  }
26
+
27
+ variables[locator.label] = secrets
22
28
  }
23
29
 
24
30
  return variables
@@ -29,10 +35,10 @@ function deployment (instances) {
29
35
  * @param {string} origin
30
36
  * @return {toa.deployment.dependency.Variable[]}
31
37
  */
32
- function secrets (locator, origin) {
38
+ function createSecrets (locator, origin) {
33
39
  const properties = ['username', 'password']
34
40
 
35
- return properties.map((property) => secret(locator, origin, property))
41
+ return properties.map((property) => createSecret(locator, origin, property))
36
42
  }
37
43
 
38
44
  /**
@@ -41,9 +47,9 @@ function secrets (locator, origin) {
41
47
  * @param {string} property
42
48
  * @return {toa.deployment.dependency.Variable}
43
49
  */
44
- function secret (locator, origin, property) {
45
- const variable = `TOA_ORIGINS_${locator.uppercase}_${up(property)}`
46
- const secret = `toa-origins-${locator.label}-${property}`
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}`
47
53
 
48
54
  return {
49
55
  name: variable,
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ exports.id = 'amqp'
@@ -1,9 +1,11 @@
1
1
  'use strict'
2
2
 
3
3
  const protocols = require('./protocols')
4
+ const { id } = require('./id')
4
5
  const { create } = require('./aspect')
5
6
  const { deployment } = require('./deployment')
6
7
 
7
8
  exports.protocols = protocols
9
+ exports.id = id
8
10
  exports.create = create
9
11
  exports.deployment = deployment
@@ -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
@@ -5,29 +5,48 @@ const fetch = require('node-fetch')
5
5
  const { Connector } = require('@toa.io/core')
6
6
  const { retry } = require('@toa.io/generic')
7
7
 
8
+ const { Permissions } = require('./.aspect/permissions')
9
+ const { id } = require('./id')
10
+ const protocols = require('./protocols')
11
+
8
12
  /**
9
13
  * @implements {toa.origins.http.Aspect}
10
14
  */
11
15
  class Aspect extends Connector {
12
16
  /** @readonly */
13
- name = 'http'
17
+ name = id
14
18
 
15
19
  /** @type {toa.origins.Manifest} */
16
20
  #origins
17
21
 
22
+ /** @type {toa.origins.http.Permissions} */
23
+ #permissions
24
+
18
25
  /**
19
26
  * @param {toa.origins.Manifest} manifest
27
+ * @param {toa.origins.http.Permissions} permissions
20
28
  */
21
- constructor (manifest) {
29
+ constructor (manifest, permissions) {
22
30
  super()
23
31
 
24
32
  this.#origins = manifest
33
+ this.#permissions = permissions
25
34
  }
26
35
 
27
36
  async invoke (name, path, request, options) {
28
37
  let origin = this.#origins[name]
29
38
 
30
- if (origin === undefined) throw new Error(`Origin '${name}' is not defined`)
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})`)
31
50
 
32
51
  if (options?.substitutions !== undefined) origin = substitute(origin, options.substitutions)
33
52
 
@@ -36,6 +55,17 @@ class Aspect extends Connector {
36
55
  return this.#request(url.href, request, options?.retry)
37
56
  }
38
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
+
39
69
  /**
40
70
  * @param {string} url
41
71
  * @param {import('node-fetch').RequestInit} request
@@ -70,19 +100,30 @@ class Aspect extends Connector {
70
100
  * @param {string[]} substitutions
71
101
  * @returns {string}
72
102
  */
73
- const substitute = (origin, substitutions) => {
103
+ function substitute (origin, substitutions) {
74
104
  const replace = () => substitutions.shift()
75
105
 
76
106
  return origin.replace(PLACEHOLDER, replace)
77
107
  }
78
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
+
79
117
  const PLACEHOLDER = /\*/g
80
118
 
81
119
  /**
82
120
  * @param {toa.origins.Manifest} manifest
121
+ * @param {toa.origins.http.Properties} [properties]
83
122
  */
84
- function create (manifest) {
85
- return new Aspect(manifest)
123
+ function create (manifest, properties) {
124
+ const permissions = new Permissions(properties)
125
+
126
+ return new Aspect(manifest, permissions)
86
127
  }
87
128
 
88
129
  exports.create = create
@@ -3,9 +3,11 @@
3
3
  const clone = require('clone-deep')
4
4
  const { generate } = require('randomstring')
5
5
  const { random } = require('@toa.io/generic')
6
-
7
6
  const { Connector } = require('@toa.io/core')
8
7
 
8
+ /** @type {string[]} */
9
+ const protocols = require('../http/protocols')
10
+
9
11
  const fixtures = require('./.test/aspect.fixtures')
10
12
  const mock = fixtures.mock
11
13
 
@@ -13,7 +15,7 @@ jest.mock('node-fetch', () => mock.fetch)
13
15
 
14
16
  const { create } = require('./aspect')
15
17
 
16
- /** @type {toa.extensions.origins.Aspect} */ let aspect
18
+ /** @type {toa.origins.http.Aspect} */ let aspect
17
19
 
18
20
  beforeEach(() => {
19
21
  jest.clearAllMocks()
@@ -45,7 +47,7 @@ describe('invoke', () => {
45
47
 
46
48
  beforeEach(async () => {
47
49
  jest.clearAllMocks()
48
-
50
+ mock.fetch.reset()
49
51
  mock.fetch.respond(200, response)
50
52
 
51
53
  result = await aspect.invoke(name, path, clone(request))
@@ -68,6 +70,15 @@ describe('invoke', () => {
68
70
  expect(mock.fetch.mock.calls[0][0]).toStrictEqual(fixtures.manifest.deep + path)
69
71
  })
70
72
 
73
+ it.each(protocols)('should throw on absolute URL (%s)',
74
+ async (protocol) => {
75
+ jest.clearAllMocks()
76
+ mock.fetch.respond(200, response)
77
+
78
+ await expect(aspect.invoke('deep', protocol + '//api.domain.com', clone(request)))
79
+ .rejects.toThrow('Absolute URLs are forbidden')
80
+ })
81
+
71
82
  it('should substitute wildcards', async () => {
72
83
  jest.clearAllMocks()
73
84
  mock.fetch.respond(200, response)
@@ -98,6 +109,7 @@ describe('invoke', () => {
98
109
  jest.clearAllMocks()
99
110
  mock.fetch.respond(200, response)
100
111
 
112
+ // noinspection JSCheckFunctionSignatures
101
113
  expect(() => aspect.invoke(name)).not.toThrow()
102
114
  })
103
115
 
@@ -125,7 +137,7 @@ describe('invoke', () => {
125
137
  it('should retry', async () => {
126
138
  jest.clearAllMocks()
127
139
 
128
- const attempts = random(5) + 1
140
+ const attempts = random(5) + 2
129
141
 
130
142
  for (let i = 1; i < attempts; i++) mock.fetch.respond(500)
131
143
 
@@ -142,3 +154,86 @@ describe('invoke', () => {
142
154
  })
143
155
  })
144
156
  })
157
+
158
+ describe.each(protocols)('absolute URL', (protocol) => {
159
+ const response = { [generate()]: generate() }
160
+
161
+ it('should request absolute URL', async () => {
162
+ mock.fetch.respond(200, response)
163
+
164
+ const properties = { null: true }
165
+ const url = protocol + '//' + generate()
166
+ const request = { method: 'POST' }
167
+
168
+ aspect = create(fixtures.manifest, properties)
169
+
170
+ await aspect.invoke(url, request)
171
+
172
+ expect(mock.fetch).toHaveBeenCalledWith(url, request)
173
+ })
174
+
175
+ it('should allow if TOA_DEV=1 and no properties', async () => {
176
+ const dev = process.env.TOA_DEV
177
+
178
+ process.env.TOA_DEV = '1'
179
+
180
+ mock.fetch.respond(200, response)
181
+
182
+ const url = protocol + '//' + generate()
183
+ const request = { method: 'POST' }
184
+
185
+ aspect = create(fixtures.manifest)
186
+
187
+ await aspect.invoke(url, request)
188
+
189
+ expect(mock.fetch).toHaveBeenCalledWith(url, request)
190
+
191
+ process.env.TOA_DEV = dev
192
+ })
193
+
194
+ it('should throw if URL not allowed', async () => {
195
+ mock.fetch.respond(200, response)
196
+
197
+ const properties = {}
198
+
199
+ aspect = create(fixtures.manifest, properties)
200
+
201
+ const url = protocol + '//' + generate()
202
+ const request = { method: 'POST' }
203
+
204
+ await expect(aspect.invoke(url, request)).rejects.toThrow('is not allowed')
205
+ })
206
+
207
+ it.each([
208
+ [String.raw`/^${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`],
209
+ [String.raw`/${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`]
210
+ ])('should allow requests %s', async (expression, url) => {
211
+ mock.fetch.respond(200, response)
212
+
213
+ const properties = { [expression]: true }
214
+
215
+ aspect = create(fixtures.manifest, properties)
216
+
217
+ await expect(aspect.invoke(url)).resolves.not.toThrow()
218
+ })
219
+
220
+ it.each([
221
+ [String.raw`/^${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`],
222
+ [String.raw`/${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`]
223
+ ])('should allow requests except %s', async (expression, url) => {
224
+ mock.fetch.respond(200, response)
225
+
226
+ const properties = { null: true, [expression]: false }
227
+
228
+ aspect = create(fixtures.manifest, properties)
229
+
230
+ await expect(aspect.invoke(url)).rejects.toThrow('is not allowed')
231
+ })
232
+
233
+ it.each([
234
+ ['starts', 'expression/'],
235
+ ['ends', '/expression']
236
+ ])('should throw if rule does not %s with /', async (_, expression) => {
237
+ expect(() => create(fixtures.manifest, { [expression]: true })).toThrow('is not a regular expression')
238
+ })
239
+ })
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ exports.id = 'http'
@@ -1,7 +1,9 @@
1
1
  'use strict'
2
2
 
3
3
  const protocols = require('./protocols')
4
+ const { id } = require('./id')
4
5
  const { create } = require('./aspect')
5
6
 
6
7
  exports.protocols = protocols
8
+ exports.id = id
7
9
  exports.create = create
@@ -0,0 +1,28 @@
1
+ import * as fetch from 'node-fetch'
2
+ import * as _extensions from '@toa.io/core/types/extensions'
3
+ import * as _retry from '@toa.io/generic/types/retry'
4
+
5
+ declare namespace toa.origins.http {
6
+
7
+ namespace invocation {
8
+ type Options = {
9
+ substitutions?: string[]
10
+ retry?: _retry.Options
11
+ }
12
+ }
13
+
14
+ type Properties = Record<string | null, boolean>
15
+
16
+ interface Permissions {
17
+ test(url: string): boolean
18
+ }
19
+
20
+ interface Aspect extends _extensions.Aspect {
21
+ invoke(origin: string, path: string, request?: fetch.RequestInit, options?: invocation.Options): Promise<fetch.Response>
22
+
23
+ invoke(url: string, request?: fetch.RequestInit): Promise<fetch.Response>
24
+ }
25
+
26
+ }
27
+
28
+ export type Aspect = toa.origins.http.Aspect
package/types/http.ts DELETED
@@ -1,18 +0,0 @@
1
- import * as fetch from 'node-fetch'
2
- import * as _extensions from '@toa.io/core/types/extensions'
3
- import * as _retry from '@toa.io/generic/types/retry'
4
-
5
- declare namespace toa.origins.http {
6
-
7
- namespace invocation {
8
- type Options = {
9
- substitutions?: string[]
10
- retry?: _retry.Options
11
- }
12
- }
13
-
14
- interface Aspect extends _extensions.Aspect {
15
- invoke(name: string, path: string, request: fetch.RequestInit, options?: invocation.Options): Promise<fetch.Response>
16
- }
17
-
18
- }
File without changes