Teaching the catalog API filters, pagination, and honest errors

Teaching the catalog API filters, pagination, and honest errors

The catalog has been living on flows for two articles now: the first one gave it a JSON API and a closed Vue admin, the second added reader accounts with email confirmation and roles. The API itself stayed where it started, though: one endpoint that returns every book at once. Four records were fine; eleven are already a stretch, and the interface wants search, a status filter, and pages. Four tasks — search, filtering, pages, and honest errors — are handled in the same site flow: filters and pagination live inside the graphql node, validation in conditions, and debugging in a dry run before publishing and the runs journal after.

Everything lives in the dynapi-vue-admin repository, branch step-6-api. It continues step-5-accounts: walk through the first two articles first.

Step 1. A contract instead of a bare array

In the first article, GET /api/books answered with a raw array and the interface took it as is. The endpoint now has a contract. Input — the query string:

GET /api/books?status=on_loan&page=1

Output — an envelope:

{
  "items": [
    { "id": "...", "title": "The Twelve Chairs", "author": "Ilya Ilf, Yevgeny Petrov", "year": 1928, "status": "on_loan" }
  ],
  "total": 4,
  "pages": 1
}

items is one page of data, total is the number of books after filtering, pages is the page count, five books each. The envelope exists because the current page’s array doesn’t tell the interface how many records exist overall — and without that number there is no pager to draw.

The catalog admin: a filter bar with search and status, a five-row table, and the “Page 1 of 3” pager

Step 2. Filters inside the graphql node

The same graphql node from the first article handles filtering, except the query now takes its variables from the page’s query string:

{
  "id": "a_q",
  "type": "action",
  "data": {
    "action_type": "graphql",
    "on_error": "stop",
    "config": {
      "query": "query($status: String, $q: String) {\n  books(limit: 5, offset: {{ offset }}, orderBy: [{field: {{ field }}, direction: DESC}], search: $q, where: {status: {eq: $status}}) {\n    id title author year status\n  }\n  booksCount(search: $q, where: {status: {eq: $status}})\n}",
      "variables": {
        "status": "{{query.status}}",
        "q": "{{query.q}}"
      },
      "result_key": "q",
      "secret_vars": []
    }
  }
}

Three mechanisms at work here. query.<name> exposes the query string of a GET flow: open /api/books?status=on_loan and {{query.status}} renders as on_loan. The search argument scans string fields case-insensitively, so one box covers titles and authors alike. And the key part — an empty variable never becomes a filter. When status is missing from the URL, the variable renders as an empty string and the platform drops it from the request entirely, so the where clause simply doesn’t apply. One query serves every case, with no branching into “filtered query” and “unfiltered query” variants.

The {{ offset }} and {{ field }} values in the query text are not defined yet — the next two steps, pagination and sorting, take care of them.

A status that doesn’t exist returns an empty items and a zero total — an honest answer to the request, not an error.

Searching for Dostoevsky in the admin: two of his novels remain in the table and the counter reads two

Step 3. Page number into offset

Pagination combines two numbers: records per page (fixed at 5 in the demo) and the requested page. The GraphQL offset argument is that page number converted into a shift, and Liquid computes it right in the query text:

{% assign page = query.page | plus: 0 %}{% if page < 1 %}{% assign page = 1 %}{% endif %}{% assign offset = page | minus: 1 | times: 5 %}

plus: 0 turns the string parameter into a number. A missing parameter and garbage like ?page=abc both become zero through the same operation, and the condition lifts zero to the first page. Negative and fractional page numbers never reach GraphQL.

The respond node builds the other half, assembling the envelope from the graphql node’s result:

{% assign total = results.q.booksCount | plus: 0 %}{"items": {{ results.q.books | json }}, "total": {{ total }}, "pages": {{ total | plus: 4 | divided_by: 5 }}}

booksCount is an auto-generated companion to the list field: it accepts the same where and search, so it counts the filtered books rather than the whole catalog. The page count comes from integer division of total + 4 by the page size — a ceiling without extra filters.

Step 4. Sorting through a whitelist

The orderBy argument takes an enum — TITLE, AUTHOR, YEAR — rather than a string. The enum guards against typos, but an unknown value would produce a GraphQL error instead of data, so the sort parameter passes through a whitelist before it reaches the query:

{% if query.sort == "title" %}{% assign field = "TITLE" %}{% elsif query.sort == "author" %}{% assign field = "AUTHOR" %}{% else %}{% assign field = "YEAR" %}{% endif %}

Any unknown value falls back to sorting by year. The node reads nicely in the editor, too: the whole template is visible, assigns on top, query below.

The books_list flow editor: the graphql node panel with the query template — page and sort assigns above the GraphQL text, variables fed from query.*

Step 5. Errors the interface can show

POST /api/books/add gets its own gate conditions. The first two demand a non-empty title and author:

{
  "id": "c_title",
  "type": "condition",
  "data": {
    "predicate": {
      "combinator": "and",
      "clauses": [
        { "field": "json.title", "operator": "matches", "value": "\\S" }
      ]
    }
  }
}

The \S regular expression requires at least one non-whitespace character, so both an empty field and a string of spaces fall into the else branch. The next gate checks the year when one arrives: the gt and lt operators compare numerically, and the 1800–2100 range weeds out typos along with fantastical publication years. Every rejection answers with status 400, the field name, and a message:

{
  "id": "a_err_year",
  "type": "action",
  "data": {
    "action_type": "respond_json",
    "config": {
      "status": 400,
      "body_template": "{\"error\": \"VALIDATION\", \"field\": \"year\", \"message\": \"The year must be a number between 1800 and 2100\"}"
    }
  }
}

The interface, for its part, is freed from guessing: the required attributes are gone, the form submits whatever it holds, and the flow returns the specifics. The app reads field and message from the response and highlights the offending input:

if (!res.ok) {
  const data = await res.json().catch(() => null);
  if (res.status === 400 && data?.field) {
    invalidField.value = data.field;
    serverMessage.value = data.message ?? "Check the form fields.";
  }
  return;
}

The validation contract lives on the server, in one place — the flow. A web UI, a mobile client, or a Telegram bot all get the same messages without anyone duplicating the checks.

An empty add form: the “Provide a title” banner and a red outline around the title field, the catalog below unaffected

Step 6. Debugging before and after publishing

A flow with branches deserves a check before publishing — the same flowctl dryrun from the first articles. Put a mock of the graphql node’s result into the context so the respond node assembles the envelope from real numbers:

{
  "method": "GET",
  "graphqlResults": {
    "q": { "books": [{ "title": "We", "year": 1920 }], "booksCount": 8 }
  }
}

The report shows both nodes green and the assembled response:

directives (1):
  - {"body": "{\"items\": [{...}], \"total\": 8, \"pages\": 2}", "status": 200}

A dry run never executes GraphQL; the mock stands in for the query. The syntax of the generated query only surfaces on a live run, which is what the runs journal is for.

The journal lives on the “Runs” tab (or in flowctl runs <slug>) and keeps every execution: which conditions took which branch, where a node failed. Here is a story from debugging the demo: someone submitted 1990.5 as the year. The range conditions let it through — the fractional number sits within bounds. The next node fell over:

node                   type              runs  errors      avg  last
a_create               graphql              2       1      14ms  ✗

last error per failing node:
  a_create: graphql.variables: variable "year" is not a valid Int: "1990.5"

The trick is that the client saw success — 201 and {"ok": true, "id": ...}. When a flow dies without reaching a respond node, the platform answers with the standard form-submission echo, and the book never appears in the catalog. The mismatch between the response and the data surfaced in the journal: a red node with the error text. The cure is one clause on the year condition:

{ "field": "json.year", "operator": "matches", "value": "^\\d{4}$" }

The “Runs” tab: a run with the “failed” badge, a red graphql node and the non-integer year diagnosis, next to a healthy run

What stayed off-screen

The delete route is unchanged since the second article: the role gate and its respond_json 403 run on the same conditions, and request validation slots into it following step five. Throughput limits live outside the flow as well: request rate is a project-level setting derived from the plan, with no per-route knob.

Next in the series: files and media — giving the catalog covers without blowing through the storage quota. Every .dynflow.json from this article sits in the step-6-api branch and applies with flowctl apply flows/books_list.dynflow.json --publish.


All posts