@fastify/reply-from 9.7.0 → 9.8.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.
- package/README.md +15 -3
- package/index.js +5 -5
- package/lib/request.js +16 -4
- package/package.json +5 -5
- package/test/full-delete-http2.test.js +50 -0
- package/test/full-querystring-rewrite-option-function-request.test.js +56 -0
- package/test/undici-proxy-agent.test.js +79 -0
- package/types/index.d.ts +8 -2
- package/types/index.test-d.ts +14 -3
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ proxy.register(require('@fastify/reply-from'), {
|
|
|
82
82
|
|
|
83
83
|
#### `undici`
|
|
84
84
|
|
|
85
|
-
By default, [undici](https://github.com/
|
|
85
|
+
By default, [undici](https://github.com/nodejs/undici) will be used to perform the HTTP/1.1
|
|
86
86
|
requests. Enabling this flag should guarantee
|
|
87
87
|
20-50% more throughput.
|
|
88
88
|
|
|
@@ -102,6 +102,18 @@ proxy.register(require('@fastify/reply-from'), {
|
|
|
102
102
|
}
|
|
103
103
|
})
|
|
104
104
|
```
|
|
105
|
+
|
|
106
|
+
You can also include a proxy for the undici client:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
proxy.register(require('@fastify/reply-from'), {
|
|
110
|
+
base: 'http://localhost:3001/',
|
|
111
|
+
undici: {
|
|
112
|
+
proxy: 'http://my.proxy.server:8080',
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
105
117
|
See undici own options for more configurations.
|
|
106
118
|
|
|
107
119
|
You can also pass the plugin a custom instance:
|
|
@@ -325,7 +337,7 @@ const customRetryLogic = ({req, res, err, getDefaultRetry}: RetryDetails) => {
|
|
|
325
337
|
### `reply.from(source, [opts])`
|
|
326
338
|
|
|
327
339
|
The plugin decorates the
|
|
328
|
-
[`Reply`](https://
|
|
340
|
+
[`Reply`](https://fastify.dev/docs/latest/Reference/Reply)
|
|
329
341
|
instance with a `from` method, which will reply to the original request
|
|
330
342
|
__from the desired source__. The options allows to override any part of
|
|
331
343
|
the request or response being sent or received to/from the source.
|
|
@@ -428,7 +440,7 @@ server.register(fastifyHttpProxy, {
|
|
|
428
440
|
});
|
|
429
441
|
```
|
|
430
442
|
|
|
431
|
-
#### `queryString` or `queryString(search, reqUrl)`
|
|
443
|
+
#### `queryString` or `queryString(search, reqUrl, request)`
|
|
432
444
|
|
|
433
445
|
Replaces the original querystring of the request with what is specified.
|
|
434
446
|
This will be passed to
|
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const fp = require('fastify-plugin')
|
|
4
|
-
const {
|
|
4
|
+
const { LruMap } = require('toad-cache')
|
|
5
5
|
const querystring = require('fast-querystring')
|
|
6
6
|
const fastContentTypeParse = require('fast-content-type-parse')
|
|
7
7
|
const Stream = require('node:stream')
|
|
@@ -32,7 +32,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
|
|
|
32
32
|
const retryMethods = new Set(opts.retryMethods || [
|
|
33
33
|
'GET', 'HEAD', 'OPTIONS', 'TRACE'])
|
|
34
34
|
|
|
35
|
-
const cache = opts.disableCache ? undefined :
|
|
35
|
+
const cache = opts.disableCache ? undefined : new LruMap(opts.cacheURLs || 100)
|
|
36
36
|
const base = opts.base
|
|
37
37
|
const requestBuilt = buildRequest({
|
|
38
38
|
http: opts.http,
|
|
@@ -80,7 +80,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
|
|
|
80
80
|
const sourceHttp2 = req.httpVersionMajor === 2
|
|
81
81
|
const headers = sourceHttp2 ? filterPseudoHeaders(req.headers) : { ...req.headers }
|
|
82
82
|
headers.host = url.host
|
|
83
|
-
const qs = getQueryString(url.search, req.url, opts)
|
|
83
|
+
const qs = getQueryString(url.search, req.url, opts, this.request)
|
|
84
84
|
let body = ''
|
|
85
85
|
|
|
86
86
|
if (opts.body !== undefined) {
|
|
@@ -227,9 +227,9 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
|
|
|
227
227
|
name: '@fastify/reply-from'
|
|
228
228
|
})
|
|
229
229
|
|
|
230
|
-
function getQueryString (search, reqUrl, opts) {
|
|
230
|
+
function getQueryString (search, reqUrl, opts, request) {
|
|
231
231
|
if (typeof opts.queryString === 'function') {
|
|
232
|
-
return '?' + opts.queryString(search, reqUrl)
|
|
232
|
+
return '?' + opts.queryString(search, reqUrl, request)
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
if (opts.queryString) {
|
package/lib/request.js
CHANGED
|
@@ -3,7 +3,7 @@ const http = require('node:http')
|
|
|
3
3
|
const https = require('node:https')
|
|
4
4
|
const querystring = require('node:querystring')
|
|
5
5
|
const eos = require('end-of-stream')
|
|
6
|
-
const
|
|
6
|
+
const { pipeline } = require('node:stream')
|
|
7
7
|
const undici = require('undici')
|
|
8
8
|
const { stripHttp1ConnectionHeaders } = require('./utils')
|
|
9
9
|
const http2 = require('node:http2')
|
|
@@ -83,7 +83,11 @@ function buildRequest (opts) {
|
|
|
83
83
|
} else if (isUndiciInstance(opts.undici)) {
|
|
84
84
|
undiciInstance = opts.undici
|
|
85
85
|
} else if (!globalAgent) {
|
|
86
|
-
|
|
86
|
+
if (undiciOpts.proxy) {
|
|
87
|
+
undiciAgent = new undici.ProxyAgent(getUndiciProxyOptions(opts.undici))
|
|
88
|
+
} else {
|
|
89
|
+
undiciAgent = new undici.Agent(getUndiciOptions(opts.undici))
|
|
90
|
+
}
|
|
87
91
|
} else {
|
|
88
92
|
undiciAgent = undici.getGlobalDispatcher()
|
|
89
93
|
}
|
|
@@ -204,7 +208,8 @@ function buildRequest (opts) {
|
|
|
204
208
|
...stripHttp1ConnectionHeaders(opts.headers)
|
|
205
209
|
}, http2Opts.requestOptions)
|
|
206
210
|
const isGet = opts.method === 'GET' || opts.method === 'get'
|
|
207
|
-
|
|
211
|
+
const isDelete = opts.method === 'DELETE' || opts.method === 'delete'
|
|
212
|
+
if (!isGet && !isDelete) {
|
|
208
213
|
end(req, opts.body, done)
|
|
209
214
|
}
|
|
210
215
|
req.setTimeout(http2Opts.requestTimeout, () => {
|
|
@@ -248,7 +253,7 @@ function end (req, body, cb) {
|
|
|
248
253
|
if (!body || typeof body === 'string' || body instanceof Uint8Array) {
|
|
249
254
|
req.end(body)
|
|
250
255
|
} else if (body.pipe) {
|
|
251
|
-
|
|
256
|
+
pipeline(body, req, err => {
|
|
252
257
|
if (err) cb(err)
|
|
253
258
|
})
|
|
254
259
|
} else {
|
|
@@ -303,6 +308,13 @@ function getAgentOptions (opts) {
|
|
|
303
308
|
}
|
|
304
309
|
}
|
|
305
310
|
|
|
311
|
+
function getUndiciProxyOptions ({ proxy, ...opts }) {
|
|
312
|
+
if (typeof proxy === 'string' || proxy instanceof URL) {
|
|
313
|
+
return getUndiciOptions({ uri: proxy, ...opts })
|
|
314
|
+
}
|
|
315
|
+
return getUndiciOptions({ ...proxy, ...opts })
|
|
316
|
+
}
|
|
317
|
+
|
|
306
318
|
function getUndiciOptions (opts = {}) {
|
|
307
319
|
const res = {
|
|
308
320
|
pipelining: 1,
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastify/reply-from",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.8.0",
|
|
4
4
|
"description": "forward your HTTP request to another server, for fastify",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"types": "types/index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"lint": "standard | snazzy",
|
|
10
|
-
"lint:fix": "standard --fix",
|
|
10
|
+
"lint:fix": "standard --fix | snazzy",
|
|
11
11
|
"test": "npm run test:unit && npm run test:typescript",
|
|
12
12
|
"test:unit": "tap",
|
|
13
13
|
"test:typescript": "tsd"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"h2url": "^0.2.0",
|
|
42
42
|
"msgpack5": "^6.0.1",
|
|
43
43
|
"nock": "^13.2.6",
|
|
44
|
+
"proxy": "^2.1.1",
|
|
44
45
|
"proxyquire": "^2.1.3",
|
|
45
46
|
"semver": "^7.5.1",
|
|
46
47
|
"simple-get": "^4.0.1",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"split2": "^4.1.0",
|
|
49
50
|
"standard": "^17.0.0",
|
|
50
51
|
"tap": "^16.2.0",
|
|
51
|
-
"tsd": "^0.
|
|
52
|
+
"tsd": "^0.31.0"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
55
|
"@fastify/error": "^3.0.0",
|
|
@@ -56,8 +57,7 @@
|
|
|
56
57
|
"fast-content-type-parse": "^1.1.0",
|
|
57
58
|
"fast-querystring": "^1.0.0",
|
|
58
59
|
"fastify-plugin": "^4.0.0",
|
|
59
|
-
"
|
|
60
|
-
"tiny-lru": "^11.0.0",
|
|
60
|
+
"toad-cache": "^3.7.0",
|
|
61
61
|
"undici": "^5.19.1"
|
|
62
62
|
},
|
|
63
63
|
"pre-commit": [
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { test } = require('tap')
|
|
4
|
+
const Fastify = require('fastify')
|
|
5
|
+
const From = require('..')
|
|
6
|
+
const got = require('got')
|
|
7
|
+
|
|
8
|
+
test('http -> http2', async function (t) {
|
|
9
|
+
const instance = Fastify()
|
|
10
|
+
|
|
11
|
+
t.teardown(instance.close.bind(instance))
|
|
12
|
+
|
|
13
|
+
const target = Fastify({
|
|
14
|
+
http2: true
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
target.delete('/', (request, reply) => {
|
|
18
|
+
t.pass('request proxied')
|
|
19
|
+
reply.code(200).header('x-my-header', 'hello!').send({
|
|
20
|
+
hello: 'world'
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
instance.delete('/', (request, reply) => {
|
|
25
|
+
reply.from()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
t.teardown(target.close.bind(target))
|
|
29
|
+
|
|
30
|
+
await target.listen({ port: 0 })
|
|
31
|
+
|
|
32
|
+
instance.register(From, {
|
|
33
|
+
base: `http://localhost:${target.server.address().port}`,
|
|
34
|
+
http2: true
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
await instance.listen({ port: 0 })
|
|
38
|
+
|
|
39
|
+
const { headers, body, statusCode } = await got(
|
|
40
|
+
`http://localhost:${instance.server.address().port}`,
|
|
41
|
+
{
|
|
42
|
+
method: 'DELETE',
|
|
43
|
+
responseType: 'json'
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
t.equal(statusCode, 200)
|
|
47
|
+
t.equal(headers['x-my-header'], 'hello!')
|
|
48
|
+
t.match(headers['content-type'], /application\/json/)
|
|
49
|
+
t.same(body, { hello: 'world' })
|
|
50
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const t = require('tap')
|
|
4
|
+
const Fastify = require('fastify')
|
|
5
|
+
const From = require('..')
|
|
6
|
+
const http = require('node:http')
|
|
7
|
+
const get = require('simple-get').concat
|
|
8
|
+
const querystring = require('node:querystring')
|
|
9
|
+
|
|
10
|
+
const instance = Fastify()
|
|
11
|
+
|
|
12
|
+
instance.addHook('preHandler', (request, reply, done) => {
|
|
13
|
+
request.addedVal = 'test'
|
|
14
|
+
done()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
t.plan(10)
|
|
18
|
+
t.teardown(instance.close.bind(instance))
|
|
19
|
+
|
|
20
|
+
const target = http.createServer((req, res) => {
|
|
21
|
+
t.pass('request proxied')
|
|
22
|
+
t.equal(req.method, 'GET')
|
|
23
|
+
t.equal(req.url, '/world?q=test')
|
|
24
|
+
res.statusCode = 205
|
|
25
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
26
|
+
res.setHeader('x-my-header', 'hello!')
|
|
27
|
+
res.end('hello world')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
instance.get('/hello', (request, reply) => {
|
|
31
|
+
reply.from(`http://localhost:${target.address().port}/world?a=b`, {
|
|
32
|
+
queryString (search, reqUrl, request) {
|
|
33
|
+
return querystring.stringify({ q: request.addedVal })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
t.teardown(target.close.bind(target))
|
|
39
|
+
|
|
40
|
+
target.listen({ port: 0 }, (err) => {
|
|
41
|
+
t.error(err)
|
|
42
|
+
|
|
43
|
+
instance.register(From)
|
|
44
|
+
|
|
45
|
+
instance.listen({ port: 0 }, (err) => {
|
|
46
|
+
t.error(err)
|
|
47
|
+
|
|
48
|
+
get(`http://localhost:${instance.server.address().port}/hello?a=b`, (err, res, data) => {
|
|
49
|
+
t.error(err)
|
|
50
|
+
t.equal(res.headers['content-type'], 'text/plain')
|
|
51
|
+
t.equal(res.headers['x-my-header'], 'hello!')
|
|
52
|
+
t.equal(res.statusCode, 205)
|
|
53
|
+
t.equal(data.toString(), 'hello world')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { test } = require('tap')
|
|
4
|
+
const { createServer } = require('node:http')
|
|
5
|
+
const Fastify = require('fastify')
|
|
6
|
+
const get = require('simple-get').concat
|
|
7
|
+
const { createProxy } = require('proxy')
|
|
8
|
+
const From = require('..')
|
|
9
|
+
|
|
10
|
+
const configFormat = {
|
|
11
|
+
string: (value) => value,
|
|
12
|
+
'url instance': (value) => new URL(value),
|
|
13
|
+
object: (value) => ({ uri: value })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const [description, format] of Object.entries(configFormat)) {
|
|
17
|
+
test(`use undici ProxyAgent to connect through proxy - configured via ${description}`, async (t) => {
|
|
18
|
+
t.plan(5)
|
|
19
|
+
const target = await buildServer()
|
|
20
|
+
const proxy = await buildProxy()
|
|
21
|
+
t.teardown(target.close.bind(target))
|
|
22
|
+
t.teardown(proxy.close.bind(proxy))
|
|
23
|
+
|
|
24
|
+
const targetUrl = `http://localhost:${target.address().port}`
|
|
25
|
+
const proxyUrl = `http://localhost:${proxy.address().port}`
|
|
26
|
+
|
|
27
|
+
proxy.on('connect', () => {
|
|
28
|
+
t.ok(true, 'should connect to proxy')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
target.on('request', (req, res) => {
|
|
32
|
+
res.setHeader('content-type', 'application/json')
|
|
33
|
+
res.end(JSON.stringify({ hello: 'world' }))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const instance = Fastify()
|
|
37
|
+
t.teardown(instance.close.bind(instance))
|
|
38
|
+
|
|
39
|
+
instance.register(From, {
|
|
40
|
+
base: targetUrl,
|
|
41
|
+
undici: {
|
|
42
|
+
proxy: format(proxyUrl)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
instance.get('/', (request, reply) => {
|
|
47
|
+
reply.from()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const executionFlow = () => new Promise((resolve) => {
|
|
51
|
+
instance.listen({ port: 0 }, err => {
|
|
52
|
+
t.error(err)
|
|
53
|
+
|
|
54
|
+
get(`http://localhost:${instance.server.address().port}`, (err, res, data) => {
|
|
55
|
+
t.error(err)
|
|
56
|
+
t.same(res.statusCode, 200)
|
|
57
|
+
t.match(JSON.parse(data.toString()), { hello: 'world' })
|
|
58
|
+
resolve()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await executionFlow()
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildServer () {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const server = createServer()
|
|
70
|
+
server.listen(0, () => resolve(server))
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildProxy () {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const server = createProxy(createServer())
|
|
77
|
+
server.listen(0, () => resolve(server))
|
|
78
|
+
})
|
|
79
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
RequestOptions as SecureRequestOptions
|
|
29
29
|
} from "https";
|
|
30
30
|
import { Pool } from 'undici';
|
|
31
|
+
import { ProxyAgent } from 'undici';
|
|
31
32
|
|
|
32
33
|
declare module "fastify" {
|
|
33
34
|
interface FastifyReply {
|
|
@@ -40,13 +41,18 @@ declare module "fastify" {
|
|
|
40
41
|
|
|
41
42
|
type FastifyReplyFrom = FastifyPluginCallback<fastifyReplyFrom.FastifyReplyFromOptions>
|
|
42
43
|
declare namespace fastifyReplyFrom {
|
|
43
|
-
type QueryStringFunction = (
|
|
44
|
+
type QueryStringFunction = (
|
|
45
|
+
search: string | undefined,
|
|
46
|
+
reqUrl: string,
|
|
47
|
+
request: FastifyRequest<RequestGenericInterface, RawServerBase>
|
|
48
|
+
) => string;
|
|
44
49
|
|
|
45
50
|
export type RetryDetails = {
|
|
46
51
|
err: Error;
|
|
47
52
|
req: FastifyRequest<RequestGenericInterface, RawServerBase>;
|
|
48
53
|
res: FastifyReply<RawServerBase>;
|
|
49
54
|
attempt: number;
|
|
55
|
+
retriesCount: number;
|
|
50
56
|
getDefaultDelay: () => number | null;
|
|
51
57
|
}
|
|
52
58
|
export interface FastifyReplyFromHooks {
|
|
@@ -98,7 +104,7 @@ declare namespace fastifyReplyFrom {
|
|
|
98
104
|
disableCache?: boolean;
|
|
99
105
|
http?: HttpOptions;
|
|
100
106
|
http2?: Http2Options | boolean;
|
|
101
|
-
undici?: Pool.Options;
|
|
107
|
+
undici?: Pool.Options & { proxy?: string | URL | ProxyAgent.Options };
|
|
102
108
|
contentTypesToEncode?: string[];
|
|
103
109
|
retryMethods?: (HTTPMethods | 'TRACE')[];
|
|
104
110
|
maxRetriesOn503?: number;
|
package/types/index.test-d.ts
CHANGED
|
@@ -38,7 +38,8 @@ const fullOptions: FastifyReplyFromOptions = {
|
|
|
38
38
|
disableCache: false,
|
|
39
39
|
undici: {
|
|
40
40
|
connections: 100,
|
|
41
|
-
pipelining: 10
|
|
41
|
+
pipelining: 10,
|
|
42
|
+
proxy: 'http://example2.com:8080'
|
|
42
43
|
},
|
|
43
44
|
contentTypesToEncode: ['application/x-www-form-urlencoded'],
|
|
44
45
|
retryMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'],
|
|
@@ -60,6 +61,10 @@ async function main() {
|
|
|
60
61
|
|
|
61
62
|
server.register(replyFrom, fullOptions);
|
|
62
63
|
|
|
64
|
+
server.register(replyFrom, { undici: { proxy: new URL('http://example2.com:8080') } });
|
|
65
|
+
|
|
66
|
+
server.register(replyFrom, { undici: { proxy: { uri: 'http://example2.com:8080' } } });
|
|
67
|
+
|
|
63
68
|
server.get("/v1", (request, reply) => {
|
|
64
69
|
expectType<FastifyReply>(reply.from());
|
|
65
70
|
});
|
|
@@ -91,7 +96,7 @@ async function main() {
|
|
|
91
96
|
instance.get("/http2", (request, reply) => {
|
|
92
97
|
reply.from("/", {
|
|
93
98
|
method: "POST",
|
|
94
|
-
retryDelay: ({err, req, res, attempt, getDefaultDelay}) => {
|
|
99
|
+
retryDelay: ({err, req, res, attempt, retriesCount, getDefaultDelay }) => {
|
|
95
100
|
const defaultDelay = getDefaultDelay();
|
|
96
101
|
if (defaultDelay) return defaultDelay;
|
|
97
102
|
|
|
@@ -111,7 +116,13 @@ async function main() {
|
|
|
111
116
|
},
|
|
112
117
|
onError(reply: FastifyReply<RawServerBase>, error) {
|
|
113
118
|
return reply.send(error.error);
|
|
114
|
-
}
|
|
119
|
+
},
|
|
120
|
+
queryString(search, reqUrl, request) {
|
|
121
|
+
expectType<string | undefined>(search);
|
|
122
|
+
expectType<string>(reqUrl);
|
|
123
|
+
expectType<FastifyRequest<RequestGenericInterface, RawServerBase>>(request);
|
|
124
|
+
return '';
|
|
125
|
+
},
|
|
115
126
|
});
|
|
116
127
|
});
|
|
117
128
|
|