Skip to content

Commit 7204831

Browse files
committed
Fix pagination, add roundtrip / golden tests.
1 parent 5699ebd commit 7204831

File tree

16 files changed

+223
-47
lines changed

16 files changed

+223
-47
lines changed

libs/wire-api/src/Wire/API/Error/Brig.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ data BrigError
115115
| RateLimitExceeded
116116
| MlsRemovalNotAllowed
117117
| UserGroupNotFound
118+
| UserGroupInvalidQueryParams
118119
| UserGroupNotATeamAdmin
119120
| UserGroupMemberIsNotInTheSameTeam
120121

@@ -346,6 +347,8 @@ type instance MapError 'MlsRemovalNotAllowed = 'StaticError 409 "mls-protocol-er
346347

347348
type instance MapError 'UserGroupNotFound = 'StaticError 404 "user-group-not-found" "User group not found"
348349

350+
type instance MapError 'UserGroupInvalidQueryParams = 'StaticError 400 "user-group-invalid-query" "Query parameters for listing user groups are invalid"
351+
349352
type instance MapError 'UserGroupNotATeamAdmin = 'StaticError 403 "user-group-write-forbidden" "Only team admins can create, update, or delete user groups."
350353

351354
type instance MapError 'UserGroupMemberIsNotInTheSameTeam = 'StaticError 400 "user-group-invalid" "Only team members of the same team can be added to a user group."

libs/wire-api/src/Wire/API/Pagination.hs

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,26 @@
1818
module Wire.API.Pagination where
1919

2020
import Data.Aeson qualified as A
21+
import Data.Bifunctor (first)
22+
import Data.ByteString.Lazy qualified as LB
2123
import Data.Default
22-
import Data.Json.Util
24+
import Data.Kind
2325
import Data.OpenApi qualified as S
2426
import Data.OpenApi.ParamSchema qualified as O
27+
import Data.Proxy
2528
import Data.Range
2629
import Data.Schema
30+
import Data.Text qualified as T
31+
import Data.Text.Encoding qualified as T
2732
import GHC.Generics
2833
import GHC.TypeLits
2934
import Imports
3035
import Servant.API
31-
import Wire.Arbitrary
36+
import Test.QuickCheck.Gen as Arbitrary
37+
import Wire.Arbitrary as Arbitrary
3238

3339
-- | (Is there an elegant way to enforce `allowedKeyFieldsInfo` before the handler kicks in?)
34-
type PaginationQuery (allowedKeyFieldsInfo :: Symbol) api =
40+
type PaginationQuery (allowedKeyFieldsInfo :: Symbol) (rowKeys :: Type) (row :: Type) =
3541
QueryParam'
3642
'[Optional, Strict, Description "Search string"]
3743
"q"
@@ -43,6 +49,7 @@ type PaginationQuery (allowedKeyFieldsInfo :: Symbol) api =
4349
( "Sort key(s): comma-separated list of field names. Must \
4450
\match sort keys encoded in pagination state. Allowed Fields: "
4551
`AppendSymbol` allowedKeyFieldsInfo
52+
-- we need `allowedKeyFieldInfo` because `SortBy` contains that information only on the value level.
4653
)
4754
]
4855
"sortBy"
@@ -58,25 +65,31 @@ type PaginationQuery (allowedKeyFieldsInfo :: Symbol) api =
5865
:> QueryParam'
5966
'[Optional, Strict, Description "Pagination state from last response (opaque to clients)"]
6067
"paginationState"
61-
PaginationState
62-
:> api
68+
(PaginationState rowKeys)
69+
:> Get '[JSON] (PaginationResult rowKeys row)
6370

6471
data SortBy = SortBy {fromSortBy :: [Text]}
6572
deriving (Eq, Ord, Show, Generic)
6673
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via Schema SortBy
6774

75+
instance Arbitrary SortBy where
76+
arbitrary = SortBy <$> arbitrary
77+
6878
instance ToSchema SortBy where
69-
schema = undefined
79+
schema = object "SortBy" $ SortBy <$> (.fromSortBy) .= field "keys" (array schema)
7080

7181
instance FromHttpApiData SortBy where
72-
parseUrlPiece = undefined
82+
parseUrlPiece = parseUrlPieceViaSchema
7383

7484
instance O.ToParamSchema SortBy
7585

7686
data SortOrder = Asc | Desc
77-
deriving (Eq, Show, Ord, Enum, Generic)
87+
deriving (Eq, Show, Ord, Enum, Bounded, Generic)
7888
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via Schema SortOrder
7989

90+
instance Arbitrary SortOrder where
91+
arbitrary = Arbitrary.elements [minBound ..]
92+
8093
instance Default SortOrder where
8194
def = Desc
8295

@@ -89,7 +102,7 @@ instance ToSchema SortOrder where
89102
]
90103

91104
instance FromHttpApiData SortOrder where
92-
parseUrlPiece = undefined
105+
parseUrlPiece = parseUrlPieceViaSchema
93106

94107
instance O.ToParamSchema SortOrder
95108

@@ -102,7 +115,7 @@ pageSizeToInt = fromRange . fromPageSize
102115

103116
-- | Doesn't crash on bad input, but shrinks it into the allowed range.
104117
pageSizeFromIntUnsafe :: Int -> PageSize
105-
pageSizeFromIntUnsafe = PageSize . (unsafeRange @Int @1 @500) . (`mod` 500) . (+ 1)
118+
pageSizeFromIntUnsafe = PageSize . (unsafeRange @Int @1 @500) . (+ 1) . (`mod` 500)
106119

107120
instance Arbitrary PageSize where
108121
arbitrary = pageSizeFromIntUnsafe <$> arbitrary
@@ -111,49 +124,60 @@ instance ToSchema PageSize where
111124
schema = PageSize <$> fromPageSize .= schema
112125

113126
instance FromHttpApiData PageSize where
114-
parseUrlPiece = undefined
127+
parseUrlPiece = parseUrlPieceViaSchema
115128

116129
instance O.ToParamSchema PageSize
117130

118131
instance Default PageSize where
119132
def = PageSize (unsafeRange 15)
120133

121-
data PaginationState = PaginationState
122-
{ searchString :: Text,
134+
data PaginationState key = PaginationState
135+
{ searchString :: Text, -- TODO: this shouldn't be in the state, but maintained separately. or we need to make it more polymorhpic, maybe?
123136
sortByKeys :: SortBy,
124137
sortOrder :: SortOrder,
125138
pageSize :: PageSize,
126-
lastRowSent :: (Text, UTCTimeMillis)
139+
lastRowSent :: key
127140
}
128141
deriving (Eq, Show, Generic)
129-
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via Schema PaginationState
142+
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via Schema (PaginationState key)
130143

131-
instance ToSchema PaginationState where
144+
instance (ToSchema key) => ToSchema (PaginationState key) where
132145
schema =
133146
object "PagintationState" $
134147
PaginationState
135148
<$> (.searchString) .= field "search_string" schema
136-
<*> (.sortBy) .= field "sort_by" (array schema)
149+
<*> (.sortByKeys) .= field "sort_by" schema
137150
<*> (.sortOrder) .= field "sort_order" schema
138151
<*> (.pageSize) .= field "page_size" schema
139-
<*> (.lastRowSent) .= field "last_row_sent" (array schema)
152+
<*> (.lastRowSent) .= field "last_row_sent" schema
153+
154+
instance (Arbitrary key) => Arbitrary (PaginationState key) where
155+
arbitrary = PaginationState <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary
140156

141-
instance FromHttpApiData PaginationState where
142-
parseUrlPiece = undefined
157+
instance (ToSchema key) => FromHttpApiData (PaginationState key) where
158+
parseUrlPiece = parseUrlPieceViaSchema
143159

144-
instance O.ToParamSchema PaginationState where
145-
toParamSchema = undefined
160+
instance O.ToParamSchema (PaginationState key) where
161+
toParamSchema _ =
162+
-- PaginationState is supposed to be opaque for clients, no need to swagger docs.
163+
O.toParamSchema (Proxy @Text)
146164

147-
data PaginationResult a = PaginationResult
148-
{ page :: [a],
149-
state :: PaginationState
165+
data PaginationResult key row = PaginationResult
166+
{ page :: [row],
167+
state :: PaginationState key
150168
}
151169
deriving (Eq, Show, Generic)
152-
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via Schema (PaginationResult a)
170+
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via Schema (PaginationResult key row)
153171

154-
instance (ToSchema a) => ToSchema (PaginationResult a) where
172+
instance (ToSchema key, ToSchema row) => ToSchema (PaginationResult key row) where
155173
schema =
156174
object "PagintationResult" $
157175
PaginationResult
158176
<$> page .= field "page" (array schema)
159177
<*> state .= field "state" schema
178+
179+
instance (Arbitrary key, Arbitrary row) => Arbitrary (PaginationResult key row) where
180+
arbitrary = PaginationResult <$> arbitrary <*> arbitrary
181+
182+
parseUrlPieceViaSchema :: (A.FromJSON a) => Text -> Either Text a
183+
parseUrlPieceViaSchema = first T.pack . A.eitherDecode . LB.fromStrict . T.encodeUtf8

libs/wire-api/src/Wire/API/Routes/Public/Brig.hs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import Data.CommaSeparatedList (CommaSeparatedList)
2727
import Data.Domain
2828
import Data.Handle
2929
import Data.Id as Id
30+
import Data.Json.Util (UTCTimeMillis)
3031
import Data.Misc
3132
import Data.Nonce (Nonce)
3233
import Data.OpenApi hiding (Contact, Header, Schema, ToSchema)
@@ -321,7 +322,7 @@ type UserGroupAPI =
321322
( From 'V9
322323
:> ZLocalUser
323324
:> "user-groups"
324-
:> PaginationQuery "created_at, name" (Get '[JSON] (PaginationResult UserGroup))
325+
:> PaginationQuery "created_at, name" UserGroupKey UserGroup
325326
)
326327
:<|> Named
327328
"update-user-group"

libs/wire-api/src/Wire/API/UserGroup.hs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,18 @@ instance ToSchema UserGroup where
9595
<*> (.members) .= field "members" (vector schema)
9696
<*> (.managedBy) .= field "managedBy" schema
9797
<*> (.createdAt) .= field "createdAt" schema
98+
99+
data UserGroupKey = UserGroupKey
100+
{ name :: UserGroupName,
101+
createdAt :: UTCTimeMillis
102+
}
103+
deriving (Eq, Ord, Show, Generic)
104+
deriving (Arbitrary) via GenericUniform UserGroupKey
105+
deriving (A.ToJSON, A.FromJSON, OpenApi.ToSchema) via Schema UserGroupKey
106+
107+
instance ToSchema UserGroupKey where
108+
schema =
109+
object "UserGroupKey" $
110+
UserGroupKey
111+
<$> (.name) .= field "name" schema
112+
<*> (.createdAt) .= field "createdAt" schema

libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import Test.Wire.API.Golden.Manual.ListUsersById
4747
import Test.Wire.API.Golden.Manual.LoginId_user
4848
import Test.Wire.API.Golden.Manual.Login_user
4949
import Test.Wire.API.Golden.Manual.MLSKeys
50+
import Test.Wire.API.Golden.Manual.Pagination
5051
import Test.Wire.API.Golden.Manual.Presence
5152
import Test.Wire.API.Golden.Manual.Push
5253
import Test.Wire.API.Golden.Manual.PushRemove
@@ -68,7 +69,13 @@ tests :: TestTree
6869
tests =
6970
testGroup
7071
"Manual golden tests"
71-
[ testGroup "NewUserGroup" $
72+
[ testGroup "PaginationResult" $
73+
testObjects
74+
[ (testObject_PaginationResult_1, "testObject_PaginationResult_1.json"),
75+
(testObject_PaginationResult_2, "testObject_PaginationResult_2.json"),
76+
(testObject_PaginationResult_3, "testObject_PaginationResult_3.json")
77+
],
78+
testGroup "NewUserGroup" $
7279
testObjects
7380
[ (testObject_NewUserGroup_1, "testObject_NewUserGroup_1.json"),
7481
(testObject_NewUserGroup_2, "testObject_NewUserGroup_2.json")
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
2+
3+
module Test.Wire.API.Golden.Manual.Pagination where
4+
5+
import Data.Default
6+
import Data.Json.Util
7+
import Imports
8+
import Wire.API.Pagination
9+
10+
someUTCTime :: UTCTimeMillis
11+
Just someUTCTime = readUTCTimeMillis "2025-04-16T16:22:21.703Z"
12+
13+
someOtherUTCTime :: UTCTimeMillis
14+
Just someOtherUTCTime = readUTCTimeMillis "2021-12-12T00:00:00.000Z"
15+
16+
testObject_PaginationResult_1 :: PaginationResult Int Int
17+
testObject_PaginationResult_1 =
18+
PaginationResult
19+
[]
20+
PaginationState
21+
{ searchString = "",
22+
sortByKeys = SortBy [],
23+
sortOrder = def,
24+
pageSize = def,
25+
lastRowSent = -1
26+
}
27+
28+
testObject_PaginationResult_2 :: PaginationResult Int Int
29+
testObject_PaginationResult_2 =
30+
PaginationResult
31+
[3, 5]
32+
PaginationState
33+
{ searchString = "q",
34+
sortByKeys = SortBy ["key1", "key2"],
35+
sortOrder = Asc,
36+
pageSize = pageSizeFromIntUnsafe 500,
37+
lastRowSent = 3
38+
}
39+
40+
testObject_PaginationResult_3 :: PaginationResult Int Int
41+
testObject_PaginationResult_3 =
42+
PaginationResult
43+
[7 .. 12]
44+
PaginationState
45+
{ searchString = "rst",
46+
sortByKeys = SortBy ["key1", "", "key2"],
47+
sortOrder = Asc,
48+
pageSize = pageSizeFromIntUnsafe 1,
49+
lastRowSent = 10
50+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"page": [],
3+
"state": {
4+
"last_row_sent": -1,
5+
"page_size": 15,
6+
"search_string": "",
7+
"sort_by": {
8+
"keys": []
9+
},
10+
"sort_order": "desc"
11+
}
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"page": [
3+
3,
4+
5
5+
],
6+
"state": {
7+
"last_row_sent": 3,
8+
"page_size": 1,
9+
"search_string": "q",
10+
"sort_by": {
11+
"keys": [
12+
"key1",
13+
"key2"
14+
]
15+
},
16+
"sort_order": "asc"
17+
}
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"page": [
3+
7,
4+
8,
5+
9,
6+
10,
7+
11,
8+
12
9+
],
10+
"state": {
11+
"last_row_sent": 10,
12+
"page_size": 2,
13+
"search_string": "rst",
14+
"sort_by": {
15+
"keys": [
16+
"key1",
17+
"",
18+
"key2"
19+
]
20+
},
21+
"sort_order": "asc"
22+
}
23+
}

libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import Wire.API.FederationStatus qualified as FederationStatus
4444
import Wire.API.Locale qualified as Locale
4545
import Wire.API.Message qualified as Message
4646
import Wire.API.OAuth qualified as OAuth
47+
import Wire.API.Pagination qualified as Pagination
4748
import Wire.API.Properties qualified as Properties
4849
import Wire.API.Provider qualified as Provider
4950
import Wire.API.Provider.Bot qualified as Provider.Bot
@@ -168,6 +169,7 @@ tests =
168169
testRoundTrip @OAuth.CreateOAuthAuthorizationCodeRequest,
169170
testRoundTrip @OAuth.OAuthAccessTokenRequest,
170171
testRoundTrip @OAuth.OAuthApplication,
172+
testRoundTrip @(Pagination.PaginationResult Int Bool),
171173
testRoundTrip @Properties.PropertyKey,
172174
testRoundTrip @Provider.Provider,
173175
testRoundTrip @Provider.ProviderProfile,
@@ -345,6 +347,7 @@ tests =
345347
testRoundTrip @UserGroup.NewUserGroup,
346348
testRoundTrip @UserGroup.UserGroupUpdate,
347349
testRoundTrip @UserGroup.UserGroup,
350+
testRoundTrip @UserGroup.UserGroupKey,
348351
testRoundTrip @EventWebSocketProtocol.MessageServerToClient,
349352
testRoundTrip @EventWebSocketProtocol.MessageClientToServer,
350353
testRoundTrip @(Wrapped.Wrapped "some_int" Int),

0 commit comments

Comments
 (0)