commit 9fa415e5c533a1002a44ebb782b1741234cb7f6d
parent 8b9954e2236f05114310154302125698935ee4dd
Author: Bohdan Potuzhnyi <potub1@bfh.ch>
Date: Sat, 10 Oct 2026 22:51:42 +0000
fixing the pagination for /fountains
Diffstat:
7 files changed, 181 insertions(+), 17 deletions(-)
diff --git a/src/backend/taler-merchant-httpd_get-private-fountains.c b/src/backend/taler-merchant-httpd_get-private-fountains.c
@@ -32,11 +32,13 @@
* Add fountain details to the JSON array in @a cls.
*
* @param cls a `json_t *` JSON array to build
+ * @param fountain_serial serial number used as pagination cursor
* @param fountain_id public identifier of the fountain
* @param description description of the fountain
*/
static void
add_fountain (void *cls,
+ uint64_t fountain_serial,
const char *fountain_id,
const char *description)
{
@@ -46,6 +48,8 @@ add_fountain (void *cls,
json_array_append_new (
pa,
GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_uint64 ("fountain_serial",
+ fountain_serial),
GNUNET_JSON_pack_string ("fountain_id",
fountain_id),
GNUNET_JSON_pack_string ("description",
@@ -62,11 +66,28 @@ TMH_private_get_fountains (const struct TMH_RequestHandler *rh,
json_t *pa;
enum GNUNET_DB_QueryStatus qs;
+ int64_t limit = 20;
+ uint64_t offset;
+
+ TALER_MHD_parse_request_snumber (connection,
+ "limit",
+ &limit);
+ offset = limit < 0 ? INT64_MAX : 0;
+ TALER_MHD_parse_request_number (connection,
+ "offset",
+ &offset);
+ if (offset > INT64_MAX)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "offset");
GNUNET_assert (NULL != mi);
pa = json_array ();
GNUNET_assert (NULL != pa);
qs = TALER_MERCHANTDB_iterate_fountains (TMH_db,
mi->settings.id,
+ offset,
+ limit,
&add_fountain,
pa);
if (0 > qs)
diff --git a/src/backenddb/iterate_fountains.c b/src/backenddb/iterate_fountains.c
@@ -63,9 +63,12 @@ lookup_fountains_cb (void *cls,
for (unsigned int i = 0; i < num_results; i++)
{
+ uint64_t fountain_serial;
char *fountain_id;
char *description;
struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("fountain_serial",
+ &fountain_serial),
GNUNET_PQ_result_spec_string ("fountain_id",
&fountain_id),
GNUNET_PQ_result_spec_string ("description",
@@ -83,6 +86,7 @@ lookup_fountains_cb (void *cls,
return;
}
lfc->cb (lfc->cb_cls,
+ fountain_serial,
fountain_id,
description);
GNUNET_PQ_cleanup_result (rs);
@@ -94,15 +98,21 @@ enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_iterate_fountains (
struct TALER_MERCHANTDB_PostgresContext *pg,
const char *instance_id,
+ uint64_t offset,
+ int64_t limit,
TALER_MERCHANTDB_FountainsCallback cb,
void *cb_cls)
{
+ uint64_t plimit = GNUNET_MIN ((uint64_t) 100,
+ TALER_MERCHANTDB_abs_limit (limit));
struct LookupFountainsContext lfc = {
.cb = cb,
.cb_cls = cb_cls,
.extract_failed = false,
};
struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&offset),
+ GNUNET_PQ_query_param_uint64 (&plimit),
GNUNET_PQ_query_param_end
};
enum GNUNET_DB_QueryStatus qs;
@@ -110,11 +120,18 @@ TALER_MERCHANTDB_iterate_fountains (
GNUNET_assert (NULL != pg->current_merchant_id);
GNUNET_assert (0 == strcmp (instance_id,
pg->current_merchant_id));
+ if (offset > INT64_MAX)
+ return GNUNET_DB_STATUS_HARD_ERROR;
TMH_PQ_prepare_anon (pg,
- "SELECT"
- " fountain_id"
- ",description"
- " FROM merchant_fountains");
+ limit >= 0
+ ? "SELECT fountain_serial,fountain_id,description"
+ " FROM merchant_fountains"
+ " WHERE fountain_serial > $1"
+ " ORDER BY fountain_serial ASC LIMIT $2"
+ : "SELECT fountain_serial,fountain_id,description"
+ " FROM merchant_fountains"
+ " WHERE fountain_serial < $1"
+ " ORDER BY fountain_serial DESC LIMIT $2");
qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
"",
params,
diff --git a/src/include/merchant-database/iterate_fountains.h b/src/include/merchant-database/iterate_fountains.h
@@ -31,21 +31,26 @@ struct TALER_MERCHANTDB_PostgresContext;
* Typically called by `iterate_fountains`.
*
* @param cls a `json_t *` JSON array to build
+ * @param fountain_serial serial number used as pagination cursor
* @param fountain_id public identifier of the fountain
* @param description description of the fountain
*/
typedef void
(*TALER_MERCHANTDB_FountainsCallback)(
void *cls,
+ uint64_t fountain_serial,
const char *fountain_id,
const char *description);
/**
- * Iterate over all fountains of an instance.
+ * Iterate over a page of fountains of an instance, ordered by serial.
+ * At most 100 entries are returned.
*
* @param pg database context
* @param instance_id instance to iterate fountains of
+ * @param offset exclusive serial cursor (at most INT64_MAX)
+ * @param limit signed page size; negative selects descending order
* @param cb function to call with each fountain
* @param cb_cls closure for @a cb
* @return database result code
@@ -54,6 +59,8 @@ enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_iterate_fountains (
struct TALER_MERCHANTDB_PostgresContext *pg,
const char *instance_id,
+ uint64_t offset,
+ int64_t limit,
TALER_MERCHANTDB_FountainsCallback cb,
void *cb_cls);
diff --git a/src/include/taler/merchant/get-private-fountains.h b/src/include/taler/merchant/get-private-fountains.h
@@ -36,6 +36,9 @@ struct TALER_MERCHANT_GetPrivateFountainsHandle;
*/
struct TALER_MERCHANT_FountainEntry
{
+ /** Serial number used as an exclusive pagination cursor. */
+ uint64_t fountain_serial;
+
/**
* Public identifier of the fountain.
*/
@@ -119,6 +122,24 @@ TALER_MERCHANT_get_private_fountains_create (
/**
+ * Select a page before starting the operation. Defaults to limit 20, offset 0.
+ * The absolute limit is capped at 100. A negative limit selects descending
+ * serial order (use INT64_MAX for the first page); zero returns an empty page.
+ * Continue using the last entry's fountain_serial as the exclusive offset.
+ *
+ * @param gpfh operation to configure
+ * @param limit signed page size
+ * @param offset exclusive serial cursor, at most INT64_MAX
+ * @return #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_get_private_fountains_set_pagination (
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
+ int64_t limit,
+ uint64_t offset);
+
+
+/**
* Start GET /private/fountains operation.
*
* @param[in,out] gpfh operation to start
diff --git a/src/lib/merchant_api_get-private-fountains.c b/src/lib/merchant_api_get-private-fountains.c
@@ -44,6 +44,10 @@ struct TALER_MERCHANT_GetPrivateFountainsHandle
*/
char *base_url;
+ int64_t limit;
+
+ uint64_t offset;
+
/**
* The full URL for this request.
*/
@@ -101,11 +105,8 @@ handle_ok (struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
gfr);
return;
}
- /* This endpoint is not paginated, so bound allocation bytes rather than
- imposing the much smaller limit used for grants within one fountain.
- Check before narrowing the count or multiplying it by the entry size. */
- if (json_array_size (jfountains) >=
- GNUNET_MAX_MALLOC_CHECKED / sizeof (struct TALER_MERCHANT_FountainEntry))
+ /* Reject responses exceeding the server page cap before allocating. */
+ if (json_array_size (jfountains) > 100)
{
gfr->hr.http_status = 0;
gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
@@ -125,6 +126,8 @@ handle_ok (struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
{
struct TALER_MERCHANT_FountainEntry *fe = &fountains[idx];
struct GNUNET_JSON_Specification ispec[] = {
+ GNUNET_JSON_spec_uint64 ("fountain_serial",
+ &fe->fountain_serial),
GNUNET_JSON_spec_string ("fountain_id",
&fe->fountain_id),
GNUNET_JSON_spec_string ("description",
@@ -223,23 +226,46 @@ TALER_MERCHANT_get_private_fountains_create (
gpfh = GNUNET_new (struct TALER_MERCHANT_GetPrivateFountainsHandle);
gpfh->ctx = ctx;
+ gpfh->limit = 20;
gpfh->base_url = GNUNET_strdup (url);
return gpfh;
}
enum TALER_ErrorCode
+TALER_MERCHANT_get_private_fountains_set_pagination (
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
+ int64_t limit,
+ uint64_t offset)
+{
+ if ( (offset > INT64_MAX) || (NULL != gpfh->job) )
+ return TALER_EC_GENERIC_PARAMETER_MALFORMED;
+ gpfh->limit = limit;
+ gpfh->offset = offset;
+ return TALER_EC_NONE;
+}
+
+
+enum TALER_ErrorCode
TALER_MERCHANT_get_private_fountains_start (
struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
TALER_MERCHANT_GetPrivateFountainsCallback cb,
TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls)
{
CURL *eh;
+ char limit_s[32];
+ char offset_s[32];
gpfh->cb = cb;
gpfh->cb_cls = cb_cls;
+ GNUNET_snprintf (limit_s, sizeof (limit_s), "%lld",
+ (long long) gpfh->limit);
+ GNUNET_snprintf (offset_s, sizeof (offset_s), "%llu",
+ (unsigned long long) gpfh->offset);
gpfh->url = TALER_url_join (gpfh->base_url,
"private/fountains",
+ "limit", limit_s,
+ "offset", offset_s,
NULL);
if (NULL == gpfh->url)
return TALER_EC_GENERIC_CONFIGURATION_INVALID;
diff --git a/src/lib/test_fountain_parsers.c b/src/lib/test_fountain_parsers.c
@@ -196,8 +196,8 @@ check_nested_arrays (json_t *info)
json_t *domain = json_string ("merchant.example");
GNUNET_CRYPTO_blind_sign_keys_create (&priv.private_key,
- &pub.public_key,
- GNUNET_CRYPTO_BSA_CS);
+ &pub.public_key,
+ GNUNET_CRYPTO_BSA_CS);
key = GNUNET_JSON_PACK (
TALER_JSON_pack_token_pub (NULL, &pub),
GNUNET_JSON_pack_timestamp ("signature_validity_start", start),
@@ -265,7 +265,8 @@ main (void)
GNUNET_JSON_pack_data_auto ("h_issue", &hash),
GNUNET_JSON_pack_array_steal ("token_sigs", json_array ()));
- json_t *list = json_pack ("{s:s,s:s}",
+ json_t *list = json_pack ("{s:i,s:s,s:s}",
+ "fountain_serial", 1,
"fountain_id", "test", "description", "Test");
json_t *valid_grants[] = { info, withdraw, list, info };
unsigned int large_counts[] = { 100000, 100000, 600000, 300000 };
@@ -289,20 +290,20 @@ main (void)
check (grants, mode, true);
json_array_clear (grants);
for (unsigned int i = 0;
- i < TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS; i++)
+ i < (2 == mode ? 100 : TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS); i++)
GNUNET_assert (0 == json_array_append (grants, valid));
check (grants, mode, false);
GNUNET_assert (0 == json_array_append (grants, valid));
- check (grants, mode, 2 != mode);
+ check (grants, mode, true);
json_array_clear (grants);
for (unsigned int i = 0; i < large_counts[mode]; i++)
GNUNET_assert (0 == json_array_append_new (grants, json_null ()));
check (grants, mode, true);
}
- /* A list is not capped at the grant limit, but still has a byte budget. */
+ /* Fountain listings reject responses beyond the page cap. */
json_array_clear (grants);
for (size_t i = 0;
- i < GNUNET_MAX_MALLOC_CHECKED / sizeof (struct TALER_MERCHANT_FountainEntry);
+ i < 101;
i++)
GNUNET_assert (0 == json_array_append (grants, list));
check (grants, 2, true);
diff --git a/src/testing/test_merchant_fountains.sh b/src/testing/test_merchant_fountains.sh
@@ -220,6 +220,77 @@ then
fi
echo "Ok" >&2
+# Exercise the default page, hard cap, both directions and invalid cursors.
+echo -n "Checking bounded fountain pagination..." >&2
+PAGINATION_IDS=()
+for ((i=0; i<104; i++))
+do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains" -X POST \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\":\"pagination-$i\",\"poll_freq\":$POLL_FREQ,\"grants\":[]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ]; then
+ exit_fail "Failed to create pagination fixture ($STATUS)"
+ fi
+ PAGINATION_IDS+=("$(jq -er .fountain_id "$LAST_RESPONSE")")
+done
+for QUERY in '' '?limit=1000' '?limit=-9223372036854775808' '?limit=0'
+do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains$QUERY" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ EXPECTED=100
+ [ -z "$QUERY" ] && EXPECTED=20
+ [ "$QUERY" = '?limit=0' ] && EXPECTED=0
+ if [ "$STATUS" != "200" ] || ! jq -e --argjson n "$EXPECTED" \
+ '.fountains | length == $n' "$LAST_RESPONSE" > /dev/null; then
+ exit_fail "Unexpected page size for $QUERY ($STATUS)"
+ fi
+done
+for LIMIT in 17 -17
+do
+ OFFSET=0
+ [ "$LIMIT" -lt 0 ] && OFFSET=9223372036854775807
+ SEEN='[]'
+ while true
+ do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains?limit=$LIMIT&offset=$OFFSET" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ]; then
+ exit_fail "Pagination failed ($STATUS)"
+ fi
+ PAGE=$(jq -c '[.fountains[].fountain_serial]' "$LAST_RESPONSE")
+ [ "$PAGE" = '[]' ] && break
+ if ! jq -ne --argjson p "$PAGE" --argjson o "$OFFSET" --argjson l "$LIMIT" \
+ 'if $l > 0 then ($p == ($p | sort)) and all($p[]; . > $o)
+ else ($p == ($p | sort | reverse)) and all($p[]; . < $o) end' > /dev/null; then
+ exit_fail "Fountain page is unordered or includes its cursor"
+ fi
+ SEEN=$(jq -nc --argjson a "$SEEN" --argjson b "$PAGE" '$a + $b')
+ OFFSET=$(jq -r '.[-1]' <<< "$PAGE")
+ done
+ if ! jq -ne --argjson a "$SEEN" \
+ '($a | length) == 105 and ($a | unique | length) == 105' > /dev/null; then
+ exit_fail "Pagination skipped or repeated fountains"
+ fi
+done
+for QUERY in '?limit=invalid' '?offset=invalid' '?offset=-1' '?offset=9223372036854775808'
+do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains$QUERY" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ [ "$STATUS" = "400" ] || exit_fail "Expected 400 for $QUERY, got $STATUS"
+done
+for ID in "${PAGINATION_IDS[@]}"
+do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$ID" -X DELETE \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ [ "$STATUS" = "204" ] || exit_fail "Failed to remove pagination fixture"
+done
+echo "Ok" >&2
+
#
# LIST AND INSPECT
#