💳 Secure Payment

Full-Service Web & Software Agency · Klamath Falls and Redding

Endpoints at the boundary

Work with Sean

An endpoint is where the client meets the API. On the Sean Dinwiddie’s Webmastery team, endpoints are generated from the API’s own contract into one API root, reviewed like any other code, decoded before a view sees what they return, and tagged so the right views refetch.

This lesson carries the orders from The API: Haskell Servant and Nile into the client, beside the cart from From scenario to slice.

1. One API root per base URL

Every endpoint that talks to the same server joins one API root: one cache, one middleware, and tags that reach across features, so an order placed at checkout refreshes the order history on another screen. A second backend, such as a CMS that serves the shop’s pages or a search service, gets a root of its own.

The root starts with no endpoints, declares its tag types, and sends the signed-in user’s token on every request as the bearer token the API’s Auth combinator reads:

// app/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { RootState } from './store'

const baseQuery = fetchBaseQuery({
  baseUrl: 'https://api.example.com/',
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as RootState).session.token
    if (token) headers.set('Authorization', `Bearer ${token}`)
    return headers
  },
})

export const api = createApi({
  reducerPath: 'api',
  baseQuery,
  tagTypes: ['Order'],
  endpoints: () => ({}),
})

The store registers the root’s reducer and its middleware once; without the middleware, nothing subscribes, refetches or invalidates. The lecture Redux Toolkit and RTK Query Best Practices shows what a second root for the same backend costs.

2. Generate the endpoints, then review them

The API writes its contract to an OpenAPI document from the Servant type on every build. RTK Query’s code generator reads that document and injects typed endpoints into the root:

// openapi-config.ts
import type { ConfigFile } from '@rtk-query/codegen-openapi'

const config = {
  schemaFile: './openapi.json',
  apiFile: './src/app/api.ts',
  apiImport: 'api',
  outputFile: './src/features/orders/ordersApi.generated.ts',
  exportName: 'generatedOrdersApi',
  hooks: true,
  filterEndpoints: ['listOrders', 'placeOrder'],
} satisfies ConfigFile

export default config

A config written in TypeScript, as this one is, needs esbuild-runner or ts-node installed beside the generator. Three things shape what it generates:

  • Each endpoint is named for its route’s operationId, which the API sets as it builds the document; without one, the generator would build a name from the method and the path.
  • filterEndpoints keeps only the routes this feature owns.
  • When the generator guesses wrong, such as a search sent as a POST, which it would make a mutation, an entry in endpointOverrides corrects it.

The generated file is read in review like any other source and never edited by hand: the config changes, the generator runs again, and the diff is reviewed. The lecture Redux Toolkit and RTK Query Best Practices lists what that review checks.

3. Decode at the boundary

The generated types say what the API promised; they don’t check what arrives. A server deployed ahead of its client, or a proxy’s error page, can answer with something else. So every response is unknown until a decoder accepts it, written as a pure function that returns the lectures’ small Either:

// features/orders/decodeOrders.ts
import { left, right, type Either } from '../../shared/either'
import type { Order } from './ordersApi.generated'

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null

const isLine = (value: unknown) =>
  isRecord(value) &&
  typeof value.productId === 'string' &&
  typeof value.name === 'string' &&
  Number.isInteger(value.priceCents) &&
  Number.isInteger(value.quantity)

const isOrder = (value: unknown): value is Order =>
  isRecord(value) &&
  typeof value.id === 'string' &&
  Number.isInteger(value.totalCents) &&
  Array.isArray(value.lines) &&
  value.lines.every(isLine)

export const decodeOrder = (raw: unknown): Either<string, Order> =>
  isOrder(raw) ? right(raw) : left('Expected an order')

export const decodeOrders = (raw: unknown): Either<string, Order[]> =>
  Array.isArray(raw) && raw.every(isOrder) ? right(raw) : left('Expected a list of orders')

The root runs the decoder an endpoint names before anything is cached, and a payload that fails becomes an ordinary error the view can show, never a crash:

// app/api.ts, continued
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError, FetchBaseQueryMeta } from '@reduxjs/toolkit/query/react'
import type { Either } from '../shared/either'

type Decode = (raw: unknown) => Either<string, unknown>

const decodingBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, { decode?: Decode }, FetchBaseQueryMeta> =
  async (args, queryApi, extraOptions) => {
    const result = await baseQuery(args, queryApi, {})
    const decode = extraOptions?.decode
    if (result.error || !decode) return result
    const decoded = decode(result.data)
    return decoded._tag === 'Right'
      ? { data: decoded.value, meta: result.meta }
      : { error: { status: 'CUSTOM_ERROR', error: decoded.error, data: result.data }, meta: result.meta }
  }

In the finished file, decodingBaseQuery sits between baseQuery and createApi, which takes it in place of baseQuery.

Decoding at the boundary A response comes back from baseQuery as unknown data and goes to the endpoint's decoder. The decoder returns Right or Left. A highlighted line runs through Right to data that is cached for the view. Left becomes a CUSTOM_ERROR with the raw payload beside it, an error the view can show. baseQueryunknowndecode(result.data)RightLeftcached dataCUSTOM_ERRORraw payload keptfor the view
Every response is unknown until its endpoint’s decoder accepts it. The violet line is a payload that passes and is cached; one that fails becomes a CUSTOM_ERROR, with the raw payload beside it, for the view to show.

An endpoint that names no decoder passes through unchecked, so the review checks that every endpoint names one; the cart in the depth passage below leaves its decoder out only to keep the patch in view.

RTK Query can also run the same check itself, as an endpoint’s responseSchema, for validators written to the Standard Schema interface. The lecture Practical Applications of Functional Programming holds to the same rule: an endpoint checks the response before any component receives it.

4. Tags say what to refetch

A query provides tags for what it holds, and a mutation invalidates the tags its change makes stale. The generated endpoints take their decoders and tags in one reviewed file beside the generated one:

// features/orders/ordersApi.ts
import { generatedOrdersApi } from './ordersApi.generated'
import { decodeOrder, decodeOrders } from './decodeOrders'

export const ordersApi = generatedOrdersApi.enhanceEndpoints({
  endpoints: {
    listOrders: {
      extraOptions: { decode: decodeOrders },
      providesTags: (orders = []) => [
        { type: 'Order', id: 'LIST' },
        ...orders.map(({ id }) => ({ type: 'Order' as const, id })),
      ],
    },
    placeOrder: {
      extraOptions: { decode: decodeOrder },
      invalidatesTags: (order) => (order ? [{ type: 'Order', id: 'LIST' }] : []),
    },
  },
})

export const { useListOrdersQuery, usePlaceOrderMutation } = ordersApi

When the API accepts an order, the list is stale, and every view showing it refetches. When the API refuses, the callback returns no tags: RTK Query invalidates on a handled error too, and a refused order changes nothing. A list with no view subscribed is dropped instead, and fetched fresh when a view next asks for it.

What a placed order refetches Two rows. The highlighted row: the order is accepted, the order list's tag (type Order, id LIST) is invalidated, and listOrders, which provides that tag, refetches. The plain row: the order is refused, the callback returns no tags, and the history stays as it was. after placeOrder answersacceptedthe orderorder listtag invalidatedrefetchlistOrdersrefusedthe orderno tagsreturnedunchangedthe history
An accepted order invalidates the order list’s tag, { type: 'Order', id: 'LIST' }, and listOrders, which provides it, refetches for every view that shows it (the violet row). A refused order gets no tags from the callback, so the history stays as it was.

The tags are written here, where they are reviewed, because tags generated from the document are only as fine as the document’s own grouping. The lecture Redux Toolkit and RTK Query Best Practices walks through invalidation.

5. The scenario at the endpoint

An endpoint is tested against a controlled boundary: a real store with the root’s reducer and middleware, and a stand-in for the server behind fetch.

The view builds the new order from the slice’s lines, product and quantity only; the price the customer saw is for display, and the API prices the order in its own transaction. The test carries the same scenario name as the API’s and the slice’s:

// features/orders/ordersApi.test.ts
import { configureStore } from '@reduxjs/toolkit'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../app/api'
import { cartSlice, itemAdded, selectLines } from '../cart/cartSlice'
import { ordersApi } from './ordersApi'

const tenantId = '018ade1a-7830-7981-b23f-f3a7f7b8f09f'
const placed = {
  id: 'order-1',
  lines: [{ productId: 'widget-a', name: 'Widget A', priceCents: 2500, quantity: 1 }],
  totalCents: 2500,
}

// The server, controlled: it records what is posted and lists what it holds.
const fakeServer = () => {
  const orders: (typeof placed)[] = []
  return async (request: Request) => {
    if (request.method === 'POST') {
      orders.push(placed)
      return Response.json(placed, { status: 201 })
    }
    return Response.json(orders)
  }
}

const makeStore = () =>
  configureStore({
    reducer: {
      [cartSlice.reducerPath]: cartSlice.reducer,
      [api.reducerPath]: api.reducer,
      session: () => ({ token: 'test-token' }),
    },
    middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware),
  })

afterEach(() => vi.unstubAllGlobals())

describe('Feature: Checkout', () => {
  it('Scenario: Placing the order', async () => {
    const server = vi.fn(fakeServer())
    vi.stubGlobal('fetch', server)
    const store = makeStore()
    // Given Ada's cart holds "Widget A" at $25, and her order history is on screen
    store.dispatch(itemAdded({ productId: 'widget-a', name: 'Widget A', priceCents: 2500 }))
    const history = store.dispatch(ordersApi.endpoints.listOrders.initiate({ tenantId }))
    await history
    // When she places the order
    await store.dispatch(
      ordersApi.endpoints.placeOrder.initiate({
        tenantId,
        newOrder: { lines: [{ productId: 'widget-a', quantity: 1 }] },
      }),
    )
    // Then the order is recorded at $25, her history shows it, and her cart is empty
    await vi.waitFor(() =>
      expect(ordersApi.endpoints.listOrders.select({ tenantId })(store.getState()).data).toEqual([placed]),
    )
    expect(selectLines(store.getState())).toEqual([])
    const [[listed], [posted]] = server.mock.calls
    expect(listed.headers.get('Authorization')).toBe('Bearer test-token')
    expect(await posted.json()).toEqual({ lines: [{ productId: 'widget-a', quantity: 1 }] })
    history.unsubscribe()
  })
})

The same file checks the failure paths: a response that isn’t an order ends as a CUSTOM_ERROR with the raw payload beside it, and a refused order leaves the history as it was. The lecture Redux Toolkit and RTK Query Best Practices lists what each layer’s tests cover, tag invalidation and optimistic rollback included.

The endpoint sits between the slice and the API, and each owner proves its own part of one scenario under the same name: the Hspec test that the API records the order, and this test that the history shows it and that the slice, hearing the order accepted, empties the cart.

The view that places the order stays minimal, and The view stays minimal builds it: it reads the cart through selectors, calls usePlaceOrderMutation with each line’s product and quantity, and shows what the endpoint returns.

Copyright Sean Paul Payne Dinwiddie
All Rights Reserved