@toa.io/extensions.exposition 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 ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@toa.io/extensions.exposition",
3
+ "version": "0.2.1-dev.3",
4
+ "description": "Toa Resources Exposition",
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
+ "devDependencies": {
22
+ "@types/express": "4.17.13"
23
+ },
24
+ "dependencies": {
25
+ "@toa.io/bindings.amqp": "*",
26
+ "@toa.io/console": "*",
27
+ "@toa.io/core": "*",
28
+ "@toa.io/generic": "*",
29
+ "@toa.io/schema": "*",
30
+ "@toa.io/yaml": "*",
31
+ "cors": "2.8.5",
32
+ "express": "4.18.1",
33
+ "path-to-regexp": "6.2.0"
34
+ },
35
+ "gitHead": "2be07592325b2e4dc823e81d882a4e50bf50de24"
36
+ }
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ const { normalize } = require('./normalize')
4
+ const { validate } = require('./validate')
5
+
6
+ exports.normalize = normalize
7
+ exports.validate = validate
@@ -0,0 +1,58 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @returns {toa.extensions.exposition.declarations.Node}
5
+ */
6
+ const normalize = (node, manifest) => {
7
+ if (node instanceof Array) node = { operations: node }
8
+
9
+ node.operations = operations(node.operations, manifest)
10
+ node.query = query(node.query)
11
+
12
+ for (const [key, value] of Object.entries(node)) {
13
+ if (key.substring(0, 1) === '/') node[key] = normalize(value, manifest)
14
+ }
15
+
16
+ return node
17
+ }
18
+
19
+ /**
20
+ * @returns {toa.extensions.exposition.declarations.Operation[] | undefined}
21
+ */
22
+ const operations = (operations, manifest) => {
23
+ if (operations === undefined) return
24
+
25
+ return operations.map((operation) => {
26
+ if (typeof operation === 'object') operation = operation.operation
27
+
28
+ if (manifest.operations[operation] === undefined) {
29
+ throw new Error(`Resource references undefined operation '${operation}'`)
30
+ }
31
+
32
+ const { type, scope, query } = manifest.operations[operation]
33
+ const normal = { operation, type, scope }
34
+
35
+ if (query !== undefined) normal.query = query
36
+
37
+ return normal
38
+ })
39
+ }
40
+
41
+ /**
42
+ * @returns {toa.extensions.exposition.declarations.Query | undefined}
43
+ */
44
+ const query = (query) => {
45
+ if (query === undefined) return
46
+
47
+ if (query.omit !== undefined && typeof query.omit !== 'object') {
48
+ query.omit = { value: query.omit, range: [] }
49
+ }
50
+
51
+ if (query.limit !== undefined && typeof query.limit !== 'object') {
52
+ query.limit = { value: query.limit, range: [] }
53
+ }
54
+
55
+ return query
56
+ }
57
+
58
+ exports.normalize = normalize
@@ -0,0 +1,69 @@
1
+ $schema: https://json-schema.org/draft/2019-09/schema
2
+ $id: https://schemas.toa.io/0.0.0/rtd
3
+
4
+ definitions:
5
+ resource:
6
+ patternProperties:
7
+ ^(\/([^\/#\?]+)?)+\/?$:
8
+ $ref: "#/definitions/resource"
9
+ properties:
10
+ query:
11
+ type: object
12
+ properties:
13
+ criteria:
14
+ nullable: true
15
+ type: string
16
+ minLength: 1
17
+ sort:
18
+ type: array
19
+ uniqueItems: true
20
+ minItems: 1
21
+ items:
22
+ type: string
23
+ projection:
24
+ type: array
25
+ uniqueItems: true
26
+ minItems: 1
27
+ items:
28
+ type: string
29
+ omit:
30
+ $ref: "#/definitions/range"
31
+ limit:
32
+ $ref: "#/definitions/range"
33
+ additionalProperties: false
34
+ operations:
35
+ type: array
36
+ uniqueItems: true
37
+ minItems: 1
38
+ items:
39
+ type: object
40
+ properties:
41
+ operation:
42
+ $ref: definitions#/definitions/token
43
+ type:
44
+ enum: [transition, observation, assignment]
45
+ scope:
46
+ type: string
47
+ enum: [object, objects, changeset, none]
48
+ query:
49
+ type: boolean
50
+ additionalProperties: false
51
+ additionalProperties: false
52
+ range:
53
+ type: object
54
+ properties:
55
+ value:
56
+ type: integer
57
+ minimum: 0
58
+ range:
59
+ type: array
60
+ uniqueItems: true
61
+ minItems: 0
62
+ items:
63
+ $ref: "#/definitions/range/properties/value"
64
+ additionalProperties: false
65
+ required: [range]
66
+
67
+ $ref: "#/definitions/resource"
68
+ not:
69
+ required: [operations] # root doesn't have path
@@ -0,0 +1,17 @@
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) => {
12
+ const error = validator.fit(declaration)
13
+
14
+ if (error) throw new Error(error.message)
15
+ }
16
+
17
+ exports.validate = validate
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ exports.PORT = 8000
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ const { PORT } = require('./constants')
4
+
5
+ /**
6
+ * @param {toa.norm.context.dependencies.Instance[]} components
7
+ * @param {toa.extensions.exposition.Annotations} annotations
8
+ * @type {toa.deployment.dependency.Constructor}
9
+ */
10
+ const deployment = (components, annotations) => {
11
+ const group = 'exposition'
12
+ const name = 'resources'
13
+ const version = require('../package.json').version
14
+ const port = PORT
15
+ const ingress = annotations
16
+
17
+ /** @type {toa.deployment.dependency.Service} */
18
+ const exposition = { group, name, version, port, ingress }
19
+
20
+ return { services: [exposition] }
21
+ }
22
+
23
+ exports.deployment = deployment
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ const { Connector } = require('@toa.io/core')
4
+ const { console } = require('@toa.io/console')
5
+
6
+ class Exposition extends Connector {
7
+ /** @type {toa.core.bindings.Broadcaster} */
8
+ #broadcast
9
+
10
+ /** @type {toa.extensions.exposition.remotes.Factory} */
11
+ #remote
12
+
13
+ /** @type {toa.extensions.exposition.exposition.Remotes} */
14
+ #remotes = {}
15
+
16
+ /**
17
+ * @param {toa.core.bindings.Broadcaster} broadcast
18
+ * @param {toa.extensions.exposition.remotes.Factory} connect
19
+ */
20
+ constructor (broadcast, connect) {
21
+ super()
22
+
23
+ this.#broadcast = broadcast
24
+ this.#remote = connect
25
+
26
+ this.depends(broadcast)
27
+ }
28
+
29
+ /** @override */
30
+ async connection () {
31
+ await this.#broadcast.receive('expose', this.#expose.bind(this))
32
+ this.#broadcast.send('ping', {}).then()
33
+
34
+ console.info(this.constructor.name + ' started')
35
+ }
36
+
37
+ /**
38
+ * @param {toa.extensions.exposition.declarations.Exposition} declaration
39
+ * @returns {Promise<void>}
40
+ */
41
+ async #expose (declaration) {
42
+ const { namespace, name, resources } = declaration
43
+ const key = namespace + '/' + name
44
+
45
+ if (this.#remotes[key] === undefined) this.#remotes[key] = this.#connect(namespace, name)
46
+
47
+ const remote = await this.#remotes[key]
48
+
49
+ remote.update(resources)
50
+ }
51
+
52
+ /**
53
+ * @param {string} namespace
54
+ * @param {string} name
55
+ * @returns {Promise<Remote>}
56
+ */
57
+ async #connect (namespace, name) {
58
+ const remote = await this.#remote(namespace, name)
59
+
60
+ await remote.connect()
61
+
62
+ this.depends(remote)
63
+
64
+ return remote
65
+ }
66
+ }
67
+
68
+ exports.Exposition = Exposition
package/src/factory.js ADDED
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ const { Locator } = require('@toa.io/core')
4
+ const { remap } = require('@toa.io/generic')
5
+
6
+ const { Tenant } = require('./tenant')
7
+ const { Exposition } = require('./exposition')
8
+ const { Server } = require('./server')
9
+ const { Remote } = require('./remote')
10
+ const { Tree } = require('./tree')
11
+ const { Query, constraints } = require('./query')
12
+
13
+ /**
14
+ * @implements {toa.core.extensions.Factory}
15
+ */
16
+ class Factory {
17
+ #boot
18
+
19
+ /** @type {toa.extensions.exposition.Server} */
20
+ #server
21
+
22
+ constructor (boot) {
23
+ this.#boot = boot
24
+ this.#server = new Server()
25
+ }
26
+
27
+ tenant (locator, declaration) {
28
+ const broadcast = this.#boot.bindings.broadcast(BINDING, GROUP, locator.id)
29
+
30
+ return new Tenant(broadcast, locator, declaration)
31
+ }
32
+
33
+ service (name) {
34
+ if (name === undefined || name === 'default' || name === 'resources') return this.#expose()
35
+ }
36
+
37
+ #expose () {
38
+ const broadcast = this.#boot.bindings.broadcast(BINDING, GROUP)
39
+ const connect = this.#connect.bind(this)
40
+ const exposition = new Exposition(broadcast, connect)
41
+
42
+ exposition.depends(this.#server)
43
+
44
+ return exposition
45
+ }
46
+
47
+ /**
48
+ * @param {string} namespace
49
+ * @param {string} name
50
+ * @returns {Promise<toa.extensions.exposition.Remote>}
51
+ */
52
+ async #connect (namespace, name) {
53
+ const locator = new Locator(name, namespace)
54
+ const remote = await this.#boot.remote(locator)
55
+ const query = this.#query.bind(this)
56
+ const tree = new Tree(query)
57
+
58
+ return new Remote(this.#server, remote, tree)
59
+ }
60
+
61
+ /**
62
+ * @param {toa.extensions.exposition.declarations.Node | any} node
63
+ * @returns {toa.extensions.exposition.Query}
64
+ */
65
+ #query (node) {
66
+ const query = Query.merge(node)
67
+ const properties = remap(query, (value, key) => new constraints[key](value))
68
+
69
+ return new Query(properties)
70
+ }
71
+ }
72
+
73
+ const BINDING = '@toa.io/bindings.amqp'
74
+ const GROUP = 'exposition'
75
+
76
+ exports.Factory = Factory
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ 'use strict'
2
+
3
+ const { Factory } = require('./factory')
4
+ const { deployment } = require('./deployment')
5
+ const { manifest } = require('./manifest')
6
+
7
+ exports.Factory = Factory
8
+ exports.deployment = deployment
9
+ exports.manifest = manifest
@@ -0,0 +1,12 @@
1
+ 'use strict'
2
+
3
+ const { normalize, validate } = require('./.manifest')
4
+
5
+ const manifest = (node, manifest) => {
6
+ normalize(node, manifest)
7
+ validate(node)
8
+
9
+ return node
10
+ }
11
+
12
+ exports.manifest = manifest
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+
3
+ const { exceptions: { RequestConflictException } } = require('@toa.io/core')
4
+
5
+ class Criteria {
6
+ #value
7
+ #open
8
+ #logic
9
+ #right
10
+
11
+ constructor (value) {
12
+ if (value === null) return
13
+
14
+ const last = value.slice(-1)
15
+
16
+ if (last === ',' || last === ';') {
17
+ this.#open = true
18
+ this.#right = true
19
+ this.#logic = last
20
+
21
+ value = value.substring(0, value.length - 1)
22
+ } else {
23
+ const first = value.substring(0, 1)
24
+
25
+ this.#open = first === ',' || first === ';'
26
+
27
+ if (this.#open === true) {
28
+ this.#right = false
29
+ this.#logic = first
30
+
31
+ value = value.substring(1)
32
+ }
33
+ }
34
+
35
+ this.#value = value
36
+ }
37
+
38
+ /** @hot */
39
+ parse (value, operation) {
40
+ if (operation.query === false) return value
41
+
42
+ if (value !== undefined) {
43
+ if (this.#open === true) {
44
+ if (this.#right) value = this.#value + this.#logic + value
45
+ else value = value + this.#logic + this.#value
46
+ } else {
47
+ throw new RequestConflictException('Query criteria is defined as closed')
48
+ }
49
+ } else value = this.#value
50
+
51
+ return value
52
+ }
53
+ }
54
+
55
+ exports.Criteria = Criteria
@@ -0,0 +1,35 @@
1
+ 'use strict'
2
+
3
+ const { exceptions: { RequestConflictException } } = require('@toa.io/core')
4
+
5
+ class Enum {
6
+ #value
7
+ #keys
8
+
9
+ constructor (value) {
10
+ this.#value = value
11
+
12
+ this.#keys = value.reduce((acc, key) => {
13
+ acc[key] = true
14
+ return acc
15
+ }, {})
16
+ }
17
+
18
+ /** @hot */
19
+ parse (value, operation) {
20
+ if (operation.type !== 'observation') return value
21
+
22
+ if (value === undefined) return this.#value
23
+ else if (value instanceof Array) {
24
+ const key = value.find((key) => !(key in this.#keys))
25
+
26
+ if (key !== undefined) {
27
+ throw new RequestConflictException(`Query projection must not contain '${key}'`)
28
+ }
29
+ }
30
+
31
+ return value
32
+ }
33
+ }
34
+
35
+ exports.Enum = Enum
@@ -0,0 +1,17 @@
1
+ 'use strict'
2
+
3
+ const { Query } = require('./query')
4
+ const { Range } = require('./range')
5
+ const { Criteria } = require('./criteria')
6
+ const { Enum } = require('./enum')
7
+ const { Sort } = require('./sort')
8
+
9
+ exports.Query = Query
10
+
11
+ exports.constraints = {
12
+ criteria: Criteria,
13
+ sort: Sort,
14
+ omit: Range,
15
+ limit: Range,
16
+ projection: Enum
17
+ }
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ const { merge } = require('@toa.io/generic')
4
+
5
+ /**
6
+ * @implements {toa.extensions.exposition.Query}
7
+ */
8
+ class Query {
9
+ #constraints
10
+
11
+ constructor (constraints) {
12
+ this.#constraints = Object.entries(constraints)
13
+ }
14
+
15
+ /** @hot */
16
+ parse (query, operation) {
17
+ for (const [key, constraint] of this.#constraints) {
18
+ const value = constraint.parse(query?.[key], operation)
19
+
20
+ if (value !== undefined) {
21
+ if (query === undefined) query = {}
22
+
23
+ query[key] = value
24
+ }
25
+ }
26
+
27
+ return query
28
+ }
29
+
30
+ /**
31
+ * @param {toa.extensions.exposition.declarations.Node} node
32
+ * @returns {toa.extensions.exposition.declarations.Query}
33
+ */
34
+ static merge (node) {
35
+ const query = {}
36
+ let current = node
37
+
38
+ do {
39
+ if (current.query !== undefined) merge(query, current.query, { ignore: true })
40
+
41
+ current = current.parent
42
+ } while (current !== undefined)
43
+
44
+ merge(query, DEFAULTS, { ignore: true })
45
+
46
+ return query
47
+ }
48
+ }
49
+
50
+ const DEFAULTS = {
51
+ omit: {
52
+ range: [0, 1000]
53
+ },
54
+ limit: {
55
+ value: 100,
56
+ range: [1, 100]
57
+ }
58
+ }
59
+
60
+ exports.Query = Query
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+
3
+ const { exceptions: { RequestConflictException } } = require('@toa.io/core')
4
+
5
+ class Range {
6
+ #value
7
+ #min
8
+ #max
9
+
10
+ constructor (constraint) {
11
+ this.#value = constraint.value
12
+
13
+ this.#min = constraint.range[0] === undefined ? this.#value : constraint.range[0]
14
+ this.#max = constraint.range[1] === undefined ? this.#value : constraint.range[1]
15
+ }
16
+
17
+ /** @hot */
18
+ parse (value, operation) {
19
+ if (operation.type !== 'observation' || operation.scope !== 'objects') return value
20
+ if (value === undefined) return this.#value
21
+
22
+ if (value > this.#max || value < this.#min) {
23
+ throw new RequestConflictException(`Query omit/limit value is out of range [${this.#min}, ${this.#max}]`)
24
+ }
25
+ }
26
+ }
27
+
28
+ exports.Range = Range
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+
3
+ class Sort {
4
+ #value
5
+
6
+ constructor (value) {
7
+ this.#value = value
8
+ }
9
+
10
+ /** @hot */
11
+ parse (value, operation) {
12
+ if (operation.query === false) return value
13
+
14
+ if (value === undefined) return this.#value
15
+ else return this.#value.concat(value)
16
+ }
17
+ }
18
+
19
+ exports.Sort = Sort
package/src/remote.js ADDED
@@ -0,0 +1,88 @@
1
+ 'use strict'
2
+
3
+ const { Connector, exceptions: { NotImplementedException } } = require('@toa.io/core')
4
+ const { console } = require('@toa.io/console')
5
+
6
+ const translate = require('./translate')
7
+
8
+ /**
9
+ * @implements {toa.extensions.exposition.Remote}
10
+ */
11
+ class Remote extends Connector {
12
+ /** @type {toa.core.Runtime} */
13
+ #remote
14
+
15
+ /** @type {toa.extensions.exposition.Tree} */
16
+ #tree
17
+
18
+ /**
19
+ * @param {toa.extensions.exposition.Server} server
20
+ * @param {toa.core.Runtime} remote
21
+ * @param {toa.extensions.exposition.Tree} tree
22
+ */
23
+ constructor (server, remote, tree) {
24
+ super()
25
+
26
+ const { namespace, name } = remote.locator
27
+ const route = '/' + (namespace === name ? namespace : namespace + '/' + name) + '*'
28
+
29
+ server.route(route, (req, res) => this.#reply(req, res))
30
+
31
+ this.#remote = remote
32
+ this.#tree = tree
33
+
34
+ this.depends(server)
35
+ this.depends(remote)
36
+ }
37
+
38
+ update (declaration) {
39
+ console.info(`Updating tree '${this.#remote.locator.id}'`)
40
+
41
+ this.#tree.update(declaration)
42
+ }
43
+
44
+ /**
45
+ * @hot
46
+ * @param {toa.extensions.exposition.http.Request} req
47
+ * @param {toa.extensions.exposition.http.Response} res
48
+ * @return {Promise<void>}
49
+ */
50
+ async #reply (req, res) {
51
+ const match = this.#tree.match(req.params[0])
52
+
53
+ if (match !== undefined) {
54
+ try {
55
+ const reply = await this.#call(req, match)
56
+
57
+ translate.response.ok(reply, res, req)
58
+ } catch (e) {
59
+ translate.response.exception(e, res)
60
+ }
61
+ } else {
62
+ translate.response.missed(res)
63
+ }
64
+
65
+ res.end()
66
+ }
67
+
68
+ /**
69
+ * @param {toa.extensions.exposition.http.Request} req
70
+ * @param {toa.extensions.exposition.tree.Match} match
71
+ * @return {Promise<toa.core.Reply>}
72
+ */
73
+ async #call (req, match) {
74
+ const method = req.method === 'HEAD' ? 'GET' : req.method
75
+ const operation = match.node.operations[method]
76
+
77
+ if (operation === undefined) throw new NotImplementedException()
78
+
79
+ const request = translate.request(req, match.params)
80
+ const query = match.node.query.parse(request.query, operation)
81
+
82
+ if (query !== undefined) request.query = query
83
+
84
+ return this.#remote.invoke(operation.operation, request)
85
+ }
86
+ }
87
+
88
+ exports.Remote = Remote