@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/src/server.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ const express = require('express')
4
+ const cors = require('cors')
5
+
6
+ const { Connector } = require('@toa.io/core')
7
+ const { console } = require('@toa.io/console')
8
+
9
+ const { PORT } = require('./constants')
10
+
11
+ // noinspection JSClosureCompilerSyntax
12
+ /**
13
+ * @implements {toa.extensions.exposition.Server}
14
+ */
15
+ class Server extends Connector {
16
+ /** @type {import('express').Application} */
17
+ #app
18
+ /** @type {import('http').Server} */
19
+ #server
20
+
21
+ constructor () {
22
+ super()
23
+
24
+ // TODO: remove express
25
+ this.#app = express()
26
+ this.#app.disable('x-powered-by')
27
+ this.#app.enable('case sensitive routing')
28
+ this.#app.enable('strict routing')
29
+ this.#app.disable('etag')
30
+ this.#app.use(express.json())
31
+ this.#app.use(cors({ allowedHeaders: ['content-type'] }))
32
+
33
+ this.#app.use((req, res, next) => {
34
+ req.safe = req.method in SAFE
35
+
36
+ if (req.method in METHODS) next()
37
+ else res.status(501).end()
38
+ })
39
+ }
40
+
41
+ route (route, callback) {
42
+ this.#app.use(route, callback)
43
+ }
44
+
45
+ async connection () {
46
+ console.info(`Starting HTTP server at :${PORT} ...`)
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const error = () => reject(new Error(`Error starting HTTP server at :${PORT}`))
50
+ // noinspection JSCheckFunctionSignatures
51
+ this.#server = this.#app.listen(PORT, () => {
52
+ console.info(`HTTP server at :${PORT} started`)
53
+
54
+ this.#server.off('error', error)
55
+ resolve()
56
+ })
57
+
58
+ this.#server.on('error', error)
59
+ })
60
+ }
61
+
62
+ async disconnection () {
63
+ console.info(`Stopping HTTP server at :${PORT} ...`)
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const error = () => reject(new Error(`Error stopping HTTP server at :${PORT}`))
67
+
68
+ this.#server.close(() => {
69
+ console.info(`HTTP server at :${PORT} stopped`)
70
+
71
+ this.#server.off('error', error)
72
+ resolve()
73
+ })
74
+
75
+ this.#server.on('error', error)
76
+ })
77
+ }
78
+ }
79
+
80
+ const METHODS = { HEAD: 1, GET: 1, POST: 1, PUT: 1, PATCH: 1 }
81
+ const SAFE = { HEAD: 1, GET: 1 }
82
+
83
+ exports.Server = Server
package/src/tenant.js ADDED
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+
3
+ const { Connector } = require('@toa.io/core')
4
+
5
+ class Tenant extends Connector {
6
+ #binding
7
+ #declaration
8
+
9
+ constructor (binding, { namespace, name }, resources) {
10
+ super()
11
+
12
+ this.#binding = binding
13
+ this.#declaration = { namespace, name, resources }
14
+
15
+ this.depends(binding)
16
+ }
17
+
18
+ async connection () {
19
+ await this.#binding.receive('ping', () => this.#expose())
20
+ await this.#expose()
21
+ }
22
+
23
+ async #expose () {
24
+ await this.#binding.send('expose', this.#declaration)
25
+ }
26
+ }
27
+
28
+ exports.Tenant = Tenant
@@ -0,0 +1,14 @@
1
+ 'use strict'
2
+
3
+ const get = (header) => {
4
+ const match = header.match(rx)
5
+ return match === null ? null : match[1]
6
+ }
7
+
8
+ const set = (value) => '"' + value + '"'
9
+
10
+ const rx = /^"([^"]+)"$/
11
+
12
+ exports.get = get
13
+ exports.set = set
14
+ exports.rx = rx
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ const { request } = require('./request')
4
+ const response = require('./response')
5
+
6
+ exports.request = request
7
+ exports.response = response
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ const { exceptions: { RequestSyntaxException, RequestConflictException } } = require('@toa.io/core')
4
+ const { empty } = require('@toa.io/generic')
5
+
6
+ const etag = require('./etag')
7
+
8
+ /**
9
+ * @hot
10
+ *
11
+ * @param {import('express').Request} req
12
+ * @param {{[key: string]: string}} params
13
+ * @returns {toa.core.Request}
14
+ */
15
+ const request = (req, params) => {
16
+ const request = {}
17
+
18
+ if (!empty(req.body)) request.input = req.body
19
+
20
+ if (!empty(req.query)) {
21
+ request.query = req.query
22
+
23
+ if (request.query.projection !== undefined) request.query.projection = request.query.projection.split(',')
24
+ if (request.query.sort !== undefined) request.query.sort = request.query.sort.split(',')
25
+ }
26
+
27
+ if (!empty(params)) {
28
+ if (req.method === 'POST') {
29
+ if (request.input === undefined) request.input = {}
30
+
31
+ for (const [key, value] of Object.entries(params)) {
32
+ if (request.input[key] === undefined) request.input[key] = value
33
+ else throw new RequestConflictException(`Input property '${key}' conflicts with path parameter`)
34
+ }
35
+ } else {
36
+ const criteria = []
37
+
38
+ if (request.query === undefined) request.query = {}
39
+
40
+ for (const [key, value] of Object.entries(params)) {
41
+ if (key === 'id') request.query.id = value
42
+ else criteria.push(key + '==' + value)
43
+ }
44
+
45
+ if (criteria.length > 0) {
46
+ const value = criteria.join(';')
47
+
48
+ if (request.query.criteria === undefined) request.query.criteria = value
49
+ else request.query.criteria = value + ';' + request.query.criteria
50
+ }
51
+ }
52
+ }
53
+
54
+ const condition = req.get('if-match')
55
+
56
+ if (condition !== undefined && condition !== '*') {
57
+ const value = etag.get(condition)
58
+
59
+ if (value === null) throw new RequestSyntaxException('ETag value must match ' + etag.rx)
60
+ if (request.query === undefined) request.query = {}
61
+
62
+ request.query.version = +value
63
+ }
64
+
65
+ return request
66
+ }
67
+
68
+ exports.request = request
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ const { exceptions: { codes } } = require('@toa.io/core')
4
+
5
+ const etag = require('./etag')
6
+
7
+ /**
8
+ * @param {toa.core.Reply} reply
9
+ * @param res
10
+ * @param req
11
+ * @hot
12
+ */
13
+ const ok = (reply, res, req) => {
14
+ if (reply.output?._version !== undefined) {
15
+ const { _version, ...output } = reply.output
16
+ const condition = req.get('if-none-match')
17
+
18
+ if (condition !== undefined && req.safe) {
19
+ const value = etag.get(condition)
20
+
21
+ if (value === _version) {
22
+ res.status(304).end()
23
+ return
24
+ }
25
+ }
26
+
27
+ res.set('etag', etag.set(_version))
28
+ reply.output = output
29
+ }
30
+
31
+ let status
32
+
33
+ if (req.method === 'POST') status = 201
34
+ else if (reply.output !== undefined || reply.error !== undefined) status = 200
35
+ else status = 204
36
+
37
+ res.status(status)
38
+ if (status !== 204) res.send(reply)
39
+ }
40
+
41
+ const missed = (response) => response.status(404)
42
+
43
+ const exception = (exception, response) => {
44
+ const status = STATUSES[exception.code] || 500
45
+
46
+ response.status(status)
47
+ response.send(exception)
48
+ }
49
+
50
+ const STATUSES = {
51
+ [codes.RequestContract]: 400,
52
+ [codes.RequestSyntax]: 400,
53
+ [codes.QuerySyntax]: 400,
54
+ [codes.RequestConflict]: 403,
55
+ [codes.StateNotFound]: 404,
56
+ [codes.NotImplemented]: 405,
57
+ [codes.StatePrecondition]: 412
58
+ }
59
+
60
+ exports.ok = ok
61
+ exports.missed = missed
62
+ exports.exception = exception
package/src/tree.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const { match } = require('path-to-regexp')
5
+
6
+ const { console } = require('@toa.io/console')
7
+
8
+ /**
9
+ * @implements {toa.extensions.exposition.Tree}
10
+ */
11
+ class Tree {
12
+ /** @type {toa.extensions.exposition.tree.Node[]} */
13
+ #nodes
14
+
15
+ /** @type {toa.extensions.exposition.query.Factory} */
16
+ #query
17
+
18
+ /**
19
+ * @param {toa.extensions.exposition.query.Factory} query
20
+ */
21
+ constructor (query) {
22
+ this.#query = query
23
+ }
24
+
25
+ /** @hot */
26
+ match (path) {
27
+ // dev only check
28
+ if (process.env.TOA_ENV === 'local') {
29
+ const nodes = this.#nodes.filter((node) => node.match(path) !== false)
30
+
31
+ if (nodes.length > 1) {
32
+ const routes = nodes.map((node) => node.route)
33
+
34
+ throw new Error('Ambiguous routes ' + routes.join(', '))
35
+ }
36
+ }
37
+
38
+ let match
39
+
40
+ const node = this.#nodes.find((node) => {
41
+ match = node.match(path)
42
+ return match !== false
43
+ })
44
+
45
+ return node === undefined ? undefined : { node, params: match.params }
46
+ }
47
+
48
+ update (tree) {
49
+ this.#nodes = []
50
+ this.#traverse(tree)
51
+ }
52
+
53
+ /**
54
+ * @param {toa.extensions.exposition.declarations.Node | any} node
55
+ * @param {string} route
56
+ * @param {toa.extensions.exposition.declarations.Node} parent
57
+ */
58
+ #traverse (node, route = undefined, parent = undefined) {
59
+ const current = {}
60
+
61
+ if (route === undefined) route = '/'
62
+ else route = trail(route)
63
+
64
+ if (parent !== undefined) node.parent = parent
65
+
66
+ if (node.operations) {
67
+ current.route = route
68
+ current.match = match(route)
69
+ current.query = this.#query(node)
70
+ current.operations = {}
71
+
72
+ for (const operation of node.operations) current.operations[method(operation)] = operation
73
+
74
+ this.#nodes.push(current)
75
+ }
76
+
77
+ let branches = 0
78
+
79
+ for (const [key, value] of Object.entries(node)) {
80
+ if (key[0] === '/') {
81
+ branches++
82
+
83
+ const branch = path.posix.resolve(route, '.' + key)
84
+
85
+ this.#traverse(value, branch, node)
86
+ }
87
+ }
88
+
89
+ if (branches === 0 && node.operations === undefined) {
90
+ console.warn(`Resource tree leaf '${route}' has no operations`)
91
+ }
92
+ }
93
+ }
94
+
95
+ const trail = (path) => path[path.length - 1] === '/' ? path : path + '/'
96
+
97
+ const method = (operation) => {
98
+ if (operation.type === 'transition') {
99
+ if (operation.query === false) return 'POST'
100
+ else return 'PUT'
101
+ }
102
+
103
+ if (operation.type === 'observation') return 'GET'
104
+ if (operation.type === 'assignment') return 'PATCH'
105
+ }
106
+
107
+ exports.Tree = Tree
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ const manifest = {
4
+ operations: {
5
+ one: {
6
+ type: 'transition',
7
+ scope: 'object',
8
+ query: false
9
+ },
10
+ two: {
11
+ type: 'observation',
12
+ scope: 'objects'
13
+ },
14
+ three: {
15
+ type: 'observation',
16
+ scope: 'objects'
17
+ }
18
+ }
19
+ }
20
+
21
+ const resources = {
22
+ '/': ['one', 'two'],
23
+ '/top': {
24
+ operations: ['one'],
25
+ '/nested': {
26
+ operations: ['two'],
27
+ '/deeper': {
28
+ operations: [{
29
+ operation: 'three'
30
+ }]
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ exports.manifest = manifest
37
+ exports.resources = resources
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ const clone = require('clone-deep')
4
+
5
+ const { normalize } = require('../src/.manifest')
6
+ const fixtures = require('./manifest.normalize.fixtures')
7
+
8
+ describe('normalize', () => {
9
+ let manifest, resources
10
+
11
+ const map = (operation) => ({
12
+ ...fixtures.manifest.operations[operation],
13
+ operation
14
+ })
15
+
16
+ beforeEach(() => {
17
+ manifest = clone(fixtures.manifest)
18
+ resources = clone(fixtures.resources)
19
+ })
20
+
21
+ it('should expand array', () => {
22
+ normalize(resources, manifest)
23
+
24
+ expect(resources['/'].operations)
25
+ .toStrictEqual(fixtures.resources['/'].map(map))
26
+ })
27
+
28
+ it('should expand operations', () => {
29
+ normalize(resources, manifest)
30
+
31
+ expect(resources['/top'].operations)
32
+ .toStrictEqual(fixtures.resources['/top'].operations.map(map))
33
+
34
+ expect(resources['/top']['/nested'].operations)
35
+ .toStrictEqual(fixtures.resources['/top']['/nested'].operations.map(map))
36
+ })
37
+ })
@@ -0,0 +1,25 @@
1
+ 'use strict'
2
+
3
+ const { validate } = require('../src/.manifest')
4
+
5
+ it('should validate', () => {
6
+ const resources = {
7
+ '/path/to': {
8
+ operations: [
9
+ {
10
+ operation: 'foo',
11
+ type: 'observation'
12
+ }
13
+ ],
14
+ '/deeper/': {
15
+ operations: [{
16
+ operation: 'bar',
17
+ type: 'transition',
18
+ query: false
19
+ }]
20
+ }
21
+ }
22
+ }
23
+
24
+ expect(() => validate(resources)).not.toThrow()
25
+ })
@@ -0,0 +1,18 @@
1
+ 'use strict'
2
+
3
+ const { Range } = require('../src/query/range')
4
+
5
+ let range
6
+
7
+ const operation = { type: 'observation', scope: 'objects' }
8
+
9
+ describe('exact', () => {
10
+ beforeAll(() => {
11
+ range = new Range({ value: 10, range: [] })
12
+ })
13
+
14
+ it('should throw on value mismatch', () => {
15
+ expect(() => range.parse(10, operation)).not.toThrow()
16
+ expect(() => range.parse(11, operation)).toThrow(/out of range/)
17
+ })
18
+ })
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ const declaration = {
4
+ '/': {
5
+ operations: ['find'],
6
+ '/:id': {
7
+ operations: ['observe'],
8
+ '/segment': {
9
+ operations: ['delete']
10
+ }
11
+ },
12
+ '/segment/:param': {
13
+ operations: ['transit']
14
+ }
15
+ },
16
+ '/sibling': {
17
+ operations: ['update']
18
+ }
19
+ }
20
+
21
+ exports.declaration = declaration
@@ -0,0 +1,44 @@
1
+ 'use strict'
2
+
3
+ const { Tree } = require('../src/tree')
4
+ const fixtures = require('./tree.fixtures')
5
+
6
+ let tree
7
+
8
+ beforeEach(() => {
9
+ tree = new Tree(() => null)
10
+ tree.update(fixtures.declaration)
11
+ })
12
+
13
+ it('should find node', () => {
14
+ expect(tree.match('/12/')).toBeDefined()
15
+ expect(tree.match('/12/segment/')).toBeDefined()
16
+ })
17
+
18
+ it('should return undefined on mismatch', () => {
19
+ expect(tree.match('/12')).not.toBeDefined()
20
+ expect(tree.match('/non/existent/')).not.toBeDefined()
21
+ })
22
+
23
+ it('should throw on resource conflicts on local dev env', () => {
24
+ const declaration = {
25
+ '/:id': {
26
+ operations: ['observe']
27
+ },
28
+ '/ok': {
29
+ operations: ['transit']
30
+ }
31
+ }
32
+
33
+ const env = process.env.TOA_ENV
34
+
35
+ process.env.TOA_ENV = 'local'
36
+
37
+ tree = new Tree(() => null)
38
+
39
+ tree.update(declaration)
40
+
41
+ expect(() => tree.match('/ok/')).toThrow(/Ambiguous routes/)
42
+
43
+ process.env.TOA_ENV = env
44
+ })
@@ -0,0 +1,10 @@
1
+ declare namespace toa.extensions.exposition {
2
+
3
+ interface Annotations {
4
+ host: string
5
+ class: string
6
+ annotations: Record<string, string>
7
+ }
8
+
9
+
10
+ }
@@ -0,0 +1,31 @@
1
+ declare namespace toa.extensions.exposition.declarations {
2
+
3
+ interface Operation {
4
+ operation: string
5
+ type: 'transition' | 'observation' | 'assignment'
6
+ scope: 'object' | 'objects' | 'changeset'
7
+ query?: boolean
8
+ }
9
+
10
+ interface Query {
11
+ criteria?: string
12
+ sort?: string[]
13
+ projection?: string[]
14
+ }
15
+
16
+ type Node = {
17
+ query?: Query
18
+ operations?: Operation[]
19
+ } & {
20
+ [key: string]: Node
21
+ }
22
+
23
+ interface Exposition {
24
+ namespace: string
25
+ name: string
26
+ resources: Node
27
+ }
28
+ }
29
+
30
+ export type Node = toa.extensions.exposition.declarations.Node
31
+ export type Operation = toa.extensions.exposition.declarations.Operation
@@ -0,0 +1,13 @@
1
+ // noinspection ES6UnusedImports
2
+
3
+ import type { Remote } from './remote'
4
+
5
+ declare namespace toa.extensions.exposition {
6
+
7
+ namespace exposition {
8
+
9
+ type Remotes = Record<string, Promise<Remote>>
10
+
11
+ }
12
+
13
+ }
@@ -0,0 +1,13 @@
1
+ import type * as express from 'express'
2
+
3
+ declare namespace toa.extensions.exposition.http {
4
+ type Method = 'GET' | 'POST' | 'PUT' | 'PATCH'
5
+
6
+ interface Request extends express.Request {
7
+ }
8
+
9
+ interface Response extends express.Response {
10
+ }
11
+ }
12
+
13
+ export type Method = toa.extensions.exposition.http.Method
@@ -0,0 +1,16 @@
1
+ import type { Query as RequestQuery } from '@toa.io/core/types/request'
2
+ import type { Node, Operation } from './declarations'
3
+
4
+ declare namespace toa.extensions.exposition {
5
+
6
+ namespace query {
7
+ type Factory = (node: Node) => Query
8
+ }
9
+
10
+ interface Query {
11
+ parse(query: RequestQuery, operation: Operation): RequestQuery
12
+ }
13
+
14
+ }
15
+
16
+ export type Query = toa.extensions.exposition.Query
@@ -0,0 +1,19 @@
1
+ // noinspection ES6UnusedImports
2
+
3
+ import type * as declarations from './declarations'
4
+
5
+ declare namespace toa.extensions.exposition {
6
+
7
+ namespace remotes {
8
+
9
+ type Factory = (namespace: string, name: string) => Promise<Remote>
10
+
11
+ }
12
+
13
+ interface Remote {
14
+ update(declaration: declarations.Node): void
15
+ }
16
+
17
+ }
18
+
19
+ export type Remote = toa.extensions.exposition.Remote
@@ -0,0 +1,13 @@
1
+ import type { Request, Response } from 'express'
2
+
3
+ declare namespace toa.extensions.exposition {
4
+
5
+ namespace server {
6
+ type Callback = (req: Request, res: Response) => void
7
+ }
8
+
9
+ interface Server {
10
+ route(route: string, callback: server.Callback)
11
+ }
12
+
13
+ }