mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
The wire carried another vendor's namespace in its error codes (PGRST1xx) and in the media type a caller sends to claim a single row. Both ends are ours, so it said someone else's name for no reason a caller could act on. Two vocabularies meet in a fault and the split is the point: a constraint the STORE refused answers with the store's own SQLSTATE, because every SQL engine already agrees on how to say that; a request the WIRE would not accept answers with a word of ours — bad_query, bad_body, not_one_row, refused, not_found, store_failed — because that is a fact about this API and nobody else gets to name it. Cardinality is asked for with application/vnd.hanzo.object+json. LLM.md described the product as a translation of somebody else's seven services, mapping their names onto ours, which is both wrong (their auth is not Hanzo IAM) and backwards. It says what Base is: one binary, one file, the surfaces it serves, and rules enforced here in Go rather than pushed down. plugins/zap/forward.go still called itself additive to four native message types that plugin.go had already removed — one node, one way in, and the table wire arrives over it like every other route.
547 lines
18 KiB
Go
547 lines
18 KiB
Go
package apis_test
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/tests"
|
|
)
|
|
|
|
// wantRange asserts the Content-Range a scenario should answer with. The count
|
|
// rides in the header on this wire, so a body assertion cannot see it.
|
|
func wantRange(want string) func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
|
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
|
if got := res.Header.Get("Content-Range"); got != want {
|
|
t.Errorf("Content-Range = %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The this wire door over real HTTP, against the same fixtures the collections
|
|
// door is tested on. The translation tests prove the query maps correctly; these
|
|
// prove the wire — the shape of the body, the count in the header, and above all
|
|
// that the door decides who may read a row exactly as the other one does.
|
|
func TestRestApiList(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "a missing table is a 404, as on the other door",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/missing",
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{`"code":"not_found"`, `"details"`, `"hint"`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
// THE property that makes this door safe to expose. It runs the same
|
|
// handler, so the list rule that refuses an unauthenticated caller on
|
|
// /v1/collections/demo1/records must refuse the same caller here. A
|
|
// second door that answered 200 would be a read bypass, and it would
|
|
// look like a feature.
|
|
Name: "a collection with no list rule still needs superuser auth",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo1",
|
|
ExpectedStatus: 403,
|
|
ExpectedContent: []string{`"code":"refused"`, `Only superusers`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
// The wire difference: a BARE ARRAY, not {items,page,perPage,...}.
|
|
// The leading `[{` and the absence of `"items"` are what a another store
|
|
// client depends on.
|
|
Name: "a public table answers a bare array",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`[{`,
|
|
`"id":"0yxhwia2amd8gec"`,
|
|
`"id":"achvryl401bhse3"`,
|
|
`"id":"llvuca81nly1qls"`,
|
|
},
|
|
NotExpectedContent: []string{
|
|
`"items"`,
|
|
`"totalItems"`,
|
|
`"perPage"`,
|
|
},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 3,
|
|
},
|
|
},
|
|
{
|
|
Name: "limit narrows the window",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?limit=1",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`[{`,
|
|
},
|
|
NotExpectedContent: []string{`"items"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
{
|
|
// An unsatisfiable window is refused rather than rounded, so a caller
|
|
// never receives real rows for a range it did not ask for.
|
|
Name: "an offset off a page boundary is refused",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?limit=25&offset=30",
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"code":"bad_query"`, `page boundary`},
|
|
NotExpectedContent: []string{`"data":{}`, `"status":400`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
// THE test whose absence hid a bug that made every filtered read a
|
|
// 400: a filter that is supposed to SUCCEED, executed end to end.
|
|
// demo2 holds test1/test2/test3, so eq on a title returns exactly one.
|
|
Name: "a filter that should match, matches",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=eq.test2",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`"id":"achvryl401bhse3"`,
|
|
`"title":"test2"`,
|
|
},
|
|
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`, `"items"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
{
|
|
Name: "like matches on a wildcard",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=like.*est3*",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"id":"0yxhwia2amd8gec"`},
|
|
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
{
|
|
Name: "in matches several",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=in.(test1,test3)",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"id":"llvuca81nly1qls"`, `"id":"0yxhwia2amd8gec"`},
|
|
NotExpectedContent: []string{`"id":"achvryl401bhse3"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 2,
|
|
},
|
|
},
|
|
{
|
|
Name: "a boolean column filters on is",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?active=is.false",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"id":"llvuca81nly1qls"`},
|
|
NotExpectedContent: []string{`"id":"achvryl401bhse3"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
{
|
|
// A column that is not a field on this collection is refused by the
|
|
// resolver, so a caller cannot name arbitrary SQL as a column.
|
|
Name: "an unknown column is refused",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?nosuchcolumn=eq.x",
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"code":"bad_query"`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
Name: "an unknown operator is refused",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=matches.x",
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"code":"bad_query"`, `unknown operator`},
|
|
NotExpectedContent: []string{`"data":{}`, `"status":400`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|
|
|
|
// The count rides in Content-Range, never in the body — and it is computed only
|
|
// when asked for, so an unasked count reports `*` rather than a number nobody
|
|
// paid for.
|
|
func TestRestApiCount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "no Prefer means no count, and the header says so",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2",
|
|
ExpectedStatus: 200,
|
|
AfterTestFunc: wantRange("0-2/*"),
|
|
ExpectedContent: []string{`[{`, `"id":"llvuca81nly1qls"`},
|
|
NotExpectedContent: []string{`"totalItems"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 3,
|
|
},
|
|
},
|
|
{
|
|
Name: "Prefer: count=exact reports the total",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2",
|
|
Headers: map[string]string{
|
|
"Prefer": "count=exact",
|
|
},
|
|
ExpectedStatus: 200,
|
|
AfterTestFunc: wantRange("0-2/3"),
|
|
ExpectedContent: []string{`[{`, `"id":"llvuca81nly1qls"`},
|
|
NotExpectedContent: []string{`"totalItems"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 3,
|
|
},
|
|
},
|
|
{
|
|
// The range is derived from the window that actually ran, so the
|
|
// second page reports where it really starts rather than 0.
|
|
Name: "the second page reports its own offset",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?limit=2&offset=2",
|
|
Headers: map[string]string{
|
|
"Prefer": "count=exact",
|
|
},
|
|
ExpectedStatus: 200,
|
|
AfterTestFunc: wantRange("2-2/3"),
|
|
ExpectedContent: []string{`"id":"0yxhwia2amd8gec"`},
|
|
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|
|
|
|
// The write verbs. The wire's PATCH and DELETE are FILTERED where Base's are
|
|
// by-id, so these prove the door resolves a filter to rows and then lets Base's
|
|
// own rules decide, rather than reimplementing them.
|
|
func TestRestApiWrites(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
// The catastrophic shape, refused. this wire allows an unfiltered
|
|
// DELETE and a REST client sends it without comment, so the first time
|
|
// anyone learns what it means is after the table is empty.
|
|
Name: "an unfiltered delete is refused",
|
|
Method: http.MethodDelete,
|
|
URL: "/v1/rest/demo2",
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"code":"bad_query"`, `needs a filter`, `every row`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
Name: "an unfiltered update is refused the same way",
|
|
Method: http.MethodPatch,
|
|
URL: "/v1/rest/demo2",
|
|
Body: strings.NewReader(`{"title":"x"}`),
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"code":"bad_query"`, `needs a filter`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
// The error body is the shape clients branch on: message/details/
|
|
// hint/code, never Base's {data,message,status}.
|
|
Name: "an error carries the fields a client reads",
|
|
Method: http.MethodDelete,
|
|
URL: "/v1/rest/demo2",
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"message"`, `"details"`, `"hint"`, `"code"`},
|
|
NotExpectedContent: []string{`"data":{}`, `"status":400`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
// A filtered write still obeys the collection's own rules: demo1 has
|
|
// no list rule, so an unauthenticated caller cannot even resolve the
|
|
// rows, let alone change them.
|
|
Name: "a filtered delete on a guarded collection still refuses",
|
|
Method: http.MethodDelete,
|
|
URL: "/v1/rest/demo1?id=eq.imy661ixudk5izi",
|
|
ExpectedStatus: 403,
|
|
ExpectedContent: []string{`Only superusers`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
{
|
|
Name: "a bulk insert is refused rather than half-applied",
|
|
Method: http.MethodPost,
|
|
URL: "/v1/rest/demo2",
|
|
Body: strings.NewReader(`[{"title":"a"},{"title":"b"}]`),
|
|
ExpectedStatus: 501,
|
|
ExpectedContent: []string{`"code":"bad_query"`, `one object per request`},
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|
|
|
|
// The writes that are supposed to WORK, executed end to end. Their absence is
|
|
// exactly what let a broken filter ship green, so the success path is the part
|
|
// that has to be measured rather than reasoned about.
|
|
func TestRestApiWritesThatSucceed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
// this wire returns nothing unless asked, and a REST client reads an
|
|
// empty body as exactly that. 201, no body.
|
|
Name: "insert returns 201 and no body by default",
|
|
Method: http.MethodPost,
|
|
URL: "/v1/rest/demo2",
|
|
Body: strings.NewReader(`{"title":"from-rest"}`),
|
|
ExpectedStatus: 201,
|
|
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
|
},
|
|
{
|
|
Name: "insert returns the row when asked",
|
|
Method: http.MethodPost,
|
|
URL: "/v1/rest/demo2",
|
|
Body: strings.NewReader(`{"title":"from-rest"}`),
|
|
Headers: map[string]string{
|
|
"Prefer": "return=representation",
|
|
},
|
|
ExpectedStatus: 201,
|
|
ExpectedContent: []string{`"title":"from-rest"`},
|
|
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
|
},
|
|
{
|
|
// .single() on a write: a bare object, not a one-element array. A
|
|
// server that answers `[{...}]` hands every .single() caller an array
|
|
// where they wrote data.id.
|
|
Name: "insert answers a bare object for .single()",
|
|
Method: http.MethodPost,
|
|
URL: "/v1/rest/demo2",
|
|
Body: strings.NewReader(`{"title":"single-rest"}`),
|
|
Headers: map[string]string{
|
|
"Prefer": "return=representation",
|
|
"Accept": "application/vnd.hanzo.object+json",
|
|
},
|
|
ExpectedStatus: 201,
|
|
ExpectedContent: []string{`"title":"single-rest"`},
|
|
NotExpectedContent: []string{`[{`},
|
|
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
|
},
|
|
{
|
|
Name: "a filtered update changes the rows the filter selects",
|
|
Method: http.MethodPatch,
|
|
URL: "/v1/rest/demo2?title=eq.test1",
|
|
Body: strings.NewReader(`{"title":"renamed"}`),
|
|
ExpectedStatus: 204,
|
|
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
|
|
},
|
|
{
|
|
Name: "a filtered update returns the changed rows when asked",
|
|
Method: http.MethodPatch,
|
|
URL: "/v1/rest/demo2?title=eq.test1",
|
|
Body: strings.NewReader(`{"title":"renamed"}`),
|
|
Headers: map[string]string{
|
|
"Prefer": "return=representation",
|
|
},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"title":"renamed"`},
|
|
NotExpectedContent: []string{`"title":"test1"`},
|
|
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
|
|
},
|
|
{
|
|
Name: "a filtered delete removes them",
|
|
Method: http.MethodDelete,
|
|
URL: "/v1/rest/demo2?title=eq.test1",
|
|
ExpectedStatus: 204,
|
|
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
|
|
},
|
|
{
|
|
// A filter that selects nothing is a no-op, not an error — and the
|
|
// count says so.
|
|
Name: "a filter that matches nothing changes nothing",
|
|
Method: http.MethodDelete,
|
|
URL: "/v1/rest/demo2?title=eq.nosuchtitle",
|
|
ExpectedStatus: 204,
|
|
ExpectedEvents: map[string]int{"*": 0},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|
|
|
|
// A duplicate is ordinary application logic, not an exceptional case: clients
|
|
// branch on error.code === '23505' to show "that name is taken". demo2 carries
|
|
// idx_unique_demo2_title, so inserting a title it already holds is the real
|
|
// thing rather than a simulated one.
|
|
func TestRestApiDuplicateIsSQLState23505(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "a unique violation answers 409 with the SQLSTATE",
|
|
Method: http.MethodPost,
|
|
URL: "/v1/rest/demo2",
|
|
Body: strings.NewReader(`{"title":"test1"}`),
|
|
ExpectedStatus: 409,
|
|
ExpectedContent: []string{
|
|
`"code":"23505"`,
|
|
`duplicate key value violates unique constraint`,
|
|
`already exists`,
|
|
},
|
|
NotExpectedContent: []string{`"data":{`, `"status":409`},
|
|
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
|
},
|
|
{
|
|
// The same write on Base's own door keeps Base's shape — the
|
|
// this wire body belongs to the this wire wire, not to the store.
|
|
Name: "the collections door still answers its own shape",
|
|
Method: http.MethodPost,
|
|
URL: "/v1/collections/demo2/records",
|
|
Body: strings.NewReader(`{"title":"test1"}`),
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`validation_not_unique`},
|
|
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|
|
|
|
// A count query is a HEAD. a REST client issues one for
|
|
// .select('*', {count: 'exact', head: true}) and reads the total out of
|
|
// Content-Range — the rows are exactly what it does not want.
|
|
func TestRestApiHeadIsTheCountQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "HEAD answers the count in the header and no body",
|
|
Method: http.MethodHead,
|
|
URL: "/v1/rest/demo2",
|
|
Headers: map[string]string{
|
|
"Prefer": "count=exact",
|
|
},
|
|
ExpectedStatus: 200,
|
|
AfterTestFunc: wantRange("0-2/3"),
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 3,
|
|
},
|
|
},
|
|
{
|
|
// The filter still applies — a count is a read like any other, so it
|
|
// answers for the rows the filter selects rather than the table.
|
|
Name: "a filtered count counts only what matches",
|
|
Method: http.MethodHead,
|
|
URL: "/v1/rest/demo2?title=eq.test2",
|
|
Headers: map[string]string{
|
|
"Prefer": "count=exact",
|
|
},
|
|
ExpectedStatus: 200,
|
|
AfterTestFunc: wantRange("0-0/1"),
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|
|
|
|
// `not.` is a PREFIX, not an operator — it wraps whatever follows. The Table
|
|
// Editor offers "is not null" as a filter, and this is how the wire spells it,
|
|
// so rejecting the prefix would leave a filter the grid can build and the server
|
|
// cannot answer. demo2 has three rows, one of them active=false.
|
|
func TestRestApiNotPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "not.eq excludes the matching row",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=not.eq.test1",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"id":"achvryl401bhse3"`, `"id":"0yxhwia2amd8gec"`},
|
|
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 2,
|
|
},
|
|
},
|
|
{
|
|
Name: "not.in excludes every named value",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=not.in.(test1,test2)",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"id":"0yxhwia2amd8gec"`},
|
|
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`, `"id":"achvryl401bhse3"`},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 1,
|
|
},
|
|
},
|
|
{
|
|
// The one the grid actually offers.
|
|
Name: "not.is.null is how the wire spells IS NOT NULL",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/rest/demo2?title=not.is.null",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`"id":"llvuca81nly1qls"`,
|
|
`"id":"achvryl401bhse3"`,
|
|
`"id":"0yxhwia2amd8gec"`,
|
|
},
|
|
ExpectedEvents: map[string]int{
|
|
"*": 0,
|
|
"OnRecordsListRequest": 1,
|
|
"OnRecordEnrich": 3,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|