@fastify/cookie 6.0.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.
@@ -0,0 +1,703 @@
1
+ 'use strict'
2
+
3
+ const tap = require('tap')
4
+ const test = tap.test
5
+ const Fastify = require('fastify')
6
+ const sinon = require('sinon')
7
+ const cookieSignature = require('cookie-signature')
8
+ const plugin = require('../')
9
+
10
+ test('cookies get set correctly', (t) => {
11
+ t.plan(7)
12
+ const fastify = Fastify()
13
+ fastify.register(plugin)
14
+
15
+ fastify.get('/test1', (req, reply) => {
16
+ reply
17
+ .setCookie('foo', 'foo', { path: '/' })
18
+ .send({ hello: 'world' })
19
+ })
20
+
21
+ fastify.inject({
22
+ method: 'GET',
23
+ url: '/test1'
24
+ }, (err, res) => {
25
+ t.error(err)
26
+ t.equal(res.statusCode, 200)
27
+ t.same(JSON.parse(res.body), { hello: 'world' })
28
+
29
+ const cookies = res.cookies
30
+ t.equal(cookies.length, 1)
31
+ t.equal(cookies[0].name, 'foo')
32
+ t.equal(cookies[0].value, 'foo')
33
+ t.equal(cookies[0].path, '/')
34
+ })
35
+ })
36
+
37
+ test('express cookie compatibility', (t) => {
38
+ t.plan(7)
39
+ const fastify = Fastify()
40
+ fastify.register(plugin)
41
+
42
+ fastify.get('/espresso', (req, reply) => {
43
+ reply
44
+ .cookie('foo', 'foo', { path: '/' })
45
+ .send({ hello: 'world' })
46
+ })
47
+
48
+ fastify.inject({
49
+ method: 'GET',
50
+ url: '/espresso'
51
+ }, (err, res) => {
52
+ t.error(err)
53
+ t.equal(res.statusCode, 200)
54
+ t.same(JSON.parse(res.body), { hello: 'world' })
55
+
56
+ const cookies = res.cookies
57
+ t.equal(cookies.length, 1)
58
+ t.equal(cookies[0].name, 'foo')
59
+ t.equal(cookies[0].value, 'foo')
60
+ t.equal(cookies[0].path, '/')
61
+ })
62
+ })
63
+
64
+ test('should set multiple cookies', (t) => {
65
+ t.plan(10)
66
+ const fastify = Fastify()
67
+ fastify.register(plugin)
68
+
69
+ fastify.get('/', (req, reply) => {
70
+ reply
71
+ .setCookie('foo', 'foo')
72
+ .cookie('bar', 'test')
73
+ .setCookie('wee', 'woo')
74
+ .send({ hello: 'world' })
75
+ })
76
+
77
+ fastify.inject({
78
+ method: 'GET',
79
+ url: '/'
80
+ }, (err, res) => {
81
+ t.error(err)
82
+ t.equal(res.statusCode, 200)
83
+ t.same(JSON.parse(res.body), { hello: 'world' })
84
+
85
+ const cookies = res.cookies
86
+ t.equal(cookies.length, 3)
87
+ t.equal(cookies[0].name, 'foo')
88
+ t.equal(cookies[0].value, 'foo')
89
+ t.equal(cookies[1].name, 'bar')
90
+ t.equal(cookies[1].value, 'test')
91
+ t.equal(cookies[2].name, 'wee')
92
+ t.equal(cookies[2].value, 'woo')
93
+ })
94
+ })
95
+
96
+ test('cookies get set correctly with millisecond dates', (t) => {
97
+ t.plan(8)
98
+ const fastify = Fastify()
99
+ fastify.register(plugin)
100
+
101
+ fastify.get('/test1', (req, reply) => {
102
+ reply
103
+ .setCookie('foo', 'foo', { path: '/', expires: Date.now() + 1000 })
104
+ .send({ hello: 'world' })
105
+ })
106
+
107
+ fastify.inject({
108
+ method: 'GET',
109
+ url: '/test1'
110
+ }, (err, res) => {
111
+ t.error(err)
112
+ t.equal(res.statusCode, 200)
113
+ t.same(JSON.parse(res.body), { hello: 'world' })
114
+
115
+ const cookies = res.cookies
116
+ t.equal(cookies.length, 1)
117
+ t.equal(cookies[0].name, 'foo')
118
+ t.equal(cookies[0].value, 'foo')
119
+ t.equal(cookies[0].path, '/')
120
+ const expires = new Date(cookies[0].expires)
121
+ t.ok(expires < new Date(Date.now() + 5000))
122
+ })
123
+ })
124
+
125
+ test('share options for setCookie and clearCookie', (t) => {
126
+ t.plan(11)
127
+ const fastify = Fastify()
128
+ const secret = 'testsecret'
129
+ fastify.register(plugin, { secret })
130
+
131
+ const cookieOptions = {
132
+ signed: true,
133
+ maxAge: 36000
134
+ }
135
+
136
+ fastify.get('/test1', (req, reply) => {
137
+ reply
138
+ .setCookie('foo', 'foo', cookieOptions)
139
+ .clearCookie('foo', cookieOptions)
140
+ .send({ hello: 'world' })
141
+ })
142
+
143
+ fastify.inject({
144
+ method: 'GET',
145
+ url: '/test1'
146
+ }, (err, res) => {
147
+ t.error(err)
148
+ t.equal(res.statusCode, 200)
149
+ t.same(JSON.parse(res.body), { hello: 'world' })
150
+
151
+ const cookies = res.cookies
152
+ t.equal(cookies.length, 2)
153
+ t.equal(cookies[0].name, 'foo')
154
+ t.equal(cookies[0].value, cookieSignature.sign('foo', secret))
155
+ t.equal(cookies[0].maxAge, 36000)
156
+
157
+ t.equal(cookies[1].name, 'foo')
158
+ t.equal(cookies[1].value, '')
159
+ t.equal(cookies[1].path, '/')
160
+ t.ok(new Date(cookies[1].expires) < new Date())
161
+ })
162
+ })
163
+
164
+ test('expires should not be overridden in clearCookie', (t) => {
165
+ t.plan(11)
166
+ const fastify = Fastify()
167
+ const secret = 'testsecret'
168
+ fastify.register(plugin, { secret })
169
+
170
+ const cookieOptions = {
171
+ signed: true,
172
+ expires: Date.now() + 1000
173
+ }
174
+
175
+ fastify.get('/test1', (req, reply) => {
176
+ reply
177
+ .setCookie('foo', 'foo', cookieOptions)
178
+ .clearCookie('foo', cookieOptions)
179
+ .send({ hello: 'world' })
180
+ })
181
+
182
+ fastify.inject({
183
+ method: 'GET',
184
+ url: '/test1'
185
+ }, (err, res) => {
186
+ t.error(err)
187
+ t.equal(res.statusCode, 200)
188
+ t.same(JSON.parse(res.body), { hello: 'world' })
189
+
190
+ const cookies = res.cookies
191
+ t.equal(cookies.length, 2)
192
+ t.equal(cookies[0].name, 'foo')
193
+ t.equal(cookies[0].value, cookieSignature.sign('foo', secret))
194
+ const expires = new Date(cookies[0].expires)
195
+ t.ok(expires < new Date(Date.now() + 5000))
196
+
197
+ t.equal(cookies[1].name, 'foo')
198
+ t.equal(cookies[1].value, '')
199
+ t.equal(cookies[1].path, '/')
200
+ t.equal(Number(cookies[1].expires), 0)
201
+ })
202
+ })
203
+
204
+ test('parses incoming cookies', (t) => {
205
+ t.plan(15)
206
+ const fastify = Fastify()
207
+ fastify.register(plugin)
208
+
209
+ // check that it parses the cookies in the onRequest hook
210
+ for (const hook of ['preValidation', 'preHandler']) {
211
+ fastify.addHook(hook, (req, reply, done) => {
212
+ t.ok(req.cookies)
213
+ t.ok(req.cookies.bar)
214
+ t.equal(req.cookies.bar, 'bar')
215
+ done()
216
+ })
217
+ }
218
+
219
+ fastify.addHook('preParsing', (req, reply, payload, done) => {
220
+ t.ok(req.cookies)
221
+ t.ok(req.cookies.bar)
222
+ t.equal(req.cookies.bar, 'bar')
223
+ done()
224
+ })
225
+
226
+ fastify.get('/test2', (req, reply) => {
227
+ t.ok(req.cookies)
228
+ t.ok(req.cookies.bar)
229
+ t.equal(req.cookies.bar, 'bar')
230
+ reply.send({ hello: 'world' })
231
+ })
232
+
233
+ fastify.inject({
234
+ method: 'GET',
235
+ url: '/test2',
236
+ headers: {
237
+ cookie: 'bar=bar'
238
+ }
239
+ }, (err, res) => {
240
+ t.error(err)
241
+ t.equal(res.statusCode, 200)
242
+ t.same(JSON.parse(res.body), { hello: 'world' })
243
+ })
244
+ })
245
+
246
+ test('does not modify supplied cookie options object', (t) => {
247
+ t.plan(3)
248
+ const expireDate = Date.now() + 1000
249
+ const cookieOptions = {
250
+ path: '/',
251
+ expires: expireDate
252
+ }
253
+ const fastify = Fastify()
254
+ fastify.register(plugin)
255
+
256
+ fastify.get('/test1', (req, reply) => {
257
+ reply
258
+ .setCookie('foo', 'foo', cookieOptions)
259
+ .send({ hello: 'world' })
260
+ })
261
+
262
+ fastify.inject({
263
+ method: 'GET',
264
+ url: '/test1'
265
+ }, (err, res) => {
266
+ t.error(err)
267
+ t.equal(res.statusCode, 200)
268
+ t.strictSame(cookieOptions, {
269
+ path: '/',
270
+ expires: expireDate
271
+ })
272
+ })
273
+ })
274
+
275
+ test('cookies gets cleared correctly', (t) => {
276
+ t.plan(5)
277
+ const fastify = Fastify()
278
+ fastify.register(plugin)
279
+
280
+ fastify.get('/test1', (req, reply) => {
281
+ reply
282
+ .clearCookie('foo')
283
+ .send({ hello: 'world' })
284
+ })
285
+
286
+ fastify.inject({
287
+ method: 'GET',
288
+ url: '/test1'
289
+ }, (err, res) => {
290
+ t.error(err)
291
+ t.equal(res.statusCode, 200)
292
+ t.same(JSON.parse(res.body), { hello: 'world' })
293
+
294
+ const cookies = res.cookies
295
+ t.equal(cookies.length, 1)
296
+ t.equal(new Date(cookies[0].expires) < new Date(), true)
297
+ })
298
+ })
299
+
300
+ test('cookies signature', (t) => {
301
+ t.plan(9)
302
+
303
+ t.test('unsign', t => {
304
+ t.plan(6)
305
+ const fastify = Fastify()
306
+ const secret = 'bar'
307
+ fastify.register(plugin, { secret })
308
+
309
+ fastify.get('/test1', (req, reply) => {
310
+ reply
311
+ .setCookie('foo', 'foo', { signed: true })
312
+ .send({ hello: 'world' })
313
+ })
314
+
315
+ fastify.inject({
316
+ method: 'GET',
317
+ url: '/test1'
318
+ }, (err, res) => {
319
+ t.error(err)
320
+ t.equal(res.statusCode, 200)
321
+ t.same(JSON.parse(res.body), { hello: 'world' })
322
+
323
+ const cookies = res.cookies
324
+ t.equal(cookies.length, 1)
325
+ t.equal(cookies[0].name, 'foo')
326
+ t.equal(cookieSignature.unsign(cookies[0].value, secret), 'foo')
327
+ })
328
+ })
329
+
330
+ t.test('key rotation uses first key to sign', t => {
331
+ t.plan(6)
332
+ const fastify = Fastify()
333
+ const secret1 = 'secret-1'
334
+ const secret2 = 'secret-2'
335
+ fastify.register(plugin, { secret: [secret1, secret2] })
336
+
337
+ fastify.get('/test1', (req, reply) => {
338
+ reply
339
+ .setCookie('foo', 'cookieVal', { signed: true })
340
+ .send({ hello: 'world' })
341
+ })
342
+
343
+ fastify.inject({
344
+ method: 'GET',
345
+ url: '/test1'
346
+ }, (err, res) => {
347
+ t.error(err)
348
+ t.equal(res.statusCode, 200)
349
+ t.same(JSON.parse(res.body), { hello: 'world' })
350
+
351
+ const cookies = res.cookies
352
+ t.equal(cookies.length, 1)
353
+ t.equal(cookies[0].name, 'foo')
354
+ t.equal(cookieSignature.unsign(cookies[0].value, secret1), 'cookieVal') // decode using first key
355
+ })
356
+ })
357
+
358
+ t.test('unsginCookie via fastify instance', t => {
359
+ t.plan(3)
360
+ const fastify = Fastify()
361
+ const secret = 'bar'
362
+
363
+ fastify.register(plugin, { secret })
364
+
365
+ fastify.get('/test1', (req, rep) => {
366
+ rep.send({
367
+ unsigned: fastify.unsignCookie(req.cookies.foo)
368
+ })
369
+ })
370
+
371
+ fastify.inject({
372
+ method: 'GET',
373
+ url: '/test1',
374
+ headers: {
375
+ cookie: `foo=${cookieSignature.sign('foo', secret)}`
376
+ }
377
+ }, (err, res) => {
378
+ t.error(err)
379
+ t.equal(res.statusCode, 200)
380
+ t.same(JSON.parse(res.body), { unsigned: { value: 'foo', renew: false, valid: true } })
381
+ })
382
+ })
383
+
384
+ t.test('unsignCookie via request decorator', t => {
385
+ t.plan(3)
386
+ const fastify = Fastify()
387
+ const secret = 'bar'
388
+ fastify.register(plugin, { secret })
389
+
390
+ fastify.get('/test1', (req, reply) => {
391
+ reply.send({
392
+ unsigned: req.unsignCookie(req.cookies.foo)
393
+ })
394
+ })
395
+
396
+ fastify.inject({
397
+ method: 'GET',
398
+ url: '/test1',
399
+ headers: {
400
+ cookie: `foo=${cookieSignature.sign('foo', secret)}`
401
+ }
402
+ }, (err, res) => {
403
+ t.error(err)
404
+ t.equal(res.statusCode, 200)
405
+ t.same(JSON.parse(res.body), { unsigned: { value: 'foo', renew: false, valid: true } })
406
+ })
407
+ })
408
+
409
+ t.test('unsignCookie via reply decorator', t => {
410
+ t.plan(3)
411
+ const fastify = Fastify()
412
+ const secret = 'bar'
413
+ fastify.register(plugin, { secret })
414
+
415
+ fastify.get('/test1', (req, reply) => {
416
+ reply.send({
417
+ unsigned: reply.unsignCookie(req.cookies.foo)
418
+ })
419
+ })
420
+
421
+ fastify.inject({
422
+ method: 'GET',
423
+ url: '/test1',
424
+ headers: {
425
+ cookie: `foo=${cookieSignature.sign('foo', secret)}`
426
+ }
427
+ }, (err, res) => {
428
+ t.error(err)
429
+ t.equal(res.statusCode, 200)
430
+ t.same(JSON.parse(res.body), { unsigned: { value: 'foo', renew: false, valid: true } })
431
+ })
432
+ })
433
+
434
+ t.test('unsignCookie via request decorator after rotation', t => {
435
+ t.plan(3)
436
+ const fastify = Fastify()
437
+ const secret1 = 'sec-1'
438
+ const secret2 = 'sec-2'
439
+ fastify.register(plugin, { secret: [secret1, secret2] })
440
+
441
+ fastify.get('/test1', (req, reply) => {
442
+ reply.send({
443
+ unsigned: req.unsignCookie(req.cookies.foo)
444
+ })
445
+ })
446
+
447
+ fastify.inject({
448
+ method: 'GET',
449
+ url: '/test1',
450
+ headers: {
451
+ cookie: `foo=${cookieSignature.sign('foo', secret2)}`
452
+ }
453
+ }, (err, res) => {
454
+ t.error(err)
455
+ t.equal(res.statusCode, 200)
456
+ t.same(JSON.parse(res.body), { unsigned: { value: 'foo', renew: true, valid: true } })
457
+ })
458
+ })
459
+
460
+ t.test('unsignCookie via reply decorator after rotation', t => {
461
+ t.plan(3)
462
+ const fastify = Fastify()
463
+ const secret1 = 'sec-1'
464
+ const secret2 = 'sec-2'
465
+ fastify.register(plugin, { secret: [secret1, secret2] })
466
+
467
+ fastify.get('/test1', (req, reply) => {
468
+ reply.send({
469
+ unsigned: reply.unsignCookie(req.cookies.foo)
470
+ })
471
+ })
472
+
473
+ fastify.inject({
474
+ method: 'GET',
475
+ url: '/test1',
476
+ headers: {
477
+ cookie: `foo=${cookieSignature.sign('foo', secret2)}`
478
+ }
479
+ }, (err, res) => {
480
+ t.error(err)
481
+ t.equal(res.statusCode, 200)
482
+ t.same(JSON.parse(res.body), { unsigned: { value: 'foo', renew: true, valid: true } })
483
+ })
484
+ })
485
+
486
+ t.test('unsignCookie via request decorator failure response', t => {
487
+ t.plan(3)
488
+ const fastify = Fastify()
489
+ const secret1 = 'sec-1'
490
+ const secret2 = 'sec-2'
491
+ fastify.register(plugin, { secret: [secret1, secret2] })
492
+
493
+ fastify.get('/test1', (req, reply) => {
494
+ reply.send({
495
+ unsigned: req.unsignCookie(req.cookies.foo)
496
+ })
497
+ })
498
+
499
+ fastify.inject({
500
+ method: 'GET',
501
+ url: '/test1',
502
+ headers: {
503
+ cookie: `foo=${cookieSignature.sign('foo', 'invalid-secret')}`
504
+ }
505
+ }, (err, res) => {
506
+ t.error(err)
507
+ t.equal(res.statusCode, 200)
508
+ t.same(JSON.parse(res.body), { unsigned: { value: null, renew: false, valid: false } })
509
+ })
510
+ })
511
+
512
+ t.test('unsignCookie reply decorator failure response', t => {
513
+ t.plan(3)
514
+ const fastify = Fastify()
515
+ const secret1 = 'sec-1'
516
+ const secret2 = 'sec-2'
517
+ fastify.register(plugin, { secret: [secret1, secret2] })
518
+
519
+ fastify.get('/test1', (req, reply) => {
520
+ reply.send({
521
+ unsigned: reply.unsignCookie(req.cookies.foo)
522
+ })
523
+ })
524
+
525
+ fastify.inject({
526
+ method: 'GET',
527
+ url: '/test1',
528
+ headers: {
529
+ cookie: `foo=${cookieSignature.sign('foo', 'invalid-secret')}`
530
+ }
531
+ }, (err, res) => {
532
+ t.error(err)
533
+ t.equal(res.statusCode, 200)
534
+ t.same(JSON.parse(res.body), { unsigned: { value: null, renew: false, valid: false } })
535
+ })
536
+ })
537
+ })
538
+
539
+ test('custom signer', t => {
540
+ t.plan(7)
541
+ const fastify = Fastify()
542
+ const signStub = sinon.stub().returns('SIGNED-VALUE')
543
+ const unsignStub = sinon.stub().returns('ORIGINAL VALUE')
544
+ const secret = { sign: signStub, unsign: unsignStub }
545
+ fastify.register(plugin, { secret })
546
+
547
+ fastify.get('/test1', (req, reply) => {
548
+ reply
549
+ .setCookie('foo', 'bar', { signed: true })
550
+ .send({ hello: 'world' })
551
+ })
552
+
553
+ fastify.inject({
554
+ method: 'GET',
555
+ url: '/test1'
556
+ }, (err, res) => {
557
+ t.error(err)
558
+ t.equal(res.statusCode, 200)
559
+ t.same(JSON.parse(res.body), { hello: 'world' })
560
+
561
+ const cookies = res.cookies
562
+ t.equal(cookies.length, 1)
563
+ t.equal(cookies[0].name, 'foo')
564
+ t.equal(cookies[0].value, 'SIGNED-VALUE')
565
+ t.ok(signStub.calledOnceWithExactly('bar'))
566
+ })
567
+ })
568
+
569
+ test('unsignCookie decorator with custom signer', t => {
570
+ t.plan(4)
571
+ const fastify = Fastify()
572
+ const signStub = sinon.stub().returns('SIGNED-VALUE')
573
+ const unsignStub = sinon.stub().returns('ORIGINAL VALUE')
574
+ const secret = { sign: signStub, unsign: unsignStub }
575
+ fastify.register(plugin, { secret })
576
+
577
+ fastify.get('/test1', (req, reply) => {
578
+ reply.send({
579
+ unsigned: reply.unsignCookie(req.cookies.foo)
580
+ })
581
+ })
582
+
583
+ fastify.inject({
584
+ method: 'GET',
585
+ url: '/test1',
586
+ headers: {
587
+ cookie: 'foo=SOME-SIGNED-VALUE'
588
+ }
589
+ }, (err, res) => {
590
+ t.error(err)
591
+ t.equal(res.statusCode, 200)
592
+ t.same(JSON.parse(res.body), { unsigned: 'ORIGINAL VALUE' })
593
+ t.ok(unsignStub.calledOnceWithExactly('SOME-SIGNED-VALUE'))
594
+ })
595
+ })
596
+
597
+ test('pass options to `cookies.parse`', (t) => {
598
+ t.plan(6)
599
+ const fastify = Fastify()
600
+ fastify.register(plugin, {
601
+ parseOptions: {
602
+ decode: decoder
603
+ }
604
+ })
605
+
606
+ fastify.get('/test1', (req, reply) => {
607
+ t.ok(req.cookies)
608
+ t.ok(req.cookies.foo)
609
+ t.equal(req.cookies.foo, 'bartest')
610
+ reply.send({ hello: 'world' })
611
+ })
612
+
613
+ fastify.inject({
614
+ method: 'GET',
615
+ url: '/test1',
616
+ headers: {
617
+ cookie: 'foo=bar'
618
+ }
619
+ }, (err, res) => {
620
+ t.error(err)
621
+ t.equal(res.statusCode, 200)
622
+ t.same(JSON.parse(res.body), { hello: 'world' })
623
+ })
624
+
625
+ function decoder (str) {
626
+ return str + 'test'
627
+ }
628
+ })
629
+
630
+ test('issue 53', (t) => {
631
+ t.plan(5)
632
+ const fastify = Fastify()
633
+ fastify.register(plugin)
634
+
635
+ let cookies
636
+ let count = 1
637
+ fastify.get('/foo', (req, reply) => {
638
+ if (count > 1) {
639
+ t.not(cookies, req.cookies)
640
+ return reply.send('done')
641
+ }
642
+
643
+ count += 1
644
+ cookies = req.cookies
645
+ reply.send('done')
646
+ })
647
+
648
+ fastify.inject({ url: '/foo' }, (err, response) => {
649
+ t.error(err)
650
+ t.equal(response.body, 'done')
651
+ })
652
+
653
+ fastify.inject({ url: '/foo' }, (err, response) => {
654
+ t.error(err)
655
+ t.equal(response.body, 'done')
656
+ })
657
+ })
658
+
659
+ test('parse cookie manually using decorator', (t) => {
660
+ t.plan(2)
661
+ const fastify = Fastify()
662
+ fastify.register(plugin)
663
+
664
+ fastify.ready(() => {
665
+ t.ok(fastify.parseCookie)
666
+ t.same(fastify.parseCookie('foo=bar', {}), { foo: 'bar' })
667
+ t.end()
668
+ })
669
+ })
670
+
671
+ test('cookies set with plugin options parseOptions field', (t) => {
672
+ t.plan(8)
673
+ const fastify = Fastify()
674
+ fastify.register(plugin, {
675
+ parseOptions: {
676
+ path: '/test',
677
+ domain: 'example.com'
678
+ }
679
+ })
680
+
681
+ fastify.get('/test', (req, reply) => {
682
+ reply.setCookie('foo', 'foo').send({ hello: 'world' })
683
+ })
684
+
685
+ fastify.inject(
686
+ {
687
+ method: 'GET',
688
+ url: '/test'
689
+ },
690
+ (err, res) => {
691
+ t.error(err)
692
+ t.equal(res.statusCode, 200)
693
+ t.same(JSON.parse(res.body), { hello: 'world' })
694
+
695
+ const cookies = res.cookies
696
+ t.equal(cookies.length, 1)
697
+ t.equal(cookies[0].name, 'foo')
698
+ t.equal(cookies[0].value, 'foo')
699
+ t.equal(cookies[0].path, '/test')
700
+ t.equal(cookies[0].domain, 'example.com')
701
+ }
702
+ )
703
+ })