@toa.io/extensions.origins 0.20.0-dev.9 → 0.20.1-alpha.0
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 +17 -11
- package/readme.md +70 -67
- package/schemas/annotation.cos.yaml +3 -0
- package/schemas/manifest.cos.yaml +4 -0
- package/source/Factory.ts +88 -0
- package/source/annotation.test.ts +150 -0
- package/source/annotation.ts +83 -0
- package/source/extension.test.ts +161 -0
- package/source/extension.ts +60 -0
- package/source/index.ts +2 -0
- package/source/manifest.test.ts +30 -0
- package/source/manifest.ts +11 -0
- package/source/protocols/amqp/.test/aspect.fixtures.js +1 -1
- package/source/protocols/amqp/.test/mock.comq.js +2 -2
- package/source/protocols/amqp/aspect.js +17 -24
- package/source/protocols/amqp/deployment.js +8 -2
- package/source/protocols/http/.aspect/permissions.js +13 -10
- package/source/protocols/http/aspect.js +16 -37
- package/source/protocols/index.ts +16 -0
- package/tsconfig.json +12 -0
- package/source/.credentials.js +0 -14
- package/source/.deployment/index.js +0 -5
- package/source/.deployment/uris.js +0 -37
- package/source/.test/constants.js +0 -3
- package/source/.test/deployment.fixtures.js +0 -20
- package/source/.test/factory.fixtures.js +0 -13
- package/source/deployment.js +0 -41
- package/source/deployment.test.js +0 -185
- package/source/factory.js +0 -44
- package/source/factory.test.js +0 -140
- package/source/index.js +0 -9
- package/source/manifest.js +0 -41
- package/source/manifest.test.js +0 -82
- package/source/protocols/amqp/aspect.test.js +0 -119
- package/source/protocols/http/.aspect/permissions.test.js +0 -23
- package/source/protocols/http/aspect.test.js +0 -220
- package/source/protocols/index.js +0 -6
- package/source/schemas/annotations.cos.yaml +0 -1
- package/source/schemas/index.js +0 -8
- package/source/schemas/manifest.cos.yaml +0 -2
- package/types/amqp.d.ts +0 -9
- package/types/deployment.d.ts +0 -7
- package/types/http.d.ts +0 -28
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/extensions.origins",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1-alpha.0",
|
|
4
4
|
"description": "Toa Origins",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
7
|
-
"main": "source/index.js",
|
|
8
7
|
"repository": {
|
|
9
8
|
"type": "git",
|
|
10
9
|
"url": "git+https://github.com/toa-io/toa.git"
|
|
@@ -15,19 +14,26 @@
|
|
|
15
14
|
"publishConfig": {
|
|
16
15
|
"access": "public"
|
|
17
16
|
},
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
},
|
|
17
|
+
"main": "transpiled/index.js",
|
|
18
|
+
"types": "transpiled/index.d.ts",
|
|
21
19
|
"dependencies": {
|
|
22
|
-
"@toa.io/core": "0.
|
|
23
|
-
"@toa.io/generic": "0.20.
|
|
24
|
-
"@toa.io/
|
|
25
|
-
"@toa.io/
|
|
26
|
-
"comq": "0.
|
|
20
|
+
"@toa.io/core": "0.21.0-alpha.0",
|
|
21
|
+
"@toa.io/generic": "0.20.1-alpha.0",
|
|
22
|
+
"@toa.io/pointer": "0.20.1-alpha.0",
|
|
23
|
+
"@toa.io/schemas": "0.20.1-alpha.0",
|
|
24
|
+
"comq": "0.10.1",
|
|
25
|
+
"msgpackr": "1.9.5",
|
|
27
26
|
"node-fetch": "2.6.7"
|
|
28
27
|
},
|
|
29
28
|
"devDependencies": {
|
|
30
29
|
"@types/node-fetch": "2.6.2"
|
|
31
30
|
},
|
|
32
|
-
"
|
|
31
|
+
"scripts": {
|
|
32
|
+
"transpile": "tsc"
|
|
33
|
+
},
|
|
34
|
+
"jest": {
|
|
35
|
+
"preset": "ts-jest",
|
|
36
|
+
"testEnvironment": "node"
|
|
37
|
+
},
|
|
38
|
+
"gitHead": "ed28dc0d2823022fbb1188bb28b994bc827a4432"
|
|
33
39
|
}
|
package/readme.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Toa Origins
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
External communications with permissions over supported protocols (HTTP and AMQP).
|
|
4
4
|
|
|
5
5
|
## TL;DR
|
|
6
6
|
|
|
@@ -11,18 +11,17 @@ namespace: dummies
|
|
|
11
11
|
|
|
12
12
|
origins:
|
|
13
13
|
docs: http://www.domain.com/docs/
|
|
14
|
-
|
|
14
|
+
queues: ~
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```javascript
|
|
18
|
-
// Node.js bridge
|
|
19
18
|
async function transition (input, object, context) {
|
|
20
|
-
// direct Aspect invocation
|
|
21
|
-
await context.aspects.http('docs', './example', { method: 'GET' })
|
|
22
|
-
|
|
23
|
-
// shortcuts
|
|
24
19
|
await context.http.docs.example.get() // GET http://www.domain.com/docs/example
|
|
25
|
-
await context.amqp.
|
|
20
|
+
await context.amqp.queues.emit('something_happened', { really: true })
|
|
21
|
+
|
|
22
|
+
// direct Aspect invocation
|
|
23
|
+
await context.aspects.http('docs', 'example', { method: 'GET' })
|
|
24
|
+
await context.aspects.http('http://api.example.com', { method: 'GET' })
|
|
26
25
|
}
|
|
27
26
|
```
|
|
28
27
|
|
|
@@ -30,31 +29,9 @@ async function transition (input, object, context) {
|
|
|
30
29
|
# context.toa.yaml
|
|
31
30
|
origins:
|
|
32
31
|
dummies.dummy:
|
|
33
|
-
|
|
34
|
-
amazon@staging: amqp://amqp.stage
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Manifest
|
|
38
|
-
|
|
39
|
-
`origins` manifest is an object with 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
|
|
47
|
-
|
|
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
|
|
32
|
+
queues: amqps://amqp.azure.com
|
|
54
33
|
```
|
|
55
34
|
|
|
56
|
-
This is only usable in local development environment.
|
|
57
|
-
|
|
58
35
|
## HTTP Aspect
|
|
59
36
|
|
|
60
37
|
Uses [node-fetch](https://github.com/node-fetch/node-fetch) and returns its result.
|
|
@@ -63,46 +40,37 @@ Aspect invocation function
|
|
|
63
40
|
signature: `async (origin: string, rel: string, reuest: fetch.Request): fetch.Response`
|
|
64
41
|
|
|
65
42
|
- `origin`: name of the origin in the manifest
|
|
66
|
-
- `rel`:
|
|
43
|
+
- `rel`: reference to a resource relative to the origin's value
|
|
67
44
|
- `request`: `Request` form `node-fetch`
|
|
68
45
|
|
|
69
46
|
### Absolute URLs
|
|
70
47
|
|
|
71
|
-
Requests to arbitrary URLs can be implemented with overloaded direct Aspect invocation.
|
|
72
|
-
|
|
73
48
|
`async (url: string, request: fetch.Request): fetch.Response`
|
|
74
49
|
|
|
75
|
-
|
|
76
|
-
permissions in the Origins Annotation.
|
|
77
|
-
|
|
78
|
-
The Rules object is stored in the `.http` property of the corresponding component. Each key in the
|
|
79
|
-
Rules object is a regular expression that URLs will be tested against, and each value is a
|
|
80
|
-
permission — either `true` to allow the URL or `false` to deny it. In cases where a URL matches
|
|
81
|
-
multiple rules, denial takes priority.
|
|
82
|
-
|
|
83
|
-
> The `null` key is a special case that represents "any URL".
|
|
84
|
-
|
|
85
|
-
#### Example
|
|
50
|
+
Requests to arbitrary URLs can be implemented with overloaded direct Aspect invocation.
|
|
86
51
|
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
origins:
|
|
90
|
-
dummies.dummy:
|
|
91
|
-
.http:
|
|
92
|
-
/^https?:\/\/api.domain.com/: true
|
|
93
|
-
/^http:\/\/sandbox.domain.com/@staging: true # staging environment
|
|
94
|
-
/.*hackers.*/: false # deny rule
|
|
95
|
-
~: true # allow any URL
|
|
96
|
-
```
|
|
52
|
+
By default, requests to arbitrary URLs are not allowed and must be explicitly permitted by setting
|
|
53
|
+
permissions in the [Origins annotation](#context-annotation).
|
|
97
54
|
|
|
98
55
|
```javascript
|
|
99
|
-
// Node.js bridge
|
|
56
|
+
// Node.js bridge
|
|
100
57
|
async function transition (input, object, context) {
|
|
101
58
|
await context.aspects.http('https://api.domain.com/example', { method: 'POST' })
|
|
102
59
|
}
|
|
103
60
|
```
|
|
104
61
|
|
|
105
|
-
|
|
62
|
+
## AMQP Aspect
|
|
63
|
+
|
|
64
|
+
Uses [ComQ](https://github.com/toa-io/comq), thus, provides interface of `comq.IO` restricted
|
|
65
|
+
to `emit` and `request` methods.
|
|
66
|
+
|
|
67
|
+
## Manifest
|
|
68
|
+
|
|
69
|
+
`origins` manifest is a [Pointer](/libraries/pointer) with origin names as keys.
|
|
70
|
+
Its values can be overridden by the context [annotation](#context-annotation).
|
|
71
|
+
If the value is `null`, then it _must_ be overriden.
|
|
72
|
+
|
|
73
|
+
### `null` manifest
|
|
106
74
|
|
|
107
75
|
To enable the extension for a component that uses arbitrary URLs without any specific origins to
|
|
108
76
|
declare, the Origins manifest should be set to `null`.
|
|
@@ -112,19 +80,54 @@ declare, the Origins manifest should be set to `null`.
|
|
|
112
80
|
origins: ~
|
|
113
81
|
```
|
|
114
82
|
|
|
115
|
-
##
|
|
83
|
+
## Context annotation
|
|
116
84
|
|
|
117
|
-
|
|
118
|
-
|
|
85
|
+
The `origins` annotation is a set of Pointers defined for the corresponding components.
|
|
86
|
+
The values of each pointer override the values defined in the manifest.
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
# context.toa.yaml
|
|
90
|
+
origins:
|
|
91
|
+
dummies.dummy:
|
|
92
|
+
queues: amqps://amqp.azure.com
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### HTTP URL Permissions
|
|
96
|
+
|
|
97
|
+
The rules for arbitrary HTTP requests are stored in the `http` property of the corresponding
|
|
98
|
+
component as an object.
|
|
99
|
+
Each key in the rules object is a regular expression that URLs will be tested against, and each
|
|
100
|
+
value is a permission — either `true` to allow the URL or `false` to deny it.
|
|
101
|
+
In cases where a URL matches multiple rules, denial takes priority.
|
|
119
102
|
|
|
120
|
-
|
|
121
|
-
follow `origins-{namespace}-{component}-{origin}` and it must have keys `username`
|
|
122
|
-
and `password`.
|
|
103
|
+
> The `null` is a special key that represents any URL.
|
|
123
104
|
|
|
124
|
-
|
|
105
|
+
#### Example
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
# context.toa.yaml
|
|
109
|
+
origins:
|
|
110
|
+
dummies.dummy:
|
|
111
|
+
http:
|
|
112
|
+
/^https?:\/\/api.domain.com/: true
|
|
113
|
+
/^http:\/\/sandbox.domain.com/@staging: true # `staging` environment
|
|
114
|
+
/.*hackers.*/: false # deny
|
|
115
|
+
~: true # allow any URL
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Deployment
|
|
119
|
+
|
|
120
|
+
Each key of the annotation is deployed as a Pointer with ID
|
|
121
|
+
following `origins-{component}-{origin}` with dots replaced by dashes.
|
|
122
|
+
This means credentials for the declared origins must be deployed as follows:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
# context.toa.yaml
|
|
126
|
+
origins:
|
|
127
|
+
dummies.dummy:
|
|
128
|
+
queues: amqp://rmq.example.com
|
|
129
|
+
```
|
|
125
130
|
|
|
126
131
|
```shell
|
|
127
|
-
|
|
128
|
-
$ toa conceal origins-dummies-dummiy-messages username developer
|
|
129
|
-
$ toa conceal origins-dummies-dummiy-messages password secret
|
|
132
|
+
$ toa conceal origins-dummies-dummy-queues username=developer password=secret
|
|
130
133
|
```
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { decode } from 'msgpackr'
|
|
2
|
+
import { resolve, type URIMap } from '@toa.io/pointer'
|
|
3
|
+
import { memo } from '@toa.io/generic'
|
|
4
|
+
import { type Protocol, protocols } from './protocols'
|
|
5
|
+
import { ENV_PREFIX, ID_PREFIX, PROPERTIES_SUFFIX } from './extension'
|
|
6
|
+
import type { Properties } from './annotation'
|
|
7
|
+
import type { Locator, extensions } from '@toa.io/core'
|
|
8
|
+
import type { Manifest } from './manifest'
|
|
9
|
+
|
|
10
|
+
export class Factory implements extensions.Factory {
|
|
11
|
+
public aspect (locator: Locator, manifest: Manifest): extensions.Aspect[] {
|
|
12
|
+
return protocols.map((protocol) => this.createAspect(locator, manifest, protocol))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private createAspect (locator: Locator, manifest: Manifest, protocol: Protocol):
|
|
16
|
+
extensions.Aspect {
|
|
17
|
+
const resolver = this.resolver(locator, manifest, protocol)
|
|
18
|
+
|
|
19
|
+
return protocol.create(resolver)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private resolver (locator: Locator, manifest: Manifest, protocol: Protocol): Resolver {
|
|
23
|
+
return memo(async (): Promise<Configuration> => {
|
|
24
|
+
const uris = await this.getURIs(locator, manifest)
|
|
25
|
+
const allProperties = this.getProperties(locator)
|
|
26
|
+
|
|
27
|
+
const origins = this.filterOrigins(uris, protocol.protocols)
|
|
28
|
+
const properties = allProperties['.' + protocol.id as keyof Properties] ?? {}
|
|
29
|
+
|
|
30
|
+
return { origins, properties }
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async getURIs (locator: Locator, manifest: Manifest): Promise<URIMap> {
|
|
35
|
+
const map: URIMap = {}
|
|
36
|
+
|
|
37
|
+
if (manifest === null) return map
|
|
38
|
+
|
|
39
|
+
for (const [name, value] of Object.entries(manifest))
|
|
40
|
+
try {
|
|
41
|
+
map[name] = await this.readOrigin(locator, name)
|
|
42
|
+
} catch {
|
|
43
|
+
// eslint-disable-next-line max-depth
|
|
44
|
+
if (value === null) throw new Error(`Origin value ${name} is not defined`)
|
|
45
|
+
|
|
46
|
+
map[name] = [value]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return map
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private filterOrigins (uris: URIMap, protocols: string[]): URIMap {
|
|
53
|
+
const filtered: URIMap = {}
|
|
54
|
+
|
|
55
|
+
for (const [name, references] of Object.entries(uris)) {
|
|
56
|
+
const url = new URL(references[0])
|
|
57
|
+
|
|
58
|
+
if (protocols.includes(url.protocol))
|
|
59
|
+
filtered[name] = references
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return filtered
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async readOrigin (locator: Locator, name: string): Promise<string[]> {
|
|
66
|
+
const id = ID_PREFIX + locator.label
|
|
67
|
+
|
|
68
|
+
return await resolve(id, name)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private getProperties (locator: Locator): Properties {
|
|
72
|
+
const variable = ENV_PREFIX + locator.uppercase + PROPERTIES_SUFFIX
|
|
73
|
+
const value = process.env[variable]
|
|
74
|
+
|
|
75
|
+
if (value === undefined) return {}
|
|
76
|
+
|
|
77
|
+
const buffer = Buffer.from(value, 'base64')
|
|
78
|
+
|
|
79
|
+
return decode(buffer)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Configuration {
|
|
84
|
+
origins: URIMap
|
|
85
|
+
properties: Record<string, boolean>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type Resolver = () => Promise<Configuration>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { generate } from 'randomstring'
|
|
2
|
+
import { Locator } from '@toa.io/core'
|
|
3
|
+
import { split, normalize, type Component, type Annotation } from './annotation'
|
|
4
|
+
import { type Instance } from './extension'
|
|
5
|
+
|
|
6
|
+
let annotation: Annotation
|
|
7
|
+
let instances: Instance[]
|
|
8
|
+
let component: Component
|
|
9
|
+
|
|
10
|
+
const locator = new Locator(generate(), generate())
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
annotation = {}
|
|
14
|
+
instances = []
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('normalize', () => {
|
|
18
|
+
it('should throw if a key is not a component ID', async () => {
|
|
19
|
+
annotation = {
|
|
20
|
+
dummies: {
|
|
21
|
+
one: 'amqp://host{0,2}-' + generate()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
expect(run)
|
|
26
|
+
.toThrow('not expected')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should not throw if valid', async () => {
|
|
30
|
+
annotation = {
|
|
31
|
+
'dummies.dummy': {
|
|
32
|
+
'.http': {
|
|
33
|
+
'/.*hackers.*/': false
|
|
34
|
+
},
|
|
35
|
+
one: 'http://host{0,2}-' + generate(),
|
|
36
|
+
two: [
|
|
37
|
+
'http://hostA-' + generate(),
|
|
38
|
+
'https://hostB-' + generate()
|
|
39
|
+
],
|
|
40
|
+
three: [
|
|
41
|
+
'amqp://hostB-' + generate(),
|
|
42
|
+
'amqps://hostB-' + generate()
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(run).not.toThrow()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should merge defaults', async () => {
|
|
51
|
+
annotation = {}
|
|
52
|
+
|
|
53
|
+
const one = 'http://api.' + generate()
|
|
54
|
+
|
|
55
|
+
instances.push({
|
|
56
|
+
locator,
|
|
57
|
+
manifest: { one }
|
|
58
|
+
} as unknown as Instance)
|
|
59
|
+
|
|
60
|
+
run()
|
|
61
|
+
|
|
62
|
+
expect(annotation)
|
|
63
|
+
.toStrictEqual({
|
|
64
|
+
[locator.id]: { one }
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should thow if null origin is not defined', async () => {
|
|
69
|
+
annotation = {}
|
|
70
|
+
|
|
71
|
+
instances.push({
|
|
72
|
+
locator,
|
|
73
|
+
manifest: {
|
|
74
|
+
one: null
|
|
75
|
+
}
|
|
76
|
+
} as unknown as Instance)
|
|
77
|
+
|
|
78
|
+
expect(run)
|
|
79
|
+
.toThrow('is not defined for')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should not thow if null origin is defined', async () => {
|
|
83
|
+
annotation = {
|
|
84
|
+
[locator.id]: {
|
|
85
|
+
one: 'http://api.' + generate()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
instances.push({
|
|
90
|
+
locator,
|
|
91
|
+
manifest: {
|
|
92
|
+
one: null
|
|
93
|
+
}
|
|
94
|
+
} as unknown as Instance)
|
|
95
|
+
|
|
96
|
+
expect(run)
|
|
97
|
+
.not.toThrow()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should throw if protocol is not supported', async () => {
|
|
101
|
+
annotation = {
|
|
102
|
+
[locator.id]: {
|
|
103
|
+
one: 'mqtt://host-' + generate()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
expect(run)
|
|
108
|
+
.toThrow('is not supported')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should throw if origin url protocols are inconsistent', async () => {
|
|
112
|
+
annotation = {
|
|
113
|
+
[locator.id]: {
|
|
114
|
+
one: ['http://host-' + generate(), 'amqp://host-' + generate()]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(run)
|
|
119
|
+
.toThrow('inconsistent')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should not throw if annotation is undefined', async () => {
|
|
123
|
+
expect(run).not.toThrow()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('split', () => {
|
|
128
|
+
it('should split', async () => {
|
|
129
|
+
const one = 'amqp://host{0,2}-' + generate()
|
|
130
|
+
|
|
131
|
+
component = {
|
|
132
|
+
'.http': {
|
|
133
|
+
'/.*hackers.*/': false
|
|
134
|
+
},
|
|
135
|
+
one
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { origins, properties } = split(component)
|
|
139
|
+
|
|
140
|
+
expect(origins)
|
|
141
|
+
.toStrictEqual({ one })
|
|
142
|
+
|
|
143
|
+
expect(properties)
|
|
144
|
+
.toStrictEqual({ '.http': component['.http'] })
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
function run (): void {
|
|
149
|
+
normalize(instances, annotation)
|
|
150
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import * as schemas from '@toa.io/schemas'
|
|
3
|
+
import { type Protocol, protocols } from './protocols'
|
|
4
|
+
import type { Instance } from './extension'
|
|
5
|
+
|
|
6
|
+
export function normalize (instances: Instance[], annotation: Annotation): void {
|
|
7
|
+
schema.validate(annotation)
|
|
8
|
+
mergeDefaults(annotation, instances)
|
|
9
|
+
checkProtocols(annotation)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function split (component: Component): {
|
|
13
|
+
origins: Origins
|
|
14
|
+
properties: Properties
|
|
15
|
+
} {
|
|
16
|
+
const origins: Origins = {}
|
|
17
|
+
const properties: Properties = {}
|
|
18
|
+
|
|
19
|
+
for (const [key, value] of Object.entries(component))
|
|
20
|
+
if (key[0] === '.') properties[key as keyof Properties] = value
|
|
21
|
+
else origins[key] = value
|
|
22
|
+
|
|
23
|
+
return { origins, properties }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mergeDefaults (annotation: Annotation, instances: Instance[]): void {
|
|
27
|
+
for (const instance of instances) {
|
|
28
|
+
const component = annotation[instance.locator.id] as Origins ?? {}
|
|
29
|
+
|
|
30
|
+
annotation[instance.locator.id] = mergeInstance(component, instance)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeInstance (origins: Origins, instance: Instance): Component {
|
|
35
|
+
if (instance.manifest === null) return origins
|
|
36
|
+
|
|
37
|
+
for (const [origin, value] of Object.entries(instance.manifest))
|
|
38
|
+
if (origins[origin] === undefined)
|
|
39
|
+
if (value === null)
|
|
40
|
+
throw new Error(`Origin '${origin}' is not defined for '${instance.locator.id}'`)
|
|
41
|
+
else origins[origin] = value
|
|
42
|
+
|
|
43
|
+
return origins
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkProtocols (annotation: Annotation): void {
|
|
47
|
+
for (const component of Object.values(annotation)) {
|
|
48
|
+
const { origins } = split(component)
|
|
49
|
+
const urlSets = Object.values(origins)
|
|
50
|
+
|
|
51
|
+
for (const urls of urlSets)
|
|
52
|
+
checkURLs(Array.isArray(urls) ? urls : [urls])
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function checkURLs (urls: string[]): void {
|
|
57
|
+
let id: string | null = null
|
|
58
|
+
|
|
59
|
+
for (const url of urls) {
|
|
60
|
+
const protocol = resolveProtocol(url)
|
|
61
|
+
|
|
62
|
+
if (id === null) id = protocol.id
|
|
63
|
+
else if (id !== protocol.id)
|
|
64
|
+
throw new Error(`Origin has inconsistent protocols: ${id}, ${protocol.id}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveProtocol (reference: string): Protocol {
|
|
69
|
+
const url = new URL(reference)
|
|
70
|
+
|
|
71
|
+
for (const protocol of protocols)
|
|
72
|
+
if (protocol.protocols.includes(url.protocol)) return protocol
|
|
73
|
+
|
|
74
|
+
throw new Error(`Protocol '${url.protocol}' is not supported.`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const path = resolve(__dirname, '../schemas/annotation.cos.yaml')
|
|
78
|
+
const schema = schemas.schema(path)
|
|
79
|
+
|
|
80
|
+
export type Component = Origins | Properties
|
|
81
|
+
export type Annotation = Record<string, Component>
|
|
82
|
+
export type Properties = Partial<Record<'.http', Record<string, boolean>>>
|
|
83
|
+
export type Origins = Record<string, string | string[]>
|