@toa.io/extensions.exposition 0.23.0-dev.0 → 1.0.0-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.
@@ -13,9 +13,8 @@
13
13
 
14
14
  The Authorization is implemented as a set of [RTD Directives](tree.md#directives).
15
15
 
16
- Directives are executed in a predetermined order until one of them grants access to a resource. If
17
- none of the
18
- directives grants access, then the Authorization interrupts request processing and responds with an
16
+ Directives are executed in a predetermined order until one of them grants access to a resource.
17
+ If none of the directives grants access, then the Authorization interrupts request processing and responds with an
19
18
  authorization error.
20
19
 
21
20
  > The Authorization directive provider is named `authorization`,
@@ -0,0 +1,42 @@
1
+ # Caching
2
+
3
+ Directive family `cache` implements the
4
+ HTTP [Cache-Control](https://datatracker.ietf.org/doc/html/rfc2616#section-14.9).
5
+
6
+ ## `cache:control`
7
+
8
+ Sets the value of the `Cache-Control` header
9
+ for [successful responses](https://datatracker.ietf.org/doc/html/rfc2616#section-10.2)
10
+ to [safe HTTP methods](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP).
11
+
12
+ ```yaml
13
+ /:
14
+ GET:
15
+ cache:control: max-age=60000
16
+ ```
17
+
18
+ ### Implicit modifications
19
+
20
+ In terms of security, the following implicit modifications are made to the `Cache-Control` header:
21
+
22
+ - If it contains the `public` directive without `no-cache` and the request is authenticated,
23
+ the `no-cache` directive is added.
24
+ This is done to prevent the storage of authentication tokens in shared caches.
25
+ - If it does not contain the `private` directive and the request is authenticated, the `private`
26
+ directive is added.
27
+ This is to prevent the storage of private data in shared caches.
28
+
29
+ ## `cache:exact`
30
+
31
+ Same as `cache:control` without implicit modifications.
32
+
33
+ ```yaml
34
+ /:
35
+ GET:
36
+ cache:exact: public, max-age=60000
37
+ ```
38
+
39
+ ## References
40
+
41
+ - HTTP 14.9.1 [What is cacheable](https://datatracker.ietf.org/doc/html/rfc2616#section-14.9.1)
42
+ - See also [features](/extensions/exposition/features/cache.feature)
@@ -124,7 +124,7 @@ Intermediate Nodes must not have Methods as they are unreachable.
124
124
 
125
125
  ## Directives
126
126
 
127
- RTD Directives are declared using RTD node or Method keys following the `{provider}:{directive}` pattern and can be used
127
+ RTD Directives are declared using RTD node or Method keys following the `{family}:{directive}` pattern and can be used
128
128
  to add or modify the behavior of request processing. Directive declarations are applied to the RTD node where they are
129
129
  declared and to all nested nodes.
130
130
 
@@ -151,10 +151,6 @@ When it is necessary to avoid directive nesting, a Route can be declared adjacen
151
151
  In this example, the Route `/posts/:user-id/:post-id/` has only the `authorization:role` directive
152
152
  applied.
153
153
 
154
- > Directives can be declared without the `{provider}:` prefix unless there are multiple directives
155
- > with the same name
156
- > across different providers.
157
-
158
154
  Another way to avoid nesting is to declare an _isolated_ Node as follows:
159
155
 
160
156
  ```yaml
@@ -0,0 +1,160 @@
1
+ Feature: Caching
2
+
3
+ Background:
4
+ Given the `identity.basic` database contains:
5
+ # developer:secret
6
+ # user:12345
7
+ | _id | username | password |
8
+ | b70a7dbca6b14a2eaac8a9eb4b2ff4db | developer | $2b$10$ZRSKkgZoGnrcTNA5w5eCcu3pxDzdTduhteVYXcp56AaNcilNkwJ.O |
9
+ Given the `identity.roles` database contains:
10
+ | _id | identity | role |
11
+ | 775a648d054e4ce1a65f8f17e5b51803 | b70a7dbca6b14a2eaac8a9eb4b2ff4db | developer |
12
+
13
+ Scenario: Caching successful response
14
+ Given the annotation:
15
+ """yaml
16
+ /:
17
+ anonymous: true
18
+ GET:
19
+ cache:control: max-age=60000
20
+ dev:stub: hello
21
+ """
22
+ When the following request is received:
23
+ """
24
+ GET / HTTP/1.1
25
+ accept: text/plain
26
+ """
27
+ Then the following reply is sent:
28
+ """
29
+ 200 OK
30
+ content-type: text/plain
31
+ cache-control: max-age=60000
32
+
33
+ hello
34
+ """
35
+
36
+ Scenario: Nested cache directives
37
+ Given the annotation:
38
+ """yaml
39
+ /:
40
+ cache:control: max-age=30000
41
+ GET:
42
+ anonymous: true
43
+ dev:stub: hello
44
+ /foo:
45
+ auth:role: developer
46
+ GET:
47
+ dev:stub: hello
48
+ /bar:
49
+ auth:role: developer
50
+ cache:control: max-age=60000, public
51
+ GET:
52
+ dev:stub: hello
53
+ """
54
+ When the following request is received:
55
+ """
56
+ GET / HTTP/1.1
57
+ accept: text/plain
58
+ """
59
+ Then the following reply is sent:
60
+ """
61
+ 200 OK
62
+ content-type: text/plain
63
+ cache-control: max-age=30000
64
+
65
+ hello
66
+ """
67
+ When the following request is received:
68
+ """
69
+ GET /foo/ HTTP/1.1
70
+ accept: text/plain
71
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
72
+ """
73
+ Then the following reply is sent:
74
+ """
75
+ 200 OK
76
+ content-type: text/plain
77
+ cache-control: private, max-age=30000
78
+
79
+ hello
80
+ """
81
+ When the following request is received:
82
+ """
83
+ GET /bar/ HTTP/1.1
84
+ accept: text/plain
85
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
86
+ """
87
+ Then the following reply is sent:
88
+ """
89
+ 200 OK
90
+ content-type: text/plain
91
+ cache-control: no-cache, max-age=60000, public
92
+
93
+ hello
94
+ """
95
+ And the reply does not contain:
96
+ """
97
+ cache-control: private, max-age=30000
98
+ """
99
+
100
+ Scenario: Cache-control is not added when request is unsafe
101
+ Given the annotation:
102
+ """yaml
103
+ /:
104
+ anonymous: true
105
+ cache:control: max-age=60000
106
+ POST:
107
+ dev:stub: hello
108
+ """
109
+ When the following request is received:
110
+ """
111
+ POST / HTTP/1.1
112
+ accept: application/yaml
113
+ """
114
+ Then the reply does not contain:
115
+ """
116
+ cache-control: max-age=60000
117
+ """
118
+
119
+ Scenario: Cache-control is added without implicit modifications
120
+ Given the annotation:
121
+ """yaml
122
+ /:
123
+ auth:role: developer
124
+ cache:exact: max-age=60000, public
125
+ GET:
126
+ dev:stub: hello
127
+ """
128
+ When the following request is received:
129
+ """
130
+ GET / HTTP/1.1
131
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
132
+ accept: text/plain
133
+
134
+ """
135
+ Then the following reply is sent:
136
+ """
137
+ 200 OK
138
+ content-type: text/plain
139
+ cache-control: max-age=60000, public
140
+
141
+ hello
142
+ """
143
+
144
+ Scenario: Response without caching
145
+ Given the annotation:
146
+ """yaml
147
+ /:
148
+ anonymous: true
149
+ GET:
150
+ dev:stub: hello
151
+ """
152
+ When the following request is received:
153
+ """
154
+ GET / HTTP/1.1
155
+ accept: text/plain
156
+ """
157
+ Then the reply does not contain:
158
+ """
159
+ cache-control:
160
+ """
@@ -4,7 +4,7 @@ import * as http from '@toa.io/http'
4
4
  import { trim } from '@toa.io/generic'
5
5
  import { buffer } from '@toa.io/streams'
6
6
  import { open } from '../../../storages/source/test/util'
7
- import { Parameters } from './parameters'
7
+ import { Parameters } from './Parameters'
8
8
  import { Gateway } from './Gateway'
9
9
 
10
10
  @binding([Gateway, Parameters])
@@ -85,7 +85,15 @@ export class HTTP {
85
85
  const { url, method, headers } = http.parse.request(head)
86
86
  const href = new URL(url, this.origin).href
87
87
  const body = open(filename)
88
- const request = { method, headers, body, duplex: 'half' } as unknown as RequestInit
88
+
89
+ headers.connection = 'close' // required for interrupted streams
90
+
91
+ const request = {
92
+ method,
93
+ headers,
94
+ body: body as unknown as ReadableStream,
95
+ duplex: 'half'
96
+ }
89
97
 
90
98
  try {
91
99
  const response = await fetch(href, request)
@@ -8,8 +8,7 @@ export class Parameters {
8
8
  }
9
9
  }
10
10
 
11
- setDefaultTimeout(10 * 1000)
12
-
11
+ setDefaultTimeout(30 * 1000)
13
12
  process.env.TOA_DEV = '1'
14
13
 
15
14
  // { octets: tmp:///exposition-octets }
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path'
2
- import { directory } from '@toa.io/filesystem'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtemp, copy } from 'fs-extra'
3
4
  import * as yaml from '@toa.io/yaml'
4
5
 
5
6
  export class Workspace {
@@ -10,7 +11,8 @@ export class Workspace {
10
11
  const method = descriptor.value
11
12
 
12
13
  descriptor.value = async function (this: Workspace, ...args: any[]): Promise<any> {
13
- if (this.root === devnull) this.root = await directory.temp()
14
+ if (this.root === devnull) this.root =
15
+ await mkdtemp(join(tmpdir(), Math.random().toString(36).slice(2)))
14
16
 
15
17
  return method.apply(this, args)
16
18
  }
@@ -23,7 +25,7 @@ export class Workspace {
23
25
  const source = join(__dirname, 'components', name)
24
26
  const target = join(this.root, name)
25
27
 
26
- await directory.copy(source, target)
28
+ await copy(source, target)
27
29
 
28
30
  if (patch !== undefined)
29
31
  await this.patchManifest(target, patch)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "0.23.0-dev.0",
3
+ "version": "1.0.0-alpha.0",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -17,11 +17,11 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@toa.io/core": "0.23.0-dev.0",
21
- "@toa.io/generic": "0.23.0-dev.0",
22
- "@toa.io/http": "0.23.0-dev.0",
23
- "@toa.io/schemas": "0.23.0-dev.0",
24
- "@toa.io/streams": "0.23.0-dev.0",
20
+ "@toa.io/core": "1.0.0-alpha.0",
21
+ "@toa.io/generic": "1.0.0-alpha.0",
22
+ "@toa.io/http": "1.0.0-alpha.0",
23
+ "@toa.io/schemas": "1.0.0-alpha.0",
24
+ "@toa.io/streams": "1.0.0-alpha.0",
25
25
  "bcryptjs": "2.4.3",
26
26
  "cors": "2.8.5",
27
27
  "error-value": "0.3.0",
@@ -38,17 +38,20 @@
38
38
  },
39
39
  "scripts": {
40
40
  "test": "jest",
41
- "transpile": "rm -rf transpiled && npx tsc && npm run transpile:basic && npm run transpile:tokens",
41
+ "transpile": "rm -rf transpiled && npx tsc && npm run transpile:basic && npm run transpile:tokens && npm run transpile:roles",
42
42
  "transpile:basic": "rm -rf ./components/identity.basic/operations && npx tsc -p ./components/identity.basic",
43
43
  "transpile:tokens": "rm -rf ./components/identity.tokens/operations && npx tsc -p ./components/identity.tokens",
44
+ "transpile:roles": "rm -rf ./components/identity.roles/operations && npx tsc -p ./components/identity.roles",
44
45
  "features": "npx cucumber-js"
45
46
  },
46
47
  "devDependencies": {
47
- "@toa.io/extensions.storages": "0.23.0-dev.0",
48
+ "@toa.io/extensions.storages": "1.0.0-alpha.0",
48
49
  "@types/bcryptjs": "2.4.3",
49
50
  "@types/cors": "2.8.13",
50
51
  "@types/express": "4.17.17",
51
- "@types/negotiator": "0.6.1"
52
+ "@types/fs-extra": "11.0.4",
53
+ "@types/negotiator": "0.6.1",
54
+ "fs-extra": "11.1.1"
52
55
  },
53
- "gitHead": "19d4af8ff3b8a1a8f191644f44113c88de4bb404"
56
+ "gitHead": "06c64546f6292cc07c52f74b31415101037f7616"
54
57
  }
package/readme.md CHANGED
@@ -182,4 +182,5 @@ exposition:
182
182
  - [Access authorization](documentation/access.md)
183
183
  - [BLOBs](documentation/octets.md)
184
184
  - [Components and resources](documentation/components.md)
185
+ - [Caching](documentation/cache.md)
185
186
  - [Features](features)
@@ -97,7 +97,9 @@ describe('result', () => {
97
97
  it('should send status code 200 if the result has a value', async () => {
98
98
  const req = createRequest()
99
99
 
100
- server.attach(async (): Promise<OutgoingMessage> => ({ headers: {}, body: generate() }))
100
+ server.attach(async (): Promise<OutgoingMessage> => ({
101
+ headers: new Headers(), body: generate()
102
+ }))
101
103
  await use(req)
102
104
 
103
105
  expect(res.status).toHaveBeenCalledWith(200)
@@ -106,7 +108,7 @@ describe('result', () => {
106
108
  it('should send status code 204 if the result has no value', async () => {
107
109
  const req = createRequest()
108
110
 
109
- server.attach(async (): Promise<OutgoingMessage> => ({ headers: {} }))
111
+ server.attach(async (): Promise<OutgoingMessage> => ({ headers: new Headers() }))
110
112
  await use(req)
111
113
 
112
114
  expect(res.status).toHaveBeenCalledWith(204)
@@ -118,7 +120,7 @@ describe('result', () => {
118
120
  const buf = Buffer.from(json)
119
121
  const req = createRequest({ headers: { accept: 'application/json' } })
120
122
 
121
- server.attach(async (): Promise<OutgoingMessage> => ({ headers: {}, body }))
123
+ server.attach(async (): Promise<OutgoingMessage> => ({ headers: new Headers(), body }))
122
124
  await use(req)
123
125
 
124
126
  expect(res.end).toHaveBeenCalledWith(buf)
@@ -84,9 +84,8 @@ export class Server extends Connector {
84
84
  else if (message.body === undefined) status = 204
85
85
  else status = 200
86
86
 
87
- response
88
- .status(status)
89
- .set(message.headers)
87
+ response.status(status)
88
+ message.headers?.forEach((value, key) => response.set(key, value))
90
89
 
91
90
  if (message.body !== undefined && message.body !== null)
92
91
  write(request, response, message)
@@ -1,4 +1,4 @@
1
- import { type IncomingHttpHeaders, type OutgoingHttpHeaders } from 'node:http'
1
+ import { type IncomingHttpHeaders } from 'node:http'
2
2
  import { Readable } from 'node:stream'
3
3
  import { type Request, type Response } from 'express'
4
4
  import { buffer } from '@toa.io/streams'
@@ -53,7 +53,7 @@ function send (message: OutgoingMessage, request: IncomingMessage, response: Res
53
53
 
54
54
  function stream
55
55
  (message: OutgoingMessage, request: IncomingMessage, response: Response): void {
56
- const encoded = message.headers !== undefined && 'content-type' in message.headers
56
+ const encoded = message.headers !== undefined && message.headers.has('content-type')
57
57
 
58
58
  if (encoded)
59
59
  pipe(message, response)
@@ -67,7 +67,7 @@ function stream
67
67
  }
68
68
 
69
69
  function pipe (message: OutgoingMessage, response: Response): void {
70
- response.set(message.headers)
70
+ message.headers?.forEach((value, key) => response.set(key, value))
71
71
  message.body.pipe(response)
72
72
  }
73
73
 
@@ -101,7 +101,7 @@ export interface IncomingMessage extends Request {
101
101
 
102
102
  export interface OutgoingMessage {
103
103
  status?: number
104
- headers?: OutgoingHttpHeaders
104
+ headers?: Headers
105
105
  body?: any
106
106
  }
107
107
 
@@ -5,7 +5,8 @@ import {
5
5
  type Route,
6
6
  type Method,
7
7
  type Mapping,
8
- type Directive, type Range
8
+ type Directive,
9
+ type Range
9
10
  } from './types'
10
11
 
11
12
  export function parse (input: object, shortcuts?: Shortcuts): Node {
package/source/Tenant.ts CHANGED
@@ -30,6 +30,11 @@ export class Tenant extends Connector {
30
30
  `'${this.branch.namespace}.${this.branch.component}' has started.`)
31
31
  }
32
32
 
33
+ public override async dispose (): Promise<void> {
34
+ console.info('Exposition Tenant for ' +
35
+ `'${this.branch.namespace}.${this.branch.component}' has been stopped.`)
36
+ }
37
+
33
38
  private async expose (): Promise<void> {
34
39
  await this.broadcast.transmit('expose', this.branch)
35
40
  }
@@ -90,9 +90,9 @@ class Authorization implements Family<Directive, Extension> {
90
90
  const authorization = `Token ${token}`
91
91
 
92
92
  if (response.headers === undefined)
93
- response.headers = {}
93
+ response.headers = new Headers()
94
94
 
95
- response.headers.authorization = authorization
95
+ response.headers.set('authorization', authorization)
96
96
  }
97
97
 
98
98
  private async resolve (authorization: string | undefined): Promise<Identity | null> {
@@ -0,0 +1,59 @@
1
+ import { match } from 'matchacho'
2
+ import type { AuthenticatedRequest, Directive } from './types'
3
+
4
+ export class Control implements Directive {
5
+ protected readonly value: string
6
+ private cache: string | null = null
7
+
8
+ public constructor (value: string) {
9
+ this.value = value
10
+ }
11
+
12
+ public set (request: AuthenticatedRequest, headers: Headers): void {
13
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method))
14
+ return
15
+
16
+ this.cache ??= this.resolve(request)
17
+
18
+ headers.set('cache-control', this.cache)
19
+ }
20
+
21
+ protected resolve (request: AuthenticatedRequest): string {
22
+ if (request.identity === null)
23
+ return this.value
24
+
25
+ const directives = this.mask()
26
+
27
+ if ((directives & (PUBLIC | NO_CACHE)) === PUBLIC)
28
+ return 'no-cache, ' + this.value
29
+
30
+ if ((directives & (PUBLIC | PRIVATE)) === 0)
31
+ return 'private, ' + this.value
32
+
33
+ return this.value
34
+ }
35
+
36
+ private mask (): number {
37
+ const directives = this.value.match(DIRECTIVES_RX)
38
+
39
+ if (directives === null)
40
+ return 0
41
+
42
+ let mask = 0
43
+
44
+ for (const directive of directives)
45
+ mask |= match<number>(directive,
46
+ 'private', PRIVATE,
47
+ 'public', PUBLIC,
48
+ 'no-cache', NO_CACHE,
49
+ 0)
50
+
51
+ return mask
52
+ }
53
+ }
54
+
55
+ const DIRECTIVES_RX = /\b(private|public|no-cache)\b/ig
56
+
57
+ const PUBLIC = 1
58
+ const PRIVATE = 2
59
+ const NO_CACHE = 4
@@ -0,0 +1,7 @@
1
+ import { Control } from './Control'
2
+
3
+ export class Exact extends Control {
4
+ protected override resolve (): string {
5
+ return this.value
6
+ }
7
+ }
@@ -0,0 +1,36 @@
1
+ import { type Input, type Output, type Family } from '../../Directive'
2
+ import { Control } from './Control'
3
+ import { type Directive } from './types'
4
+ import { Exact } from './Exact'
5
+ import type * as http from '../../HTTP'
6
+
7
+ class Cache implements Family<Directive> {
8
+ public readonly name: string = 'cache'
9
+ public readonly mandatory: boolean = false
10
+
11
+ public create (name: string, value: any): Directive {
12
+ const Class = constructors[name]
13
+
14
+ if (Class === undefined)
15
+ throw new Error(`Directive '${name}' is not provided by the '${this.name}' family.`)
16
+
17
+ return new Class(value)
18
+ }
19
+
20
+ public preflight (): Output {
21
+ return null
22
+ }
23
+
24
+ public async settle
25
+ (directives: Directive[], request: Input, response: http.OutgoingMessage): Promise<void> {
26
+ response.headers ??= new Headers()
27
+ directives[0]?.set(request, response.headers)
28
+ }
29
+ }
30
+
31
+ const constructors: Record<string, new (value: any) => Directive> = {
32
+ control: Control,
33
+ exact: Exact
34
+ }
35
+
36
+ export = new Cache()
@@ -0,0 +1,3 @@
1
+ import Family from './Family'
2
+
3
+ export = Family
@@ -0,0 +1,9 @@
1
+ import { type Input } from '../../Directive'
2
+
3
+ export interface Directive {
4
+ set: (input: Input, headers: Headers) => void
5
+ }
6
+
7
+ export interface AuthenticatedRequest extends Input {
8
+ identity?: unknown | null
9
+ }
@@ -1,6 +1,7 @@
1
1
  import { type Family } from '../Directive'
2
2
  import Dev from './dev'
3
3
  import Auth from './auth'
4
+ import Cache from './cache'
4
5
  import Octets from './octets'
5
6
 
6
- export const families: Family[] = [Auth, Octets, Dev]
7
+ export const families: Family[] = [Auth, Octets, Dev, Cache]
@@ -48,11 +48,11 @@ export class Fetch implements Directive {
48
48
  if (result instanceof Error)
49
49
  throw new NotFound()
50
50
 
51
- const headers = {
51
+ const headers = new Headers({
52
52
  'content-type': result.type,
53
- 'content-length': result.size,
53
+ 'content-length': result.size.toString(),
54
54
  etag: result.checksum
55
- }
55
+ })
56
56
 
57
57
  return { headers, body: result.stream }
58
58
  }