Skip to content

Commit 57aeeb5

Browse files
committed
feat(service): add pagination and result limits to Query endpoints (#265)
Add QueryPageRequest/QueryPageResponse types and automatic pagination to all query endpoints. Response format changes from bare JSON array to paginated object with items, total, hasMore, and effectiveLimit. - New Service.Query.Pagination module with types and parsing - Query typeclass extended with maxResultsImpl for per-query limits - Default limit: 100, absolute max: 1000 - Offset capped at 10M to prevent arithmetic overflow - Web transport parses ?limit=N&offset=M query parameters - Security: total count computed after auth filtering only - effectiveLimit field surfaces any silent capping to clients
1 parent de6c362 commit 57aeeb5

12 files changed

Lines changed: 601 additions & 47 deletions

File tree

core/nhcore.cabal

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ library
249249
Service.Query.Core
250250
Service.Query.Definition
251251
Service.Query.Endpoint
252+
Service.Query.Pagination
252253
Service.Query.Registry
253254
Service.Query.Subscriber
254255
Service.Query.TH
@@ -416,6 +417,7 @@ test-suite nhcore-test
416417
IntegrationSpec
417418
IntSpec
418419
OutboundIntegrationSpec
420+
EventVariantOfSpec
419421
LogSpec
420422
DecimalSpec
421423
Service.ApplicationSpec
@@ -428,6 +430,7 @@ test-suite nhcore-test
428430
Service.EventStore.PostgresSpec
429431
Service.MockTransport
430432
Service.Query.EndpointSpec
433+
Service.Query.PaginationSpec
431434
Service.Query.RegistrySpec
432435
Service.Query.SubscriberSpec
433436
Service.Query.THSpec
@@ -527,6 +530,7 @@ test-suite nhcore-test-service
527530
Service.FileUpload.RoutesSpec
528531
Service.MockTransport
529532
Service.Query.EndpointSpec
533+
Service.Query.PaginationSpec
530534
Service.Query.RegistrySpec
531535
Service.Query.SubscriberSpec
532536
Service.Query.THSpec

core/service/Service/Query/Core.hs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Basics
1919
import Maybe (Maybe)
2020
import Service.Entity.Core (Entity)
2121
import Service.Query.Auth (QueryAuthError)
22+
import Service.Query.Pagination (absoluteMaxLimit)
2223
import Uuid (Uuid)
2324

2425

@@ -86,6 +87,13 @@ class Query query where
8687
-- This is called AFTER data is fetched, allowing ownership checks.
8788
canViewImpl :: Maybe UserClaims -> query -> Maybe QueryAuthError
8889

90+
-- | Maximum results this query type will return in a single page.
91+
--
92+
-- Default: 'absoluteMaxLimit' (1000). To override, define
93+
-- @maxResults :: Int@ in your query module before calling @deriveQuery@.
94+
maxResultsImpl :: Int
95+
maxResultsImpl = absoluteMaxLimit
96+
8997

9098
-- | Defines how an entity contributes to a query (read model).
9199
--

core/service/Service/Query/Definition.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ createDefinitionWithStore storeFactory = do
146146
-- 2. Wire all entities and collect their registries
147147
registry <- wireEntities @entities @query queryNameText rawEventStore queryStore
148148

149-
-- 3. Create endpoint handler (lambda accepts Maybe UserClaims and Maybe NeoQL.Expr)
150-
let endpoint userClaims maybeExpr = Endpoint.createQueryEndpoint queryStore userClaims maybeExpr
149+
-- 3. Create endpoint handler (lambda accepts Maybe UserClaims, Maybe NeoQL.Expr, and QueryPageRequest)
150+
let endpoint userClaims maybeExpr pageRequest = Endpoint.createQueryEndpoint queryStore userClaims maybeExpr pageRequest
151151

152152
Task.yield (registry, (queryNameText, endpoint, endpointSchema))
153153
}

core/service/Service/Query/Endpoint.hs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Maybe (Maybe (..))
1010
import Service.Query.Auth (QueryEndpointError)
1111
import Service.Query.Auth qualified as Auth
1212
import Service.Query.Core (Query (..))
13+
import Service.Query.Pagination (QueryPageRequest (..), QueryPageResponse (..))
1314
import Service.QueryObjectStore.Core (Error (..), QueryObjectStore (..))
1415
import Task (Task)
1516
import Task qualified
@@ -20,20 +21,21 @@ import NeoQL qualified
2021

2122
-- | Create an endpoint handler for a query type.
2223
--
23-
-- Returns all instances of the query as a JSON array.
24-
-- Used for the HTTP endpoint: GET /queries/{query-name}
24+
-- Returns a paginated response with items, total count, hasMore flag,
25+
-- and the effective limit that was applied.
2526
--
2627
-- This handler performs two-phase authorization:
2728
-- 1. canAccessImpl (pre-fetch): "Can this user access this query type at all?"
2829
-- 2. canViewImpl (post-fetch): "Can this user view this specific instance?"
2930
--
30-
-- Returns typed QueryEndpointError for proper HTTP status mapping.
31+
-- Pagination is applied AFTER authorization and NeoQL filtering.
32+
-- The @total@ count reflects only authorized, filtered results.
3133
--
3234
-- Example:
3335
--
3436
-- @
3537
-- handler <- Endpoint.createQueryEndpoint @UserOrders queryStore
36-
-- -- Returns: "[{\"userId\":\"...\",\"orders\":[...]}, ...]"
38+
-- -- Returns: "{\"items\":[...],\"total\":142,\"hasMore\":true,\"effectiveLimit\":100}"
3739
-- @
3840
createQueryEndpoint ::
3941
forall query.
@@ -43,8 +45,9 @@ createQueryEndpoint ::
4345
QueryObjectStore query ->
4446
Maybe UserClaims ->
4547
Maybe Expr ->
48+
QueryPageRequest ->
4649
Task QueryEndpointError Text
47-
createQueryEndpoint queryStore userClaims maybeExpr = do
50+
createQueryEndpoint queryStore userClaims maybeExpr pageRequest = do
4851
-- Phase 1: Pre-fetch authorization
4952
case canAccessImpl @query userClaims of
5053
Just authErr -> Task.throw (Auth.AuthorizationError authErr)
@@ -66,7 +69,23 @@ createQueryEndpoint queryStore userClaims maybeExpr = do
6669
authorizedQueries
6770
|> Array.takeIf (\query -> NeoQL.execute expr (Json.toJSON query))
6871

69-
let responseText = filteredQueries |> Json.encodeText
72+
-- SECURITY: total computed AFTER canViewImpl and NeoQL filtering.
73+
-- Exposing pre-auth count would leak record existence to unauthorized users.
74+
let total = Array.length filteredQueries
75+
let effectiveLimit = min pageRequest.limit (maxResultsImpl @query)
76+
let pagedItems =
77+
filteredQueries
78+
|> Array.drop pageRequest.offset
79+
|> Array.take effectiveLimit
80+
let hasMore = pageRequest.offset + effectiveLimit < total
81+
let response = QueryPageResponse
82+
{ items = pagedItems
83+
, total = total
84+
, hasMore = hasMore
85+
, effectiveLimit = effectiveLimit
86+
}
87+
88+
let responseText = response |> Json.encodeText
7089

7190
Task.yield responseText
7291

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
-- | Pagination types and utilities for query endpoints.
2+
--
3+
-- Provides 'QueryPageRequest' for parsing client pagination parameters
4+
-- and 'QueryPageResponse' for wrapping paginated results with metadata.
5+
--
6+
-- Pagination is applied automatically by the query endpoint handler.
7+
-- Jess doesn't need to do anything — all query endpoints are paginated
8+
-- by default with sensible limits.
9+
--
10+
-- To override the maximum results per page for a specific query, define
11+
-- @maxResults :: Int@ in the query module before calling @deriveQuery@.
12+
module Service.Query.Pagination (
13+
-- * Types
14+
QueryPageRequest (..),
15+
QueryPageResponse (..),
16+
17+
-- * Constants
18+
defaultLimit,
19+
absoluteMaxLimit,
20+
21+
-- * Parsing
22+
parsePageRequest,
23+
) where
24+
25+
import Array (Array)
26+
import Basics
27+
import Json qualified
28+
import Maybe (Maybe (..))
29+
import Maybe qualified
30+
import Text (Text)
31+
import Text qualified
32+
33+
34+
-- | Pagination request parameters parsed from query string.
35+
--
36+
-- @
37+
-- -- Parse from raw query params:
38+
-- let pageRequest = parsePageRequest (Just "25") (Just "50")
39+
-- -- QueryPageRequest { limit = 25, offset = 50 }
40+
-- @
41+
data QueryPageRequest = QueryPageRequest
42+
{ limit :: !Int
43+
, offset :: !Int
44+
}
45+
deriving (Eq, Show, Generic)
46+
47+
48+
-- | Paginated response wrapper with metadata.
49+
--
50+
-- The @total@ field reflects the count of items AFTER authorization filtering.
51+
-- It never exposes counts of records the user is not authorized to see.
52+
--
53+
-- The @effectiveLimit@ field surfaces any capping that was applied.
54+
-- When the client requests @limit=5000@ but the server caps to 1000,
55+
-- @effectiveLimit@ will be 1000 so the client can detect the difference.
56+
--
57+
-- @
58+
-- -- Example response:
59+
-- -- { "items": [...], "total": 142, "hasMore": true, "effectiveLimit": 100 }
60+
-- @
61+
data QueryPageResponse a = QueryPageResponse
62+
{ items :: !(Array a)
63+
, total :: !Int
64+
, hasMore :: !Bool
65+
, effectiveLimit :: !Int
66+
}
67+
deriving (Eq, Show, Generic)
68+
69+
instance (Json.ToJSON a) => Json.ToJSON (QueryPageResponse a)
70+
instance (Json.FromJSON a) => Json.FromJSON (QueryPageResponse a)
71+
72+
73+
-- | Default page size when no limit is specified (100).
74+
defaultLimit :: Int
75+
defaultLimit = 100
76+
77+
78+
-- | Absolute maximum page size — hard cap (1000).
79+
absoluteMaxLimit :: Int
80+
absoluteMaxLimit = 1000
81+
82+
83+
-- | Parse pagination parameters from raw query string values.
84+
--
85+
-- Invalid or missing values fall back to defaults.
86+
-- Limit is clamped to @[1, absoluteMaxLimit]@.
87+
-- Offset is clamped to @[0, 10_000_000]@.
88+
--
89+
-- @
90+
-- parsePageRequest (Just "25") (Just "50")
91+
-- -- QueryPageRequest { limit = 25, offset = 50 }
92+
--
93+
-- parsePageRequest Nothing Nothing
94+
-- -- QueryPageRequest { limit = 100, offset = 0 }
95+
--
96+
-- parsePageRequest (Just "abc") (Just "-5")
97+
-- -- QueryPageRequest { limit = 100, offset = 0 }
98+
-- @
99+
parsePageRequest :: Maybe Text -> Maybe Text -> QueryPageRequest
100+
parsePageRequest maybeLimitText maybeOffsetText = do
101+
let parsedLimit =
102+
maybeLimitText
103+
|> Maybe.andThen Text.toInt
104+
|> Maybe.withDefault defaultLimit
105+
let parsedOffset =
106+
maybeOffsetText
107+
|> Maybe.andThen Text.toInt
108+
|> Maybe.withDefault 0
109+
QueryPageRequest
110+
{ limit = parsedLimit |> max 1 |> min absoluteMaxLimit
111+
, offset = parsedOffset |> max 0 |> min 10_000_000
112+
}

core/service/Service/Transport.hs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Schema (Schema)
1616
import Service.Auth (RequestContext, UserClaims)
1717
import Service.Command.Core (Command, NameOf)
1818
import Service.Query.Auth (QueryEndpointError)
19+
import Service.Query.Pagination (QueryPageRequest)
1920
import Service.Response (CommandResponse)
2021
import Task (Task)
2122
import Text (Text)
@@ -44,7 +45,7 @@ type EndpointHandler = RequestContext -> Bytes -> ((CommandResponse, Bytes) -> T
4445
-- - StorageError -> 500
4546
--
4647
-- Used for GET /queries/{query-name} endpoints.
47-
type QueryEndpointHandler = Maybe UserClaims -> Maybe Expr -> Task QueryEndpointError Text
48+
type QueryEndpointHandler = Maybe UserClaims -> Maybe Expr -> QueryPageRequest -> Task QueryEndpointError Text
4849

4950

5051
-- | Schema information for an endpoint (command or query).

core/service/Service/Transport/Web.hs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import Service.FileUpload.Core qualified as FileUpload
5353
import Service.FileUpload.Web (FileUploadRoutes (..))
5454
import Service.CommandExecutor.TH (deriveKnownHash)
5555
import Service.Query.Auth (QueryAuthError (..), QueryEndpointError (..))
56+
import Service.Query.Pagination qualified as Pagination
5657
import Service.Response (CommandResponse)
5758
import Service.Response qualified as Response
5859
import Service.Transport (EndpointHandler, Endpoints (..), Transport (..))
@@ -616,11 +617,15 @@ instance Transport WebTransport where
616617
let errBody = [fmt|{"error":"parse_error","message":#{safeMsg}}|]
617618
badRequest errBody respond
618619
Result.Ok maybeExpr -> do
620+
-- Parse pagination parameters
621+
let pageRequest = Pagination.parsePageRequest
622+
(getQueryParam "limit")
623+
(getQueryParam "offset")
619624
-- Helper to process query with given user claims
620625
let processQueryWithClaims userClaims = do
621626
-- Execute the query handler with error recovery
622627
-- Handler performs internal canAccess/canView checks
623-
result <- handler userClaims maybeExpr |> Task.asResult
628+
result <- handler userClaims maybeExpr pageRequest |> Task.asResult
624629
case result of
625630
Result.Ok responseText -> okJson responseText
626631
Result.Err endpointError ->

0 commit comments

Comments
 (0)