@toa.io/extensions.exposition 1.0.0-alpha.267 → 1.0.0-alpha.268
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/CHANGELOG.md +15 -0
- package/components/{octets.storage → exposition.octets}/manifest.toa.yaml +2 -2
- package/components/exposition.stash/manifest.toa.yaml +12 -0
- package/components/exposition.stash/operations/count.js +7 -0
- package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
- package/documentation/io.md +45 -17
- package/documentation/notes/throttling.md +6 -0
- package/features/io.throttle.feature +127 -0
- package/features/map.feature +5 -5
- package/features/octets.cloudinary.feature +12 -9
- package/features/octets.download.feature +4 -3
- package/features/octets.entries.feature +5 -3
- package/features/octets.feature +4 -4
- package/features/octets.head.feature +1 -1
- package/features/octets.location.feature +1 -1
- package/features/octets.meta.feature +2 -2
- package/features/octets.workflows.feature +7 -7
- package/package.json +8 -8
- package/schemas/io/throttle.cos.yaml +9 -2
- package/source/Directive.test.ts +72 -7
- package/source/Directive.ts +15 -5
- package/source/HTTP/messages.ts +5 -1
- package/source/Interception.ts +8 -0
- package/source/RTD/Context.ts +3 -0
- package/source/RTD/Directives.ts +1 -1
- package/source/RTD/Tree.ts +17 -0
- package/source/RTD/factory.ts +11 -1
- package/source/directives/cors/CORS.ts +26 -9
- package/source/directives/io/Directive.ts +4 -2
- package/source/directives/io/IO.ts +18 -7
- package/source/directives/io/Throttle.ts +8 -6
- package/source/directives/io/lib/throttle/Configuration.test.ts +21 -0
- package/source/directives/io/lib/throttle/Configuration.ts +11 -4
- package/source/directives/io/lib/throttle/Keys.ts +12 -11
- package/source/directives/io/lib/throttle/Quotas.test.ts +207 -46
- package/source/directives/io/lib/throttle/Quotas.ts +59 -35
- package/source/directives/io/lib/throttle/components/Component.ts +2 -1
- package/source/directives/io/lib/throttle/components/Identity.ts +19 -0
- package/source/directives/io/lib/throttle/components/Route.ts +19 -0
- package/source/directives/io/lib/throttle/components/Segment.ts +19 -0
- package/source/directives/io/lib/throttle/components/index.ts +8 -2
- package/source/directives/io/schemas.test.ts +16 -0
- package/source/directives/octets/Octets.ts +1 -1
- package/source/directives/octets/workflows/Execution.ts +4 -2
- package/transpiled/Directive.d.ts +3 -1
- package/transpiled/Directive.js +12 -3
- package/transpiled/Directive.js.map +1 -1
- package/transpiled/HTTP/messages.js +4 -1
- package/transpiled/HTTP/messages.js.map +1 -1
- package/transpiled/Interception.d.ts +2 -0
- package/transpiled/Interception.js +4 -0
- package/transpiled/Interception.js.map +1 -1
- package/transpiled/RTD/Context.d.ts +2 -0
- package/transpiled/RTD/Directives.d.ts +1 -1
- package/transpiled/RTD/Tree.js +14 -0
- package/transpiled/RTD/Tree.js.map +1 -1
- package/transpiled/RTD/factory.js +8 -1
- package/transpiled/RTD/factory.js.map +1 -1
- package/transpiled/directives/cors/CORS.d.ts +8 -1
- package/transpiled/directives/cors/CORS.js +23 -9
- package/transpiled/directives/cors/CORS.js.map +1 -1
- package/transpiled/directives/io/Directive.d.ts +4 -2
- package/transpiled/directives/io/IO.d.ts +6 -3
- package/transpiled/directives/io/IO.js +11 -5
- package/transpiled/directives/io/IO.js.map +1 -1
- package/transpiled/directives/io/Throttle.d.ts +5 -3
- package/transpiled/directives/io/Throttle.js +6 -6
- package/transpiled/directives/io/Throttle.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Configuration.d.ts +6 -2
- package/transpiled/directives/io/lib/throttle/Configuration.js +5 -2
- package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Keys.d.ts +4 -3
- package/transpiled/directives/io/lib/throttle/Keys.js +7 -10
- package/transpiled/directives/io/lib/throttle/Keys.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Quotas.d.ts +27 -8
- package/transpiled/directives/io/lib/throttle/Quotas.js +47 -31
- package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/components/Component.d.ts +2 -1
- package/transpiled/directives/io/lib/throttle/components/Identity.d.ts +11 -0
- package/transpiled/directives/io/lib/throttle/components/Identity.js +16 -0
- package/transpiled/directives/io/lib/throttle/components/Identity.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/components/Route.d.ts +12 -0
- package/transpiled/directives/io/lib/throttle/components/Route.js +20 -0
- package/transpiled/directives/io/lib/throttle/components/Route.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/components/Segment.d.ts +9 -0
- package/transpiled/directives/io/lib/throttle/components/Segment.js +20 -0
- package/transpiled/directives/io/lib/throttle/components/Segment.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/components/index.d.ts +1 -1
- package/transpiled/directives/io/lib/throttle/components/index.js +7 -1
- package/transpiled/directives/io/lib/throttle/components/index.js.map +1 -1
- package/transpiled/directives/octets/Octets.js +1 -1
- package/transpiled/directives/octets/Octets.js.map +1 -1
- package/transpiled/directives/octets/workflows/Execution.js +3 -1
- package/transpiled/directives/octets/workflows/Execution.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
- package/features/auth.issue.feature +0 -32
- package/source/directives/io/lib/throttle/Interval.ts +0 -40
- package/source/directives/io/lib/throttle/Quota.ts +0 -22
- package/transpiled/directives/io/lib/throttle/Interval.d.ts +0 -10
- package/transpiled/directives/io/lib/throttle/Interval.js +0 -39
- package/transpiled/directives/io/lib/throttle/Interval.js.map +0 -1
- package/transpiled/directives/io/lib/throttle/Quota.d.ts +0 -8
- package/transpiled/directives/io/lib/throttle/Quota.js +0 -22
- package/transpiled/directives/io/lib/throttle/Quota.js.map +0 -1
- /package/components/{octets.storage → exposition.octets}/operations/delete.js +0 -0
- /package/components/{octets.storage → exposition.octets}/operations/get.js +0 -0
- /package/components/{octets.storage → exposition.octets}/operations/head.js +0 -0
- /package/components/{octets.storage → exposition.octets}/operations/put.js +0 -0
package/source/Directive.test.ts
CHANGED
|
@@ -14,6 +14,14 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
|
|
|
14
14
|
settle: jest.fn(),
|
|
15
15
|
dispose: jest.fn()
|
|
16
16
|
},
|
|
17
|
+
{
|
|
18
|
+
name: 'qux',
|
|
19
|
+
mandatory: true,
|
|
20
|
+
create: jest.fn((_0: any, _1: any, _2: any) => generate() as any),
|
|
21
|
+
preflight: jest.fn(),
|
|
22
|
+
settle: jest.fn(),
|
|
23
|
+
dispose: jest.fn()
|
|
24
|
+
},
|
|
17
25
|
{
|
|
18
26
|
name: 'bar',
|
|
19
27
|
mandatory: false,
|
|
@@ -29,11 +37,12 @@ let factory: DirectivesFactory
|
|
|
29
37
|
beforeEach(() => {
|
|
30
38
|
jest.clearAllMocks()
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
for (const family of families) {
|
|
41
|
+
assert.ok(family.preflight !== undefined)
|
|
42
|
+
|
|
43
|
+
family.preflight.mockImplementation(() => null)
|
|
44
|
+
}
|
|
34
45
|
|
|
35
|
-
families[0].preflight.mockImplementation(() => null)
|
|
36
|
-
families[1].preflight.mockImplementation(() => null)
|
|
37
46
|
factory = new DirectivesFactory(families, {} as unknown as Remotes)
|
|
38
47
|
})
|
|
39
48
|
|
|
@@ -53,12 +62,22 @@ it('should create directive', async () => {
|
|
|
53
62
|
|
|
54
63
|
factory.create(declarations)
|
|
55
64
|
|
|
56
|
-
for (
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
for (const declaration of declarations) {
|
|
66
|
+
const family = families.find(({ name }) => name === declaration.family)!
|
|
67
|
+
|
|
68
|
+
expect(family.create.mock.calls[0][0]).toBe(declaration.name)
|
|
69
|
+
expect(family.create.mock.calls[0][1]).toBe(declaration.value)
|
|
59
70
|
}
|
|
60
71
|
})
|
|
61
72
|
|
|
73
|
+
it('should pass the route to the families', async () => {
|
|
74
|
+
const route = '/' + generate()
|
|
75
|
+
|
|
76
|
+
factory.create([{ family: 'foo', name: generate(), value: generate() }], route)
|
|
77
|
+
|
|
78
|
+
expect(families[0].create.mock.calls[0][3]).toBe(route)
|
|
79
|
+
})
|
|
80
|
+
|
|
62
81
|
it('should throw error if directive family is not found', async () => {
|
|
63
82
|
const declaration: syntax.Directive = {
|
|
64
83
|
family: generate(),
|
|
@@ -97,3 +116,49 @@ it('should apply mandatory families', async () => {
|
|
|
97
116
|
|
|
98
117
|
expect(families[0].preflight).toHaveBeenCalled()
|
|
99
118
|
})
|
|
119
|
+
|
|
120
|
+
describe('order', () => {
|
|
121
|
+
function order (): string[] {
|
|
122
|
+
return families
|
|
123
|
+
.filter(({ preflight }) => preflight!.mock.calls.length > 0)
|
|
124
|
+
.sort((a, b) => a.preflight!.mock.invocationCallOrder[0] -
|
|
125
|
+
b.preflight!.mock.invocationCallOrder[0])
|
|
126
|
+
.map(({ name }) => name)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it('should run mandatory families in their own order, whatever a manifest says', async () => {
|
|
130
|
+
// `qux` declared first, but `foo` is registered first and both are mandatory
|
|
131
|
+
const directives = factory.create([
|
|
132
|
+
{ family: 'qux', name: generate(), value: generate() },
|
|
133
|
+
{ family: 'foo', name: generate(), value: generate() }
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
await directives.preflight(generate() as unknown as Context, [])
|
|
137
|
+
|
|
138
|
+
expect(order()).toStrictEqual(['foo', 'qux'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should run a mandatory family before a declared one it is not declared with',
|
|
142
|
+
async () => {
|
|
143
|
+
// only `qux` is declared: `foo` still has to run first, not merely first of
|
|
144
|
+
// whatever had no declarations
|
|
145
|
+
const directives = factory.create([
|
|
146
|
+
{ family: 'qux', name: generate(), value: generate() }
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
await directives.preflight(generate() as unknown as Context, [])
|
|
150
|
+
|
|
151
|
+
expect(order()).toStrictEqual(['foo', 'qux'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should run mandatory families before the rest', async () => {
|
|
155
|
+
const directives = factory.create([
|
|
156
|
+
{ family: 'bar', name: generate(), value: generate() },
|
|
157
|
+
{ family: 'qux', name: generate(), value: generate() }
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
await directives.preflight(generate() as unknown as Context, [])
|
|
161
|
+
|
|
162
|
+
expect(order()).toStrictEqual(['foo', 'qux', 'bar'])
|
|
163
|
+
})
|
|
164
|
+
})
|
package/source/Directive.ts
CHANGED
|
@@ -63,13 +63,10 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
|
|
|
63
63
|
this.remotes = remotes
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
public create (declarations: RTD.syntax.Directive[]): Directives {
|
|
66
|
+
public create (declarations: RTD.syntax.Directive[], route: string = ''): Directives {
|
|
67
67
|
const groups: Record<string, any> = {}
|
|
68
68
|
const mandatory = new Set(this.mandatory)
|
|
69
69
|
|
|
70
|
-
declarations.sort((a, b) =>
|
|
71
|
-
(mandatory.has(b.family) ? 1 : 0) - (mandatory.has(a.family) ? 1 : 0))
|
|
72
|
-
|
|
73
70
|
const names: Record<string, string[]> = {}
|
|
74
71
|
|
|
75
72
|
for (const declaration of declarations) {
|
|
@@ -78,7 +75,7 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
|
|
|
78
75
|
if (family === undefined)
|
|
79
76
|
throw new Error(`Directive family '${declaration.family}' is not found`)
|
|
80
77
|
|
|
81
|
-
const directive = family.create(declaration.name, declaration.value, this.remotes)
|
|
78
|
+
const directive = family.create(declaration.name, declaration.value, this.remotes, route)
|
|
82
79
|
|
|
83
80
|
groups[family.name] ??= []
|
|
84
81
|
groups[family.name].push(directive)
|
|
@@ -103,6 +100,12 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
|
|
|
103
100
|
names: names[family]
|
|
104
101
|
})
|
|
105
102
|
|
|
103
|
+
// Mandatory families run in the order they are registered in, not in the order a
|
|
104
|
+
// manifest happens to mention them: `auth` must resolve the identity before `io`
|
|
105
|
+
// can key a quota on it, and a request it denies should not reach `io` at all.
|
|
106
|
+
// The rest keep the order they were declared in, the sort being stable.
|
|
107
|
+
sets.sort((a, b) => this.rank(a.family.name) - this.rank(b.family.name))
|
|
108
|
+
|
|
106
109
|
const directives = new Directives(sets)
|
|
107
110
|
|
|
108
111
|
this.instances.push(directives)
|
|
@@ -114,6 +117,13 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
|
|
|
114
117
|
for (const directives of this.instances)
|
|
115
118
|
directives.dispose()
|
|
116
119
|
}
|
|
120
|
+
|
|
121
|
+
/** Mandatory families first, in their own order; everything else keeps its own. */
|
|
122
|
+
private rank (family: string): number {
|
|
123
|
+
const index = this.mandatory.indexOf(family)
|
|
124
|
+
|
|
125
|
+
return index === -1 ? this.mandatory.length : index
|
|
126
|
+
}
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
function options (set: RTD.DirectiveSet, stage: 'preflight' | 'settle'): SpanOptions {
|
package/source/HTTP/messages.ts
CHANGED
|
@@ -66,7 +66,11 @@ export async function read (context: Context): Promise<any> {
|
|
|
66
66
|
|
|
67
67
|
function send (message: OutgoingMessage, context: Context, response: http.ServerResponse): void {
|
|
68
68
|
if (message.body === undefined || message.body === null) {
|
|
69
|
-
|
|
69
|
+
// a HEAD reply carries no body but must still report the length a GET would
|
|
70
|
+
// have returned, so a length already set by a directive is left alone
|
|
71
|
+
if (!response.hasHeader('content-length'))
|
|
72
|
+
response.setHeader('content-length', '0')
|
|
73
|
+
|
|
70
74
|
response.end()
|
|
71
75
|
|
|
72
76
|
return
|
package/source/Interception.ts
CHANGED
|
@@ -5,6 +5,11 @@ export class Interception implements Interceptor {
|
|
|
5
5
|
|
|
6
6
|
public constructor (interceptors: Interceptor[]) {
|
|
7
7
|
this.interceptors = interceptors
|
|
8
|
+
|
|
9
|
+
// interceptors are module singletons, so a second gateway in one process —
|
|
10
|
+
// which is how the features run — would otherwise inherit the first one's state
|
|
11
|
+
for (const interceptor of interceptors)
|
|
12
|
+
interceptor.reset?.()
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
public async intercept (input: Input): Promise<Output> {
|
|
@@ -21,4 +26,7 @@ export class Interception implements Interceptor {
|
|
|
21
26
|
|
|
22
27
|
export interface Interceptor {
|
|
23
28
|
intercept: (input: Input) => Output | Promise<Output>
|
|
29
|
+
|
|
30
|
+
/** Discards whatever the interceptor accumulated while serving. */
|
|
31
|
+
reset?: () => void
|
|
24
32
|
}
|
package/source/RTD/Context.ts
CHANGED
package/source/RTD/Directives.ts
CHANGED
package/source/RTD/Tree.ts
CHANGED
|
@@ -61,6 +61,7 @@ export class Tree {
|
|
|
61
61
|
factory: this.directives,
|
|
62
62
|
stack: this.root.directives ?? []
|
|
63
63
|
},
|
|
64
|
+
path: label(extension),
|
|
64
65
|
extension
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -68,4 +69,20 @@ export class Tree {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A branch's routes are relative to wherever it is merged, and the mount point is not
|
|
74
|
+
* known while it is being built — so the component it came from is what keeps two
|
|
75
|
+
* branches from looking like the same route.
|
|
76
|
+
*/
|
|
77
|
+
function label (extension: unknown): string {
|
|
78
|
+
if (extension === null || typeof extension !== 'object')
|
|
79
|
+
return ''
|
|
80
|
+
|
|
81
|
+
const { namespace, component } = extension as Record<string, unknown>
|
|
82
|
+
|
|
83
|
+
return typeof namespace === 'string' && typeof component === 'string'
|
|
84
|
+
? `${namespace}.${component}`
|
|
85
|
+
: ''
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
const PROTECTED = true
|
package/source/RTD/factory.ts
CHANGED
|
@@ -42,17 +42,27 @@ export function branchTTL (): number {
|
|
|
42
42
|
|
|
43
43
|
function createRoute (route: syntax.Route, context: Context): Route {
|
|
44
44
|
const stack = context.directives.stack.slice()
|
|
45
|
+
const path = context.path
|
|
45
46
|
const segments = segment(route.path)
|
|
47
|
+
|
|
48
|
+
context.path = join(path, route.path)
|
|
49
|
+
|
|
46
50
|
const node = createNode(route.node, context)
|
|
47
51
|
|
|
48
52
|
context.directives.stack = stack // restore
|
|
53
|
+
context.path = path
|
|
49
54
|
|
|
50
55
|
return new Route(segments, node)
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
function join (base: string, path: string): string {
|
|
59
|
+
// '/one/' and '/two/' would otherwise read as '/one//two/'
|
|
60
|
+
return (base + path).replace(/\/+/g, '/').replace(/(.)\/$/, '$1')
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
function createMethod (method: syntax.Method, context: Context): Method {
|
|
54
64
|
const stack = method.directives.concat(context.directives.stack)
|
|
55
|
-
const directives = context.directives.factory.create(stack)
|
|
65
|
+
const directives = context.directives.factory.create(stack, context.path)
|
|
56
66
|
|
|
57
67
|
const endpoint = method.mapping?.endpoint === undefined
|
|
58
68
|
? null
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import type { Input, Output } from '../../io'
|
|
2
2
|
import type { Interceptor } from '../../Interception'
|
|
3
3
|
|
|
4
|
+
/** What is allowed before any directive asks for more. */
|
|
5
|
+
const REQUEST_HEADERS = [
|
|
6
|
+
'accept',
|
|
7
|
+
'authorization',
|
|
8
|
+
'content-type',
|
|
9
|
+
'if-match',
|
|
10
|
+
'if-none-match'
|
|
11
|
+
]
|
|
12
|
+
|
|
4
13
|
export class CORS implements Interceptor {
|
|
5
14
|
public readonly name = 'cors'
|
|
6
15
|
|
|
7
|
-
private
|
|
8
|
-
'accept',
|
|
9
|
-
'authorization',
|
|
10
|
-
'content-type',
|
|
11
|
-
'if-match',
|
|
12
|
-
'if-none-match'
|
|
13
|
-
])
|
|
16
|
+
private requestHeaders = new Set<string>(REQUEST_HEADERS)
|
|
14
17
|
|
|
15
18
|
private readonly headers = new Headers({
|
|
16
19
|
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, LOCK, UNLOCK',
|
|
17
20
|
'access-control-allow-credentials': 'true',
|
|
18
|
-
'access-control-allow-headers':
|
|
21
|
+
'access-control-allow-headers': this.allowedHeaders(),
|
|
19
22
|
'access-control-max-age': '3600',
|
|
20
23
|
'cache-control': 'max-age=3600',
|
|
21
24
|
vary: 'origin'
|
|
@@ -46,9 +49,23 @@ export class CORS implements Interceptor {
|
|
|
46
49
|
return null
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
public reset (): void {
|
|
53
|
+
this.requestHeaders = new Set(REQUEST_HEADERS)
|
|
54
|
+
this.headers.set('access-control-allow-headers', this.allowedHeaders())
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
public allow (header: string): void {
|
|
50
58
|
this.requestHeaders.add(header.toLowerCase())
|
|
51
|
-
this.headers.set('access-control-allow-headers',
|
|
59
|
+
this.headers.set('access-control-allow-headers', this.allowedHeaders())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sorted, because the set is filled as branches merge and their order is not
|
|
64
|
+
* fixed — an unsorted value would differ between otherwise identical processes,
|
|
65
|
+
* and between one restart and the next.
|
|
66
|
+
*/
|
|
67
|
+
private allowedHeaders (): string {
|
|
68
|
+
return Array.from(this.requestHeaders).sort().join(', ')
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
private preflightResponse (origin: string): Output {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { Remote } from '@toa.io/core'
|
|
2
|
+
import type { Parameter } from '../../RTD'
|
|
1
3
|
import type { Input as Context } from '../../io'
|
|
2
4
|
import type * as http from '../../HTTP'
|
|
3
5
|
|
|
4
6
|
export interface Directive {
|
|
5
|
-
preflight: (context: Context) => void
|
|
7
|
+
preflight: (context: Context, parameters: Parameter[]) => void
|
|
6
8
|
settle?: (context: Context, response: http.OutgoingMessage) => Promise<void> | void
|
|
7
9
|
dispose?: () => void
|
|
8
10
|
}
|
|
@@ -10,5 +12,5 @@ export interface Directive {
|
|
|
10
12
|
export interface Constructor {
|
|
11
13
|
validate: (value: unknown) => void
|
|
12
14
|
|
|
13
|
-
new (value: any): Directive
|
|
15
|
+
new (value: any, counter: Promise<Remote>, route: string): Directive
|
|
14
16
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { Output } from './Output'
|
|
2
2
|
import { Input } from './Input'
|
|
3
3
|
import { Throttle } from './Throttle'
|
|
4
|
+
import type { Remote } from '@toa.io/core'
|
|
4
5
|
import type * as http from '../../HTTP'
|
|
6
|
+
import type { Parameter, DirectiveFamily } from '../../RTD'
|
|
7
|
+
import type { Remotes } from '../../Remotes'
|
|
5
8
|
import type { Constructor, Directive } from './Directive'
|
|
6
|
-
import type { DirectiveFamily } from '../../RTD'
|
|
7
9
|
|
|
8
10
|
export class IO implements DirectiveFamily<Directive> {
|
|
9
11
|
public readonly name = 'io'
|
|
10
12
|
public readonly mandatory = true
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
/** Throttling counts through a component, because only a component has a stash aspect. */
|
|
15
|
+
private counter: Promise<Remote> | null = null
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line max-params
|
|
18
|
+
public create (name: string, value: unknown, remotes: Remotes, route: string): Directive {
|
|
13
19
|
if (!(name in constructors))
|
|
14
20
|
throw new Error(`Directive 'io:${name}' is not implemented`)
|
|
15
21
|
|
|
@@ -17,20 +23,25 @@ export class IO implements DirectiveFamily<Directive> {
|
|
|
17
23
|
|
|
18
24
|
Directive.validate(value)
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
// discovering boots the component, so nothing is discovered until something throttles
|
|
27
|
+
if (name === 'throttle')
|
|
28
|
+
this.counter ??= remotes.discover('exposition', 'stash')
|
|
29
|
+
|
|
30
|
+
return new Directive(value, this.counter!, route)
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
public preflight (directives: Directive[], context: http.Context
|
|
33
|
+
public preflight (directives: Directive[], context: http.Context,
|
|
34
|
+
parameters: Parameter[]): null {
|
|
24
35
|
let restricted = false
|
|
25
36
|
|
|
26
37
|
for (const directive of directives) {
|
|
27
38
|
restricted ||= directive instanceof Output
|
|
28
39
|
|
|
29
|
-
directive.preflight(context)
|
|
40
|
+
directive.preflight(context, parameters)
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
if (!restricted)
|
|
33
|
-
DENIAL.preflight(context)
|
|
44
|
+
DENIAL.preflight(context, parameters)
|
|
34
45
|
|
|
35
46
|
return null
|
|
36
47
|
}
|
|
@@ -52,4 +63,4 @@ const constructors: Record<string, Constructor> = {
|
|
|
52
63
|
throttle: Throttle
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
const DENIAL:
|
|
66
|
+
const DENIAL: Directive = new Output([])
|
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import { TooManyRequests } from '../../HTTP'
|
|
2
2
|
import * as schemas from './schemas'
|
|
3
3
|
import { parse, Quotas, type Declaration } from './lib/throttle'
|
|
4
|
+
import type { Remote } from '@toa.io/core'
|
|
4
5
|
import type * as http from '../../HTTP'
|
|
6
|
+
import type { Parameter } from '../../RTD'
|
|
5
7
|
import type { Directive } from './Directive'
|
|
6
8
|
|
|
7
9
|
export class Throttle implements Directive {
|
|
8
10
|
private readonly quotas: Quotas
|
|
9
11
|
|
|
10
|
-
public constructor (declaration: Declaration) {
|
|
12
|
+
public constructor (declaration: Declaration, counter: Promise<Remote>, route: string) {
|
|
11
13
|
const configuration = parse(declaration)
|
|
12
14
|
|
|
13
|
-
this.quotas = Quotas.create(configuration)
|
|
15
|
+
this.quotas = Quotas.create(configuration, counter, route)
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
public static validate (declaration: unknown): asserts declaration is Declaration {
|
|
17
19
|
schemas.throttle.validate(declaration, 'Incorrect \'io:throttle\' format')
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
public preflight (context: http.Context): void {
|
|
21
|
-
if (!this.quotas.ok(context))
|
|
22
|
+
public preflight (context: http.Context, parameters: Parameter[]): void {
|
|
23
|
+
if (!this.quotas.ok(context, parameters))
|
|
22
24
|
throw new TooManyRequests()
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
public settle (context: http.Context, output: http.OutgoingMessage): void {
|
|
26
|
-
this.quotas.use(context, output)
|
|
27
|
+
public async settle (context: http.Context, output: http.OutgoingMessage): Promise<void> {
|
|
28
|
+
await this.quotas.use(context, output)
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
public dispose (): void {
|
|
@@ -38,3 +38,24 @@ it('should convert condition', () => {
|
|
|
38
38
|
]
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
|
+
|
|
42
|
+
it('should convert a key component that takes an argument', () => {
|
|
43
|
+
expect(parse({ key: { segment: 'id' }, ...rest })).toMatchObject({
|
|
44
|
+
key: [
|
|
45
|
+
{
|
|
46
|
+
method: 'segment',
|
|
47
|
+
options: 'id'
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should convert a mixed key', () => {
|
|
54
|
+
expect(parse({ key: ['route', { segment: 'id' }, 'identity'], ...rest })).toMatchObject({
|
|
55
|
+
key: [
|
|
56
|
+
{ method: 'route' },
|
|
57
|
+
{ method: 'segment', options: 'id' },
|
|
58
|
+
{ method: 'identity' }
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -11,13 +11,15 @@ interface Rule<T, K = unknown> {
|
|
|
11
11
|
options?: K
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export type KeyComponentMethod = 'ip' | 'path'
|
|
14
|
+
export type KeyComponentMethod = 'ip' | 'path' | 'route' | 'identity' | 'segment'
|
|
15
15
|
export type KeyConditionMethod = 'status'
|
|
16
16
|
|
|
17
17
|
export type KeyComponent = Rule<KeyComponentMethod>
|
|
18
18
|
export type KeyCondition = Rule<KeyConditionMethod>
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** A bare component, or the one that takes an argument. */
|
|
21
|
+
type KeyEntry = KeyComponentMethod | { segment: string }
|
|
22
|
+
type KeyDeclaration = KeyEntry | KeyEntry[]
|
|
21
23
|
|
|
22
24
|
type ConditionDeclaration =
|
|
23
25
|
Record<KeyConditionMethod, unknown>
|
|
@@ -41,9 +43,14 @@ export function parse (declaration: Declaration): Configuration {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
function mapKey (declaration: KeyDeclaration): KeyComponent[] {
|
|
44
|
-
const
|
|
46
|
+
const entries = Array.isArray(declaration) ? declaration : [declaration]
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
// a bare `path`, or `segment: id` for the ones that take an argument
|
|
49
|
+
return entries.flatMap((entry): KeyComponent[] =>
|
|
50
|
+
typeof entry === 'string'
|
|
51
|
+
? [{ method: entry }]
|
|
52
|
+
: Object.entries(entry).map(([method, options]) =>
|
|
53
|
+
({ method: method as KeyComponentMethod, options })))
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
function mapCondition (declaration?: ConditionDeclaration): KeyCondition[] | undefined {
|
|
@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { Components, type Component } from './components'
|
|
3
3
|
import { Conditions, type Condition } from './conditions'
|
|
4
4
|
import type { KeyComponent, KeyCondition } from './Configuration'
|
|
5
|
+
import type { Parameter } from '../../../../RTD'
|
|
5
6
|
import type { Input as Context, Output } from '../../../../io'
|
|
6
7
|
|
|
8
|
+
const NONE: Parameter[] = []
|
|
9
|
+
|
|
7
10
|
export class Keys {
|
|
8
11
|
private readonly components: Component[]
|
|
9
12
|
private readonly conditions?: Condition[]
|
|
@@ -13,28 +16,26 @@ export class Keys {
|
|
|
13
16
|
this.conditions = conditions
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
public static create (componentRules: KeyComponent[], conditionRules?: KeyCondition[]
|
|
17
|
-
|
|
19
|
+
public static create (componentRules: KeyComponent[], conditionRules?: KeyCondition[],
|
|
20
|
+
route: string = ''): Keys {
|
|
21
|
+
const components = componentRules.map((rule) =>
|
|
22
|
+
new Components[rule.method](rule.options, route))
|
|
23
|
+
|
|
18
24
|
const conditions = conditionRules?.map((rule) => new Conditions[rule.method](rule.options))
|
|
19
25
|
|
|
20
26
|
return new this(components, conditions)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
public get (context: Context): string {
|
|
29
|
+
public get (context: Context, parameters: Parameter[] = NONE): string {
|
|
24
30
|
const hash = createHash('sha256')
|
|
25
31
|
|
|
26
32
|
for (const component of this.components)
|
|
27
|
-
hash.update(component.get(context))
|
|
33
|
+
hash.update(component.get(context, parameters))
|
|
28
34
|
|
|
29
35
|
return hash.digest('hex')
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
public
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (miss === true)
|
|
36
|
-
return null
|
|
37
|
-
else
|
|
38
|
-
return this.get(input)
|
|
38
|
+
public matches (input: Context, output: Output): boolean {
|
|
39
|
+
return this.conditions?.some((condition) => !condition.match(input, output)) !== true
|
|
39
40
|
}
|
|
40
41
|
}
|