merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/backend/taler-merchant-httpd_get-private-fountains.c | 21+++++++++++++++++++++
Msrc/backenddb/iterate_fountains.c | 25+++++++++++++++++++++----
Msrc/include/merchant-database/iterate_fountains.h | 9++++++++-
Msrc/include/taler/merchant/get-private-fountains.h | 21+++++++++++++++++++++
Msrc/lib/merchant_api_get-private-fountains.c | 36+++++++++++++++++++++++++++++++-----
Msrc/lib/test_fountain_parsers.c | 15++++++++-------
Msrc/testing/test_merchant_fountains.sh | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 #