Merge lp:~pedronis/ubuntu-push/unicast-endp into lp:ubuntu-push/automatic
- unicast-endp
- Merge into automatic
Proposed by
Samuele Pedroni
Status: | Merged |
---|---|
Approved by: | Samuele Pedroni |
Approved revision: | 145 |
Merged at revision: | 145 |
Proposed branch: | lp:~pedronis/ubuntu-push/unicast-endp |
Merge into: | lp:ubuntu-push/automatic |
Prerequisite: | lp:~pedronis/ubuntu-push/unicast-broker |
Diff against target: |
527 lines (+311/-34) 4 files modified
Makefile (+1/-0) dependencies.tsv (+1/-0) server/api/handlers.go (+103/-21) server/api/handlers_test.go (+206/-13) |
To merge this branch: | bzr merge lp:~pedronis/ubuntu-push/unicast-endp |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
John Lenton (community) | Approve | ||
Review via email: mp+218045@code.launchpad.net |
Commit message
/notify api endpoint for unicast, takes explicit user id,device id pair for now
Description of the change
/notify api endpoint for unicast, takes explicit user id,device id pair for now
To post a comment you must log in.
Revision history for this message
John Lenton (chipaca) : | # |
review:
Approve
- 145. By Samuele Pedroni
-
Merged unicast-broker into unicast-endp.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'Makefile' |
2 | --- Makefile 2014-05-01 10:24:23 +0000 |
3 | +++ Makefile 2014-05-02 15:13:47 +0000 |
4 | @@ -11,6 +11,7 @@ |
5 | GODEPS += launchpad.net/go-dbus/v1 |
6 | GODEPS += launchpad.net/go-xdg/v0 |
7 | GODEPS += code.google.com/p/gosqlite/sqlite3 |
8 | +GODEPS += launchpad.net/~ubuntu-push-hackers/ubuntu-push/go-uuid/uuid |
9 | |
10 | TOTEST = $(shell env GOPATH=$(GOPATH) go list $(PROJECT)/...|grep -v acceptance|grep -v http13client ) |
11 | |
12 | |
13 | === modified file 'dependencies.tsv' |
14 | --- dependencies.tsv 2014-04-30 16:44:16 +0000 |
15 | +++ dependencies.tsv 2014-05-02 15:13:47 +0000 |
16 | @@ -2,3 +2,4 @@ |
17 | launchpad.net/go-dbus/v1 bzr james@jamesh.id.au-20140206110213-pbzcr6ucaz3rqmnw 125 |
18 | launchpad.net/go-xdg/v0 bzr john.lenton@canonical.com-20140208094800-gubd5md7cro3mtxa 10 |
19 | launchpad.net/gocheck bzr gustavo@niemeyer.net-20140127131816-zshobk1qqme626xw 86 |
20 | +launchpad.net/~ubuntu-push-hackers/ubuntu-push/go-uuid bzr samuele.pedroni@canonical.com-20140130122455-pm9h8etl4owp90lg 1 |
21 | |
22 | === modified file 'server/api/handlers.go' |
23 | --- server/api/handlers.go 2014-02-20 17:09:03 +0000 |
24 | +++ server/api/handlers.go 2014-05-02 15:13:47 +0000 |
25 | @@ -19,12 +19,15 @@ |
26 | package api |
27 | |
28 | import ( |
29 | + "encoding/base64" |
30 | "encoding/json" |
31 | "fmt" |
32 | "io" |
33 | "net/http" |
34 | "time" |
35 | |
36 | + "launchpad.net/~ubuntu-push-hackers/ubuntu-push/go-uuid/uuid" |
37 | + |
38 | "launchpad.net/ubuntu-push/logger" |
39 | "launchpad.net/ubuntu-push/server/broker" |
40 | "launchpad.net/ubuntu-push/server/store" |
41 | @@ -93,6 +96,11 @@ |
42 | ioError, |
43 | "Could not read request body", |
44 | } |
45 | + ErrMissingIdField = &APIError{ |
46 | + http.StatusBadRequest, |
47 | + invalidRequest, |
48 | + "Missing id field", |
49 | + } |
50 | ErrMissingData = &APIError{ |
51 | http.StatusBadRequest, |
52 | invalidRequest, |
53 | @@ -130,10 +138,17 @@ |
54 | } |
55 | ) |
56 | |
57 | -type Message struct { |
58 | - Registration string `json:"registration"` |
59 | - CoalesceTag string `json:"coalesce_tag"` |
60 | - Data json.RawMessage `json:"data"` |
61 | +type castCommon struct { |
62 | +} |
63 | + |
64 | +type Unicast struct { |
65 | + UserId string `json:"userid"` |
66 | + DeviceId string `json:"deviceid"` |
67 | + AppId string `json:"appid"` |
68 | + //Registration string `json:"registration"` |
69 | + //CoalesceTag string `json:"coalesce_tag"` |
70 | + ExpireOn string `json:"expire_on"` |
71 | + Data json.RawMessage `json:"data"` |
72 | } |
73 | |
74 | // Broadcast request JSON object. |
75 | @@ -198,11 +213,11 @@ |
76 | |
77 | var zeroTime = time.Time{} |
78 | |
79 | -func checkBroadcast(bcast *Broadcast) (time.Time, *APIError) { |
80 | - if len(bcast.Data) == 0 { |
81 | +func checkCastCommon(data json.RawMessage, expireOn string) (time.Time, *APIError) { |
82 | + if len(data) == 0 { |
83 | return zeroTime, ErrMissingData |
84 | } |
85 | - expire, err := time.Parse(time.RFC3339, bcast.ExpireOn) |
86 | + expire, err := time.Parse(time.RFC3339, expireOn) |
87 | if err != nil { |
88 | return zeroTime, ErrInvalidExpiration |
89 | } |
90 | @@ -212,6 +227,10 @@ |
91 | return expire, nil |
92 | } |
93 | |
94 | +func checkBroadcast(bcast *Broadcast) (time.Time, *APIError) { |
95 | + return checkCastCommon(bcast.Data, bcast.ExpireOn) |
96 | +} |
97 | + |
98 | type StoreForRequest func(w http.ResponseWriter, request *http.Request) (store.PendingStore, error) |
99 | |
100 | // context holds the interfaces to delegate to serving requests |
101 | @@ -234,6 +253,20 @@ |
102 | return sto, nil |
103 | } |
104 | |
105 | +func (ctx *context) prepare(w http.ResponseWriter, request *http.Request, reqObj interface{}) (store.PendingStore, *APIError) { |
106 | + body, apiErr := ReadBody(request, MaxRequestBodyBytes) |
107 | + if apiErr != nil { |
108 | + return nil, apiErr |
109 | + } |
110 | + |
111 | + err := json.Unmarshal(body, reqObj) |
112 | + if err != nil { |
113 | + return nil, ErrMalformedJSONObject |
114 | + } |
115 | + |
116 | + return ctx.getStore(w, request) |
117 | +} |
118 | + |
119 | type BroadcastHandler struct { |
120 | *context |
121 | } |
122 | @@ -270,23 +303,13 @@ |
123 | } |
124 | }() |
125 | |
126 | - body, apiErr := ReadBody(request, MaxRequestBodyBytes) |
127 | - if apiErr != nil { |
128 | - return |
129 | - } |
130 | - |
131 | - sto, apiErr := h.getStore(writer, request) |
132 | - if apiErr != nil { |
133 | - return |
134 | - } |
135 | - defer sto.Close() |
136 | - |
137 | broadcast := &Broadcast{} |
138 | - err := json.Unmarshal(body, broadcast) |
139 | - if err != nil { |
140 | - apiErr = ErrMalformedJSONObject |
141 | + |
142 | + sto, apiErr := h.prepare(writer, request, broadcast) |
143 | + if apiErr != nil { |
144 | return |
145 | } |
146 | + defer sto.Close() |
147 | |
148 | apiErr = h.doBroadcast(sto, broadcast) |
149 | if apiErr != nil { |
150 | @@ -297,6 +320,64 @@ |
151 | fmt.Fprintf(writer, `{"ok":true}`) |
152 | } |
153 | |
154 | +type UnicastHandler struct { |
155 | + *context |
156 | +} |
157 | + |
158 | +func checkUnicast(ucast *Unicast) (time.Time, *APIError) { |
159 | + if ucast.UserId == "" || ucast.DeviceId == "" || ucast.AppId == "" { |
160 | + return zeroTime, ErrMissingIdField |
161 | + } |
162 | + return checkCastCommon(ucast.Data, ucast.ExpireOn) |
163 | +} |
164 | + |
165 | +// use a base64 encoded TimeUUID |
166 | +var generateMsgId = func() string { |
167 | + return base64.StdEncoding.EncodeToString(uuid.NewUUID()) |
168 | +} |
169 | + |
170 | +func (h *UnicastHandler) doUnicast(sto store.PendingStore, ucast *Unicast) *APIError { |
171 | + expire, apiErr := checkUnicast(ucast) |
172 | + if apiErr != nil { |
173 | + return apiErr |
174 | + } |
175 | + chanId := store.UnicastInternalChannelId(ucast.UserId, ucast.DeviceId) |
176 | + msgId := generateMsgId() |
177 | + err := sto.AppendToUnicastChannel(chanId, ucast.AppId, ucast.Data, msgId, expire) |
178 | + if err != nil { |
179 | + h.logger.Errorf("could not store notification: %v", err) |
180 | + return ErrCouldNotStoreNotification |
181 | + } |
182 | + |
183 | + h.broker.Unicast(chanId) |
184 | + return nil |
185 | +} |
186 | + |
187 | +func (h *UnicastHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
188 | + var apiErr *APIError |
189 | + defer func() { |
190 | + if apiErr != nil { |
191 | + RespondError(writer, apiErr) |
192 | + } |
193 | + }() |
194 | + |
195 | + unicast := &Unicast{} |
196 | + |
197 | + sto, apiErr := h.prepare(writer, request, unicast) |
198 | + if apiErr != nil { |
199 | + return |
200 | + } |
201 | + defer sto.Close() |
202 | + |
203 | + apiErr = h.doUnicast(sto, unicast) |
204 | + if apiErr != nil { |
205 | + return |
206 | + } |
207 | + |
208 | + writer.Header().Set("Content-Type", "application/json") |
209 | + fmt.Fprintf(writer, `{"ok":true}`) |
210 | +} |
211 | + |
212 | // MakeHandlersMux makes a handler that dispatches for the various API endpoints. |
213 | func MakeHandlersMux(storeForRequest StoreForRequest, broker broker.BrokerSending, logger logger.Logger) *http.ServeMux { |
214 | ctx := &context{ |
215 | @@ -306,5 +387,6 @@ |
216 | } |
217 | mux := http.NewServeMux() |
218 | mux.Handle("/broadcast", &BroadcastHandler{context: ctx}) |
219 | + mux.Handle("/notify", &UnicastHandler{context: ctx}) |
220 | return mux |
221 | } |
222 | |
223 | === modified file 'server/api/handlers_test.go' |
224 | --- server/api/handlers_test.go 2014-05-02 15:13:47 +0000 |
225 | +++ server/api/handlers_test.go 2014-05-02 15:13:47 +0000 |
226 | @@ -18,6 +18,7 @@ |
227 | |
228 | import ( |
229 | "bytes" |
230 | + "encoding/base64" |
231 | "encoding/json" |
232 | "errors" |
233 | "fmt" |
234 | @@ -32,7 +33,7 @@ |
235 | |
236 | "launchpad.net/ubuntu-push/protocol" |
237 | "launchpad.net/ubuntu-push/server/store" |
238 | - helpers "launchpad.net/ubuntu-push/testing" |
239 | + help "launchpad.net/ubuntu-push/testing" |
240 | ) |
241 | |
242 | func TestHandlers(t *testing.T) { TestingT(t) } |
243 | @@ -42,14 +43,14 @@ |
244 | json string |
245 | client *http.Client |
246 | c *C |
247 | - testlog *helpers.TestLogger |
248 | + testlog *help.TestLogger |
249 | } |
250 | |
251 | var _ = Suite(&handlersSuite{}) |
252 | |
253 | func (s *handlersSuite) SetUpTest(c *C) { |
254 | s.client = &http.Client{} |
255 | - s.testlog = helpers.NewTestLogger(c, "error") |
256 | + s.testlog = help.NewTestLogger(c, "error") |
257 | } |
258 | |
259 | func (s *handlersSuite) TestAPIError(c *C) { |
260 | @@ -99,7 +100,7 @@ |
261 | |
262 | var future = time.Now().Add(4 * time.Hour).Format(time.RFC3339) |
263 | |
264 | -func (s *handlersSuite) TestCheckBroadcast(c *C) { |
265 | +func (s *handlersSuite) TestCheckCastBroadcastAndCommon(c *C) { |
266 | payload := json.RawMessage(`{"foo":"bar"}`) |
267 | broadcast := &Broadcast{ |
268 | Channel: "system", |
269 | @@ -135,11 +136,11 @@ |
270 | } |
271 | |
272 | type checkBrokerSending struct { |
273 | - store store.PendingStore |
274 | - chanId store.InternalChannelId |
275 | - err error |
276 | - top int64 |
277 | - payloads []json.RawMessage |
278 | + store store.PendingStore |
279 | + chanId store.InternalChannelId |
280 | + err error |
281 | + top int64 |
282 | + notifications []protocol.Notification |
283 | } |
284 | |
285 | func (cbsend *checkBrokerSending) Broadcast(chanId store.InternalChannelId) { |
286 | @@ -147,11 +148,15 @@ |
287 | cbsend.err = err |
288 | cbsend.chanId = chanId |
289 | cbsend.top = top |
290 | - cbsend.payloads = protocol.ExtractPayloads(notifications) |
291 | + cbsend.notifications = notifications |
292 | } |
293 | |
294 | func (cbsend *checkBrokerSending) Unicast(chanIds ...store.InternalChannelId) { |
295 | - // xxx later |
296 | + // for now |
297 | + if len(chanIds) != 1 { |
298 | + panic("not expecting many chan ids for now") |
299 | + } |
300 | + cbsend.Broadcast(chanIds[0]) |
301 | } |
302 | |
303 | func (s *handlersSuite) TestDoBroadcast(c *C) { |
304 | @@ -168,7 +173,7 @@ |
305 | c.Check(bsend.err, IsNil) |
306 | c.Check(bsend.chanId, Equals, store.SystemInternalChannelId) |
307 | c.Check(bsend.top, Equals, int64(1)) |
308 | - c.Check(bsend.payloads, DeepEquals, []json.RawMessage{payload}) |
309 | + c.Check(bsend.notifications, DeepEquals, help.Ns(payload)) |
310 | } |
311 | |
312 | func (s *handlersSuite) TestDoBroadcastUnknownChannel(c *C) { |
313 | @@ -197,6 +202,11 @@ |
314 | return isto.intercept("AppendToChannel", err) |
315 | } |
316 | |
317 | +func (isto *interceptInMemoryPendingStore) AppendToUnicastChannel(chanId store.InternalChannelId, appId string, payload json.RawMessage, msgId string, expiration time.Time) error { |
318 | + err := isto.InMemoryPendingStore.AppendToUnicastChannel(chanId, appId, payload, msgId, expiration) |
319 | + return isto.intercept("AppendToUnicastChannel", err) |
320 | +} |
321 | + |
322 | func (s *handlersSuite) TestDoBroadcastUnknownError(c *C) { |
323 | sto := &interceptInMemoryPendingStore{ |
324 | store.NewInMemoryPendingStore(), |
325 | @@ -234,6 +244,115 @@ |
326 | c.Check(s.testlog.Captured(), Equals, "ERROR could not store notification: fail\n") |
327 | } |
328 | |
329 | +func (s *handlersSuite) TestCheckUnicast(c *C) { |
330 | + payload := json.RawMessage(`{"foo":"bar"}`) |
331 | + unicast := func() *Unicast { |
332 | + return &Unicast{ |
333 | + UserId: "user1", |
334 | + DeviceId: "DEV1", |
335 | + AppId: "app1", |
336 | + ExpireOn: future, |
337 | + Data: payload, |
338 | + } |
339 | + } |
340 | + u := unicast() |
341 | + expire, apiErr := checkUnicast(u) |
342 | + c.Assert(apiErr, IsNil) |
343 | + c.Check(expire.Format(time.RFC3339), Equals, future) |
344 | + |
345 | + u = unicast() |
346 | + u.UserId = "" |
347 | + expire, apiErr = checkUnicast(u) |
348 | + c.Check(apiErr, Equals, ErrMissingIdField) |
349 | + |
350 | + u = unicast() |
351 | + u.AppId = "" |
352 | + expire, apiErr = checkUnicast(u) |
353 | + c.Check(apiErr, Equals, ErrMissingIdField) |
354 | + |
355 | + u = unicast() |
356 | + u.DeviceId = "" |
357 | + expire, apiErr = checkUnicast(u) |
358 | + c.Check(apiErr, Equals, ErrMissingIdField) |
359 | + |
360 | + u = unicast() |
361 | + u.Data = json.RawMessage(nil) |
362 | + expire, apiErr = checkUnicast(u) |
363 | + c.Check(apiErr, Equals, ErrMissingData) |
364 | +} |
365 | + |
366 | +func (s *handlersSuite) TestGenerateMsgId(c *C) { |
367 | + msgId := generateMsgId() |
368 | + decoded, err := base64.StdEncoding.DecodeString(msgId) |
369 | + c.Assert(err, IsNil) |
370 | + c.Check(decoded, HasLen, 16) |
371 | +} |
372 | + |
373 | +func (s *handlersSuite) TestDoUnicast(c *C) { |
374 | + prevGenMsgId := generateMsgId |
375 | + defer func() { |
376 | + generateMsgId = prevGenMsgId |
377 | + }() |
378 | + generateMsgId = func() string { |
379 | + return "MSG-ID" |
380 | + } |
381 | + sto := store.NewInMemoryPendingStore() |
382 | + bsend := &checkBrokerSending{store: sto} |
383 | + bh := &UnicastHandler{&context{nil, bsend, nil}} |
384 | + payload := json.RawMessage(`{"a": 1}`) |
385 | + apiErr := bh.doUnicast(sto, &Unicast{ |
386 | + UserId: "user1", |
387 | + DeviceId: "DEV1", |
388 | + AppId: "app1", |
389 | + ExpireOn: future, |
390 | + Data: payload, |
391 | + }) |
392 | + c.Check(apiErr, IsNil) |
393 | + c.Check(bsend.err, IsNil) |
394 | + c.Check(bsend.chanId, Equals, store.UnicastInternalChannelId("user1", "DEV1")) |
395 | + c.Check(bsend.top, Equals, int64(0)) |
396 | + c.Check(bsend.notifications, DeepEquals, []protocol.Notification{ |
397 | + protocol.Notification{ |
398 | + AppId: "app1", |
399 | + MsgId: "MSG-ID", |
400 | + Payload: payload, |
401 | + }, |
402 | + }) |
403 | +} |
404 | + |
405 | +func (s *handlersSuite) TestDoUnicastMissingIdField(c *C) { |
406 | + sto := store.NewInMemoryPendingStore() |
407 | + bh := &UnicastHandler{} |
408 | + apiErr := bh.doUnicast(sto, &Unicast{ |
409 | + ExpireOn: future, |
410 | + Data: json.RawMessage(`{"a": 1}`), |
411 | + }) |
412 | + c.Check(apiErr, Equals, ErrMissingIdField) |
413 | +} |
414 | + |
415 | +func (s *handlersSuite) TestDoUnicastCouldNotStoreNotification(c *C) { |
416 | + sto := &interceptInMemoryPendingStore{ |
417 | + store.NewInMemoryPendingStore(), |
418 | + func(meth string, err error) error { |
419 | + if meth == "AppendToUnicastChannel" { |
420 | + return errors.New("fail") |
421 | + } |
422 | + return err |
423 | + }, |
424 | + } |
425 | + ctx := &context{logger: s.testlog} |
426 | + bh := &UnicastHandler{ctx} |
427 | + apiErr := bh.doUnicast(sto, &Unicast{ |
428 | + UserId: "user1", |
429 | + DeviceId: "DEV1", |
430 | + AppId: "app1", |
431 | + ExpireOn: future, |
432 | + Data: json.RawMessage(`{"a": 1}`), |
433 | + }) |
434 | + c.Check(apiErr, Equals, ErrCouldNotStoreNotification) |
435 | + c.Check(s.testlog.Captured(), Equals, "ERROR could not store notification: fail\n") |
436 | +} |
437 | + |
438 | func newPostRequest(path string, message interface{}, server *httptest.Server) *http.Request { |
439 | packedMessage, err := json.Marshal(message) |
440 | if err != nil { |
441 | @@ -274,7 +393,11 @@ |
442 | } |
443 | |
444 | func (bsend testBrokerSending) Unicast(chanIds ...store.InternalChannelId) { |
445 | - // xxx later |
446 | + // for now |
447 | + if len(chanIds) != 1 { |
448 | + panic("not expecting many chan ids for now") |
449 | + } |
450 | + bsend.chanId <- chanIds[0] |
451 | } |
452 | |
453 | func (s *handlersSuite) TestRespondsToBasicSystemBroadcast(c *C) { |
454 | @@ -489,3 +612,73 @@ |
455 | |
456 | checkError(c, response, ErrWrongRequestMethod) |
457 | } |
458 | + |
459 | +func (s *handlersSuite) TestRespondsUnicast(c *C) { |
460 | + sto := store.NewInMemoryPendingStore() |
461 | + stoForReq := func(http.ResponseWriter, *http.Request) (store.PendingStore, error) { |
462 | + return sto, nil |
463 | + } |
464 | + bsend := testBrokerSending{make(chan store.InternalChannelId, 1)} |
465 | + testServer := httptest.NewServer(MakeHandlersMux(stoForReq, bsend, nil)) |
466 | + defer testServer.Close() |
467 | + |
468 | + payload := json.RawMessage(`{"foo":"bar"}`) |
469 | + |
470 | + request := newPostRequest("/notify", &Unicast{ |
471 | + UserId: "user2", |
472 | + DeviceId: "dev3", |
473 | + AppId: "app2", |
474 | + ExpireOn: future, |
475 | + Data: payload, |
476 | + }, testServer) |
477 | + |
478 | + response, err := s.client.Do(request) |
479 | + c.Assert(err, IsNil) |
480 | + |
481 | + c.Check(response.StatusCode, Equals, http.StatusOK) |
482 | + c.Check(response.Header.Get("Content-Type"), Equals, "application/json") |
483 | + body, err := getResponseBody(response) |
484 | + c.Assert(err, IsNil) |
485 | + c.Check(string(body), Matches, ".*ok.*") |
486 | + |
487 | + chanId := store.UnicastInternalChannelId("user2", "dev3") |
488 | + c.Check(<-bsend.chanId, Equals, chanId) |
489 | + top, notifications, err := sto.GetChannelSnapshot(chanId) |
490 | + c.Assert(err, IsNil) |
491 | + c.Check(top, Equals, int64(0)) |
492 | + c.Check(notifications, HasLen, 1) |
493 | +} |
494 | + |
495 | +func (s *handlersSuite) TestCannotUnicastTooBigMessages(c *C) { |
496 | + testServer := httptest.NewServer(&UnicastHandler{}) |
497 | + defer testServer.Close() |
498 | + |
499 | + bigString := strings.Repeat("a", MaxRequestBodyBytes) |
500 | + dataString := fmt.Sprintf(`"%v"`, bigString) |
501 | + |
502 | + request := newPostRequest("/", &Unicast{ |
503 | + ExpireOn: future, |
504 | + Data: json.RawMessage([]byte(dataString)), |
505 | + }, testServer) |
506 | + |
507 | + response, err := s.client.Do(request) |
508 | + c.Assert(err, IsNil) |
509 | + checkError(c, response, ErrRequestBodyTooLarge) |
510 | +} |
511 | + |
512 | +func (s *handlersSuite) TestCannotUnicastWithMissingFields(c *C) { |
513 | + stoForReq := func(http.ResponseWriter, *http.Request) (store.PendingStore, error) { |
514 | + return store.NewInMemoryPendingStore(), nil |
515 | + } |
516 | + ctx := &context{stoForReq, nil, nil} |
517 | + testServer := httptest.NewServer(&UnicastHandler{ctx}) |
518 | + defer testServer.Close() |
519 | + |
520 | + request := newPostRequest("/", &Unicast{ |
521 | + Data: json.RawMessage(`{"foo":"bar"}`), |
522 | + }, testServer) |
523 | + |
524 | + response, err := s.client.Do(request) |
525 | + c.Assert(err, IsNil) |
526 | + checkError(c, response, ErrMissingIdField) |
527 | +} |