@toa.io/extensions.origins 0.20.0-dev.9 → 0.20.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.
Files changed (43) hide show
  1. package/package.json +17 -11
  2. package/readme.md +70 -67
  3. package/schemas/annotation.cos.yaml +3 -0
  4. package/schemas/manifest.cos.yaml +4 -0
  5. package/source/Factory.ts +88 -0
  6. package/source/annotation.test.ts +150 -0
  7. package/source/annotation.ts +83 -0
  8. package/source/extension.test.ts +161 -0
  9. package/source/extension.ts +60 -0
  10. package/source/index.ts +2 -0
  11. package/source/manifest.test.ts +30 -0
  12. package/source/manifest.ts +11 -0
  13. package/source/protocols/amqp/.test/aspect.fixtures.js +1 -1
  14. package/source/protocols/amqp/.test/mock.comq.js +2 -2
  15. package/source/protocols/amqp/aspect.js +17 -24
  16. package/source/protocols/amqp/deployment.js +8 -2
  17. package/source/protocols/http/.aspect/permissions.js +13 -10
  18. package/source/protocols/http/aspect.js +16 -37
  19. package/source/protocols/index.ts +16 -0
  20. package/tsconfig.json +12 -0
  21. package/source/.credentials.js +0 -14
  22. package/source/.deployment/index.js +0 -5
  23. package/source/.deployment/uris.js +0 -37
  24. package/source/.test/constants.js +0 -3
  25. package/source/.test/deployment.fixtures.js +0 -20
  26. package/source/.test/factory.fixtures.js +0 -13
  27. package/source/deployment.js +0 -41
  28. package/source/deployment.test.js +0 -185
  29. package/source/factory.js +0 -44
  30. package/source/factory.test.js +0 -140
  31. package/source/index.js +0 -9
  32. package/source/manifest.js +0 -41
  33. package/source/manifest.test.js +0 -82
  34. package/source/protocols/amqp/aspect.test.js +0 -119
  35. package/source/protocols/http/.aspect/permissions.test.js +0 -23
  36. package/source/protocols/http/aspect.test.js +0 -220
  37. package/source/protocols/index.js +0 -6
  38. package/source/schemas/annotations.cos.yaml +0 -1
  39. package/source/schemas/index.js +0 -8
  40. package/source/schemas/manifest.cos.yaml +0 -2
  41. package/types/amqp.d.ts +0 -9
  42. package/types/deployment.d.ts +0 -7
  43. 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.0-dev.9",
3
+ "version": "0.20.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
- "scripts": {
19
- "test": "echo \"Error: run tests from root\" && exit 1"
20
- },
17
+ "main": "transpiled/index.js",
18
+ "types": "transpiled/index.d.ts",
21
19
  "dependencies": {
22
- "@toa.io/core": "0.20.0-dev.9",
23
- "@toa.io/generic": "0.20.0-dev.9",
24
- "@toa.io/schemas": "0.20.0-dev.9",
25
- "@toa.io/yaml": "0.20.0-dev.9",
26
- "comq": "0.7.0",
20
+ "@toa.io/core": "0.20.0",
21
+ "@toa.io/generic": "0.20.0",
22
+ "@toa.io/pointer": "0.20.0",
23
+ "@toa.io/schemas": "0.20.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
- "gitHead": "4e503ffe4fe6dfab1165e795832d3d4fba711582"
31
+ "scripts": {
32
+ "transpile": "tsc"
33
+ },
34
+ "jest": {
35
+ "preset": "ts-jest",
36
+ "testEnvironment": "node"
37
+ },
38
+ "gitHead": "28fc4b45c224c3683acaaf0e4abd1eb04e07b408"
33
39
  }
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Toa Origins
2
2
 
3
- Enables external communications over supported protocols (HTTP and AMQP).
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
- amazon: amqps://amqp.amazon.com
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.amazon.emit('something_happened', { really: true })
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
- amazon: amqps://amqp.azure.com
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`: relative reference to a resource
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
- By default, requests to arbitrary URLs are not allowed and must be explicitly permitted by setting
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
- ```yaml
88
- # context.toa.yaml
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
- #### `null` manifest
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
- ## AMQP Aspect
83
+ ## Context annotation
116
84
 
117
- Uses [ComQ](https://github.com/toa-io/comq), thus, provides interface of `comq.IO` restricted
118
- to `emit` and `request` methods.
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
- AMQP origins can have credential secrets deployed. Secret's name must
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
- ### Example
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
- # deploy credentials to the current kubectl context
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,3 @@
1
+ /^[a-zA-Z0-9]{1,32}\.[a-zA-Z0-9]{1,32}$/:
2
+ /^\.http$/: <boolean>
3
+ /^[a-zA-Z0-9]{1,32}$/+: string
@@ -0,0 +1,4 @@
1
+ /^[a-zA-Z0-9]{1,32}$/:
2
+ - type: string
3
+ format: uri
4
+ - type: 'null'
@@ -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[]>