@toa.io/extensions.origins 0.2.1-dev.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.
- package/package.json +31 -0
- package/src/.manifest/index.js +7 -0
- package/src/.manifest/normalize.js +17 -0
- package/src/.manifest/schema.yaml +13 -0
- package/src/.manifest/validate.js +13 -0
- package/src/aspect.js +95 -0
- package/src/factory.js +14 -0
- package/src/index.js +7 -0
- package/src/manifest.js +13 -0
- package/test/aspect.fixtures.js +34 -0
- package/test/aspect.test.js +144 -0
- package/test/factory.fixtures.js +5 -0
- package/test/factory.test.js +22 -0
- package/test/manifest.fixtures.js +11 -0
- package/test/manifest.test.js +58 -0
- package/types/aspect.ts +19 -0
- package/types/declaration.d.ts +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toa.io/extensions.origins",
|
|
3
|
+
"version": "0.2.1-dev.3",
|
|
4
|
+
"description": "Toa operations context HTTP client",
|
|
5
|
+
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
|
+
"homepage": "https://github.com/toa-io/toa#readme",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/toa-io/toa.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/toa-io/toa/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@toa.io/core": "*",
|
|
23
|
+
"@toa.io/generic": "*",
|
|
24
|
+
"@toa.io/schema": "*",
|
|
25
|
+
"@toa.io/yaml": "*",
|
|
26
|
+
"node-fetch": "2.6.7"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node-fetch": "2.6.2"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @returns {toa.extensions.origins.Declaration}
|
|
5
|
+
*/
|
|
6
|
+
const normalize = (declaration) => {
|
|
7
|
+
declaration = origins(declaration)
|
|
8
|
+
|
|
9
|
+
return declaration
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const origins = (declaration) => {
|
|
13
|
+
if (declaration.origins !== undefined) return declaration
|
|
14
|
+
else return { origins: { ...declaration } }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
exports.normalize = normalize
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
$schema: https://json-schema.org/draft/2019-09/schema
|
|
2
|
+
$id: https://schemas.toa.io/0.0.0/extensions/origins/declaration
|
|
3
|
+
|
|
4
|
+
type: object
|
|
5
|
+
properties:
|
|
6
|
+
origins:
|
|
7
|
+
type: object
|
|
8
|
+
minProperties: 1
|
|
9
|
+
patternProperties:
|
|
10
|
+
'.*':
|
|
11
|
+
type: string
|
|
12
|
+
pattern: ^https?:\/\/(?=.{1,254}(?::|$))(?:(?!\d|-)(?![a-z0-9\-]{1,62}-(?:\.|:|$))[a-z0-9\-]{1,63}\b(?!\.$)\.?)+(:\d+)?$
|
|
13
|
+
required: [origins]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
const { Schema } = require('@toa.io/schema')
|
|
6
|
+
const { load } = require('@toa.io/yaml')
|
|
7
|
+
|
|
8
|
+
const schema = load.sync(path.resolve(__dirname, 'schema.yaml'))
|
|
9
|
+
const validator = new Schema(schema)
|
|
10
|
+
|
|
11
|
+
const validate = (declaration) => validator.validate(declaration)
|
|
12
|
+
|
|
13
|
+
exports.validate = validate
|
package/src/aspect.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* @implements {toa.extensions.origins.Aspect}
|
|
10
|
+
*/
|
|
11
|
+
class Aspect extends Connector {
|
|
12
|
+
/** @readonly */
|
|
13
|
+
name = 'origins'
|
|
14
|
+
|
|
15
|
+
/** @type {toa.extensions.origins.Origins} */
|
|
16
|
+
#origins
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {toa.extensions.origins.Declaration | Object} declaration
|
|
20
|
+
*/
|
|
21
|
+
constructor (declaration) {
|
|
22
|
+
super()
|
|
23
|
+
|
|
24
|
+
this.#origins = declaration.origins
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async invoke (name, path, request, options) {
|
|
28
|
+
let origin = this.#origins[name]
|
|
29
|
+
|
|
30
|
+
if (origin === undefined) throw new Error(`Origin '${name}' is not defined`)
|
|
31
|
+
|
|
32
|
+
if (options?.substitutions !== undefined) origin = substitute(origin, options.substitutions)
|
|
33
|
+
|
|
34
|
+
const url = new URL(origin)
|
|
35
|
+
|
|
36
|
+
if (path !== undefined) append(url, path)
|
|
37
|
+
|
|
38
|
+
return this.#request(url.href, request, options?.retry)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} url
|
|
43
|
+
* @param {import('node-fetch').RequestInit} request
|
|
44
|
+
* @param {toa.generic.retry.Options} [options]
|
|
45
|
+
* @return {Promise<import('node-fetch').Response>}
|
|
46
|
+
*/
|
|
47
|
+
async #request (url, request, options) {
|
|
48
|
+
const call = () => fetch(url, request)
|
|
49
|
+
|
|
50
|
+
if (options === undefined) return call()
|
|
51
|
+
else return this.#retry(call, options)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Function} call
|
|
56
|
+
* @param {toa.generic.retry.Options} options
|
|
57
|
+
* @return {any}
|
|
58
|
+
*/
|
|
59
|
+
#retry (call, options) {
|
|
60
|
+
return retry(async (retry) => {
|
|
61
|
+
const response = await call()
|
|
62
|
+
|
|
63
|
+
if (Math.floor(response.status / 100) !== 2) return retry()
|
|
64
|
+
|
|
65
|
+
return response
|
|
66
|
+
}, options)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} origin
|
|
72
|
+
* @param {string[]} substitutions
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
const substitute = (origin, substitutions) => {
|
|
76
|
+
const replace = () => substitutions.shift()
|
|
77
|
+
|
|
78
|
+
return origin.replace(PLACEHOLDER, replace)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {URL} url
|
|
83
|
+
* @param {string} path
|
|
84
|
+
*/
|
|
85
|
+
const append = (url, path) => {
|
|
86
|
+
const [pathname, search] = path.split('?')
|
|
87
|
+
|
|
88
|
+
url.pathname = pathname
|
|
89
|
+
|
|
90
|
+
if (search !== undefined) url.search = search
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const PLACEHOLDER = /\*/g
|
|
94
|
+
|
|
95
|
+
exports.Aspect = Aspect
|
package/src/factory.js
ADDED
package/src/index.js
ADDED
package/src/manifest.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
|
|
5
|
+
const declaration = {
|
|
6
|
+
origins: {
|
|
7
|
+
foo: 'https://' + generate().toLowerCase(),
|
|
8
|
+
amazon: 'https://*.*.amazon.com:*'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const responses = []
|
|
13
|
+
|
|
14
|
+
const fetch = jest.fn(async () => {
|
|
15
|
+
const response = responses.shift()
|
|
16
|
+
|
|
17
|
+
if (response === undefined) throw new Error('Response is not mocked')
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
status: response.status,
|
|
21
|
+
json: () => response.body
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
fetch.respond = (status, body) => {
|
|
26
|
+
responses.push({ status, body })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fetch.reset = () => {
|
|
30
|
+
responses.length = 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
exports.declaration = declaration
|
|
34
|
+
exports.mock = { fetch }
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const clone = require('clone-deep')
|
|
4
|
+
const { generate } = require('randomstring')
|
|
5
|
+
const { random } = require('@toa.io/generic')
|
|
6
|
+
|
|
7
|
+
const { Connector } = require('@toa.io/core')
|
|
8
|
+
|
|
9
|
+
const fixtures = require('./aspect.fixtures')
|
|
10
|
+
const mock = fixtures.mock
|
|
11
|
+
|
|
12
|
+
jest.mock('node-fetch', () => mock.fetch)
|
|
13
|
+
|
|
14
|
+
const { Aspect } = require('../src/aspect')
|
|
15
|
+
|
|
16
|
+
/** @type {toa.extensions.origins.Aspect} */ let aspect
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks()
|
|
20
|
+
|
|
21
|
+
aspect = new Aspect(fixtures.declaration)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should be instance of core.Connector', () => {
|
|
25
|
+
expect(aspect).toBeInstanceOf(Connector)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should have name \'origins\'', () => {
|
|
29
|
+
expect(aspect.name).toStrictEqual('origins')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('invoke', () => {
|
|
33
|
+
const path = '/' + generate()
|
|
34
|
+
const headers = { [generate().toLowerCase()]: generate() }
|
|
35
|
+
const body = generate()
|
|
36
|
+
|
|
37
|
+
/** @type {import('node-fetch').RequestInit} */
|
|
38
|
+
const request = { method: 'PATCH', headers, body }
|
|
39
|
+
const name = 'foo'
|
|
40
|
+
const response = { [generate()]: generate() }
|
|
41
|
+
|
|
42
|
+
let call
|
|
43
|
+
let args
|
|
44
|
+
let result
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
jest.clearAllMocks()
|
|
48
|
+
|
|
49
|
+
mock.fetch.respond(200, response)
|
|
50
|
+
|
|
51
|
+
result = await aspect.invoke(name, path, clone(request))
|
|
52
|
+
call = mock.fetch.mock.calls[0]
|
|
53
|
+
args = call?.[1]
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should throw on unknown origin', async () => {
|
|
57
|
+
await expect(() => aspect.invoke('bar', path, request)).rejects.toThrow(/is not defined/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should not resolve absolute urls', async () => {
|
|
61
|
+
jest.clearAllMocks()
|
|
62
|
+
mock.fetch.respond(200, response)
|
|
63
|
+
|
|
64
|
+
const path = 'https://toa.io'
|
|
65
|
+
|
|
66
|
+
await aspect.invoke(name, path, clone(request))
|
|
67
|
+
|
|
68
|
+
expect(mock.fetch.mock.calls[0][0]).toStrictEqual(fixtures.declaration.origins.foo + '/' + path)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should substitute wildcards', async () => {
|
|
72
|
+
jest.clearAllMocks()
|
|
73
|
+
mock.fetch.respond(200, response)
|
|
74
|
+
|
|
75
|
+
const substitutions = ['foo', 'bar', 443]
|
|
76
|
+
|
|
77
|
+
await aspect.invoke('amazon', path, clone(request), { substitutions })
|
|
78
|
+
|
|
79
|
+
const url = mock.fetch.mock.calls[0][0]
|
|
80
|
+
|
|
81
|
+
expect(url).toStrictEqual('https://foo.bar.amazon.com' + path)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should not lose query string', async () => {
|
|
85
|
+
jest.clearAllMocks()
|
|
86
|
+
mock.fetch.respond(200, response)
|
|
87
|
+
|
|
88
|
+
const path = generate() + '?foo=' + generate()
|
|
89
|
+
|
|
90
|
+
await aspect.invoke(name, path)
|
|
91
|
+
|
|
92
|
+
const url = mock.fetch.mock.calls[0][0]
|
|
93
|
+
|
|
94
|
+
expect(url).toStrictEqual(fixtures.declaration.origins.foo + '/' + path)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should not throw if path is not defined', async () => {
|
|
98
|
+
jest.clearAllMocks()
|
|
99
|
+
mock.fetch.respond(200, response)
|
|
100
|
+
|
|
101
|
+
expect(() => aspect.invoke(name)).not.toThrow()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('fetch', () => {
|
|
105
|
+
it('should fetch', async () => {
|
|
106
|
+
expect(mock.fetch).toHaveBeenCalledTimes(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should pass url', () => {
|
|
110
|
+
expect(call[0]).toStrictEqual(fixtures.declaration.origins.foo + path)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should pass request', () => {
|
|
114
|
+
expect(args).toStrictEqual(request)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should return response', async () => {
|
|
118
|
+
const body = await result.json()
|
|
119
|
+
|
|
120
|
+
expect(body).toStrictEqual(response)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('retry', () => {
|
|
125
|
+
it('should retry', async () => {
|
|
126
|
+
jest.clearAllMocks()
|
|
127
|
+
|
|
128
|
+
const attempts = random(5) + 1
|
|
129
|
+
|
|
130
|
+
for (let i = 1; i < attempts; i++) mock.fetch.respond(500)
|
|
131
|
+
|
|
132
|
+
mock.fetch.respond(200, response)
|
|
133
|
+
|
|
134
|
+
/** @type {toa.extensions.origins.invocation.Options} */
|
|
135
|
+
const options = {
|
|
136
|
+
retry: { base: 0, retries: attempts }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await aspect.invoke(name, path, clone(request), options)
|
|
140
|
+
|
|
141
|
+
expect(mock.fetch).toHaveBeenCalledTimes(attempts)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
|
|
5
|
+
const { Aspect } = require('../src/aspect')
|
|
6
|
+
const { Locator } = require('@toa.io/core')
|
|
7
|
+
|
|
8
|
+
const fixtures = require('./factory.fixtures')
|
|
9
|
+
const { Factory } = require('../src')
|
|
10
|
+
|
|
11
|
+
/** @type {toa.core.extensions.Factory} */
|
|
12
|
+
let factory
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
factory = new Factory()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should create context extension', () => {
|
|
19
|
+
const extension = factory.aspect(new Locator(generate(), generate()), fixtures.declaration)
|
|
20
|
+
|
|
21
|
+
expect(extension).toBeInstanceOf(Aspect)
|
|
22
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const clone = require('clone-deep')
|
|
4
|
+
const { generate } = require('randomstring')
|
|
5
|
+
|
|
6
|
+
const fixtures = require('./manifest.fixtures')
|
|
7
|
+
const { manifest } = require('../src')
|
|
8
|
+
|
|
9
|
+
let declaration
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
declaration = clone(fixtures.declaration)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const exec = () => manifest(declaration)
|
|
16
|
+
const gen = () => 'https://host-' + generate().toLowerCase() + '.com'
|
|
17
|
+
|
|
18
|
+
it('should expand origins', () => {
|
|
19
|
+
const origins = {
|
|
20
|
+
[generate()]: gen(),
|
|
21
|
+
[generate()]: gen()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const value = clone(origins)
|
|
25
|
+
const result = manifest(value)
|
|
26
|
+
|
|
27
|
+
expect(result).toStrictEqual({ origins })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should not throw if valid', () => {
|
|
31
|
+
expect(exec).not.toThrow()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should require origins', () => {
|
|
35
|
+
delete declaration.origins
|
|
36
|
+
|
|
37
|
+
expect(exec).toThrow(/fewer than 1 items/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should require at least one origin', () => {
|
|
41
|
+
declaration.origins = {}
|
|
42
|
+
|
|
43
|
+
expect(exec).toThrow(/fewer than 1 items/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should require origin values as strings', () => {
|
|
47
|
+
declaration.origins.foo = ['bar', 'baz']
|
|
48
|
+
|
|
49
|
+
expect(exec).toThrow(/must be string/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('origin', () => {
|
|
53
|
+
it('should require origin values as web origins', () => {
|
|
54
|
+
declaration.origins.foo = 'http://origin/with/path'
|
|
55
|
+
|
|
56
|
+
expect(exec).toThrow(/must match pattern/)
|
|
57
|
+
})
|
|
58
|
+
})
|
package/types/aspect.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as fetch from 'node-fetch'
|
|
2
|
+
|
|
3
|
+
import * as _extensions from '@toa.io/core/types/extensions'
|
|
4
|
+
import * as _retry from '@toa.io/generic/types/retry'
|
|
5
|
+
|
|
6
|
+
declare namespace toa.extensions.origins {
|
|
7
|
+
|
|
8
|
+
namespace invocation {
|
|
9
|
+
type Options = {
|
|
10
|
+
substitutions?: string[]
|
|
11
|
+
retry?: _retry.Options
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Aspect extends _extensions.Aspect {
|
|
16
|
+
invoke(name: string, path: string, request: fetch.RequestInit, options?: invocation.Options): Promise<fetch.Response>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
}
|