http
If your app talks to a backend that already exists, such as a Laravel, Rails, Adonis or Go API, you need a client that adds the user's login token to every request, tries once to refresh it when it expires, and turns every kind of failure into one predictable error. @kitr/http is that client.
It is ky, re-exported, plus createHttpClient, which adds the token, refresh, CSRF and error handling, and two helpers, httpQuery and httpMutation, that turn endpoints into Query contracts. The app gets one client, bound in the container as http.
It runs in the browser, and on the server when a page renders.
This guide follows Acme's order screen at app.acme.com talking to an API at api.acme.com.
When to use it
Use @kitr/http when the API lives outside this app: a separate backend, a vendor's REST API, another team's service. Describe each endpoint once with httpQuery or httpMutation, and components read it through queries.
Use API for endpoints you write in this app's server/ folder (its client is typed from your routes), and Actions for a server function called from a form or button.
Install
kitr add httpFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add does:
- Adds Query first if the project does not have it, because
httprequires it. - Installs
@kitr/httpandky. - Adds
'@kitr/http'topackagesand() => import('@kitr/http/provider')toprovidersinkitrrc.ts. - Adds
VITE_API_URL: e.url({ default: 'http://localhost:3333' })to thepublicblock ofcreateEnvinapp/env.ts. - Adds
VITE_API_URL=http://localhost:3333to.env.example, and to.envwhen that file exists. - Writes
config/http.ts(shown under Configure). - Adds a
## kitr: httpsection toAGENTS.md: the client isapp.container.use('http'), endpoints are wrapped withhttpQuery/httpMutation, and errors are alreadyKitrError.
kitr add http asks no questions. To see the changes without writing them:
kitr add http --dry-runFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
What kitr remove does
kitr remove http removes the provider, the packages entry, the ky and @kitr/http dependencies, the AGENTS.md section and config/http.ts. It leaves VITE_API_URL in app/env.ts and .env.example, and lists the files that still import @kitr/http.
Configure
kitr add http writes this file:
// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
// getToken: () => auth.getToken(), refresh: () => auth.refresh(),
})
| Option | Default | Description |
|---|---|---|
baseUrl |
none | Prefix for every relative path. http.get('orders') with baseUrl: 'https://api.acme.com/v1' requests https://api.acme.com/v1/orders. |
getToken |
none | Called before every request with the container. Return a token (or a promise of one) to send it; return null or undefined to send none. |
refresh |
none | Called once when a response is 401, with the container. Return true when a new token is ready; the request then retries once with it. |
| onUnauthorized | none | Called with the KitrError whenever a request finally fails with 401, for example to send the user to sign in. |
| authHeader | 'Authorization' | Header that carries the token. With the default the value is Bearer <token>; any other header gets the raw token. |
| retry | { limit: 2, methods: ['get', 'put', 'head', 'delete', 'options', 'trace'] } | ky's retry rule. POST and PATCH are never retried, so a write is never sent twice. |
| timeout | 10_000 | ky's per-request timeout in milliseconds, or false for none. |
| hooks | {} | ky hooks. Yours run after kitr's own beforeRequest, afterResponse and beforeError hooks. |
| headers, credentials, fetch, ... | ky's defaults | Every other ky option except prefixUrl passes through unchanged. |
getToken and refresh receive the container the client belongs to. On a server that is the request's own container, so getToken: (container) => container.use('auth').getToken() sends the token of the user being served and never another's.
| Variable | Used for |
|---|---|
VITE_API_URL |
The API's base URL, read through env.VITE_API_URL. Public, so it ships to the browser. Defaults to http://localhost:3333. |
To point production at the real API, set the variable where the app is built:
VITE_API_URL=https://api.acme.com/v1 pnpm build
config/http.ts is a module, so you can compute options in it. This one sends cookies to an API on another origin and names the client in a header:
// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
credentials: 'include',
headers: { 'x-acme-client': 'web' },
timeout: 15_000,
})
Load your first endpoint
Describe the endpoint once in the feature's queries.ts: httpQuery makes a query contract and queryFrom makes it usable by components and loaders:
// app/queries/orders.ts
import { app } from '#app/app'
import { createKeys } from '@kitr/kitr/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'
export interface Order {
id: string
customer: string
total: number
status: 'open' | 'paid' | 'shipped'
}
const http = () => app.container.use('http')
export const orderKeys = createKeys('orders')
export const ordersQuery = queryFrom(
httpQuery<void, Order[]>(http, {
path: 'orders',
key: () => orderKeys.list(),
}),
)
http is a function that returns the client, because the route table loads before the app boots, when the container has no http binding yet. Pass the client directly only from code that runs after boot.
Then show it on a page:
// app/routes.ts
import { ordersQuery } from '#app/queries/orders'
r.page('/orders', {
loader: ({ context }) => context.queryClient.ensureQueryData(ordersQuery.options()),
lazy: () => import('./pages/orders'),
})
// app/pages/orders.tsx
import { useSuspenseQuery } from '@kitr/query'
import { ordersQuery } from '#app/queries/orders'
export default function OrdersPage() {
const { data: orders } = useSuspenseQuery(ordersQuery.options())
return (
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.customer}: ${order.total}
</li>
))}
</ul>
)
}
Open /orders. The loader calls GET http://localhost:3333/orders and the page lists what the API returns, such as Ada: $120. If the API is down, the route's error component shows the KitrError.
Read the client
The provider binds one client as http in the container. It is a normal ky instance. Outside components, read it from the app:
// app/lib/invoices/download.ts
import { app } from '#app/app'
const http = app.container.use('http')
export async function downloadInvoice(id: string) {
return http.get(`invoices/${id}/pdf`).blob()
}
In a component, read it with useService from @kitr/react:
// app/components/invoices/download-button.tsx
import { useService } from '@kitr/react'
export function DownloadButton({ id }: { id: string }) {
const http = useService('http')
const download = async () => {
const pdf = await http.get(`invoices/${id}/pdf`).blob()
window.open(URL.createObjectURL(pdf))
}
return (
<button type="button" onClick={download}>
Download PDF
</button>
)
}
Prefer httpQuery for anything a screen shows; use the client directly for one-off calls such as a download.
Call an endpoint directly
The client has a method per HTTP verb. Each returns a response promise with .json(), .text(), .blob() and .arrayBuffer():
// app/lib/orders/api.ts
import { app } from '#app/app'
import type { Order } from '#app/queries/orders'
const http = app.container.use('http')
const orders = await http.get('orders', { searchParams: { status: 'open', page: 2 } }).json<Order[]>()
// GET https://api.acme.com/v1/orders?status=open&page=2
const created = await http.post('orders', { json: { customer: 'Linus', total: 45 } }).json<Order>()
// => { id: 'ord_3', customer: 'Linus', total: 45, status: 'open' }
await http.delete(`orders/${created.id}`)
A leading slash makes no difference, and an absolute URL ignores baseUrl. For a client with extra defaults, call ky's extend; it keeps the token, refresh and error handling:
// app/lib/billing/client.ts
import { app } from '#app/app'
export const billing = app.container.use('http').extend({
headers: { 'x-acme-team': 'billing' },
timeout: 30_000,
})
Turn an endpoint into a query
httpQuery(client, options) returns a query contract that sends a GET. Pass it to queryFrom:
// app/queries/orders.ts
import { app } from '#app/app'
import { createKeys } from '@kitr/kitr/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'
import type { Order } from './types'
const http = () => app.container.use('http')
export const orderKeys = createKeys('orders')
export const orderQuery = queryFrom(
httpQuery<string, Order>(http, {
path: (id) => `orders/${id}`,
key: (id) => orderKeys.detail(id),
}),
)
export const ordersQuery = queryFrom(
httpQuery<{ status?: Order['status']; page: number }, Order[]>(http, {
path: 'orders',
key: (filters) => orderKeys.list(filters),
searchParams: ({ status, page }) => ({ status, page }),
staleTime: 10_000,
}),
)
| Option | Default | Description |
|---|---|---|
path |
required | A path relative to baseUrl, or a function of the input that returns one. |
key |
required | The cache key for an input. Include every input that changes the result. |
searchParams |
none | A function of the input that returns query string values. undefined values are left out, so { status: undefined, page: 1 } sends ?page=1. |
schema |
none | A Standard Schema that validates the response and types the result. |
options |
{} |
ky options for this request only, such as headers or timeout. |
staleTime |
the config's | How long this query's data stays fresh, in milliseconds. |
Validate the response
An API you don't control can change shape. Give httpQuery or httpMutation a schema from zod, valibot or any Standard Schema library: a response that fails it becomes a VALIDATION error instead of a crash later, and the schema types the result:
// app/queries/invoices.ts
import { app } from '#app/app'
import { createKeys } from '@kitr/kitr/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'
import { z } from 'zod'
const http = () => app.container.use('http')
export const invoiceKeys = createKeys('invoices')
const invoice = z.object({
id: z.string(),
orderId: z.string(),
total: z.number(),
paidAt: z.string().nullable(),
})
export const invoicesQuery = queryFrom(
httpQuery(http, {
path: 'invoices',
key: () => invoiceKeys.list(),
schema: z.array(invoice),
}),
)
When the API sends { "total": "80.00" } where a number is expected, the query fails with:
// => KitrError { code: 'VALIDATION', status: 422, message: 'Validation failed', fieldErrors: { '0.total': ['Invalid input: expected number, received string'] } }
The key is the path to the bad value (0.total: the first invoice's total); the message comes from your schema library.
Turn an endpoint into a mutation
httpMutation(client, options) returns a mutation contract for mutationFrom and useMutation. Declare effects so every screen showing the changed data updates:
// app/queries/orders.ts
import { app } from '#app/app'
import { httpMutation } from '@kitr/http'
import { mutationFrom } from '@kitr/query'
import { orderKeys } from './keys'
import type { Order } from './types'
const http = () => app.container.use('http')
export const createOrder = mutationFrom(
httpMutation<{ customer: string; total: number }, Order>(http, {
path: 'orders',
effects: { invalidate: [orderKeys.lists()] },
}),
)
export const shipOrder = mutationFrom(
httpMutation<{ id: string; carrier: string }, Order>(http, {
method: 'post',
path: (input) => `orders/${input.id}/ship`,
body: (input) => ({ carrier: input.carrier }),
effects: (order) => ({
set: [{ key: orderKeys.detail(order.id), data: order }],
invalidate: [orderKeys.lists()],
}),
}),
)
// app/components/orders/ship-button.tsx
import { useMutation } from '@kitr/query'
import { shipOrder } from '#app/queries/orders'
export function ShipButton({ id }: { id: string }) {
const ship = useMutation(shipOrder)
return (
<button type="button" disabled={ship.isPending} onClick={() => ship.mutate({ id, carrier: 'UPS' })}>
Ship
</button>
)
}
| Option | Default | Description |
|---|---|---|
method |
'post' |
One of 'get', 'post', 'put', 'patch', 'delete'. |
path |
required | A path, or a function of the input that returns one. |
body |
the input | Builds the JSON body from the input. By default the input is the body (none for get and delete). |
schema |
none | A Standard Schema that validates the response and types the result. |
effects |
none | Cache effects after success: an object, or a function of (result, input). See Query. |
options |
{} |
ky options for this request only. |
Delete and update records
delete sends no body by default, and a 204 No Content response resolves to undefined, so the result type is void:
// app/queries/projects.ts
import { app } from '#app/app'
import { createKeys } from '@kitr/kitr/app'
import { httpMutation } from '@kitr/http'
import { mutationFrom } from '@kitr/query'
const http = () => app.container.use('http')
export const projectKeys = createKeys('projects')
export const deleteProject = mutationFrom(
httpMutation<{ id: string }, void>(http, {
method: 'delete',
path: ({ id }) => `projects/${id}`,
effects: (_result, { id }) => ({
remove: [projectKeys.detail(id)],
invalidate: [projectKeys.lists()],
}),
}),
)
export const renameProject = mutationFrom(
httpMutation<{ id: string; name: string }, { id: string; name: string }>(http, {
method: 'patch',
path: ({ id }) => `projects/${id}`,
body: ({ name }) => ({ name }),
effects: (project) => ({ set: [{ key: projectKeys.detail(project.id), data: project }] }),
}),
)
renameProject sends PATCH /projects/prj_1 with the body {"name":"Website"}; the id stays in the URL because body leaves it out.
Upload a file
httpMutation always sends JSON. For a file, call the client with a FormData body (for uploads with progress and size limits, see Uploads):
// app/lib/invoices/import.ts
import { app } from '#app/app'
import { mutationFrom } from '@kitr/query'
import { invoiceKeys } from '#app/queries/invoices'
const http = app.container.use('http')
export const importInvoices = mutationFrom({
mutate: async (file: File) => {
const form = new FormData()
form.append('file', file)
return http.post('invoices/import', { body: form, timeout: 60_000 }).json<{ imported: number }>()
},
effects: { invalidate: [invoiceKeys.lists()] },
})
// => { imported: 42 }
Send the login token
getToken runs before every request; a returned token is sent as Authorization: Bearer <token>. With Auth's token adapter, keep the adapter in its own module and point the client at it:
// app/auth/adapter.ts
import { localStorageTokens, tokenAdapter } from '@kitr/auth'
import { env } from '#app/env'
export const authAdapter = tokenAdapter<{ id: string; email: string }>({
storage: localStorageTokens('acme:token'),
refreshStorage: localStorageTokens('acme:refresh'),
fetchUser: async (token) => {
const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } })
return res.ok ? res.json() : null
},
refresh: async (refreshToken) => {
const res = await fetch(`${env.VITE_API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
return res.ok ? res.json() : null
},
})
// config/http.ts
import { defineConfig } from '@kitr/http'
import { authAdapter } from '#app/auth/adapter'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
getToken: () => authAdapter.getToken?.(),
refresh: async () => (await authAdapter.refresh?.()) ?? false,
onUnauthorized: () => window.location.assign('/sign-in'),
})
Components never touch the token: when Ada signs in, the next request carries it. An API that expects its own header gets the raw token; this config sends x-api-key: <token>:
// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
authHeader: 'x-api-key',
getToken: () => localStorage.getItem('acme:api-key'),
})
Refresh an expired token
On a 401 with refresh set, the client calls refresh() once. If it returns true, it reads the token again with getToken() and retries the request once. If it returns false, or the retry is also 401, the request fails with UNAUTHORIZED and onUnauthorized runs.
Requests that fail together share one refresh() call: Grace's dashboard loading three lists after her token expired refreshes once. onUnauthorized runs for every request that ends in 401; use it to sign the user out or go to sign-in:
// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
onUnauthorized: (error) => {
console.warn('Session ended:', error.message)
window.location.assign(`/sign-in?next=${encodeURIComponent(window.location.pathname)}`)
},
})
Send the CSRF token
On every request except GET, HEAD and OPTIONS, the client sends the kitr-csrf cookie as the x-csrf-token header. That satisfies Shield's double-submit check, so writes to a same-site kitr server need no code.
Retry and time out
Idempotent requests (GET, PUT, HEAD, DELETE, OPTIONS, TRACE) retry up to twice on retryable statuses (408, 429, 5xx) and network errors. POST and PATCH are never retried, so a payment is never sent twice. Query adds its own retries on top; for a screen that must fail fast, turn off one layer:
// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
retry: 0, // Query's retries are enough
timeout: 5_000,
})
For one slow endpoint, pass options to the contract instead of changing every request:
// app/queries/reports.ts
import { app } from '#app/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'
const http = () => app.container.use('http')
export const yearlyReportQuery = queryFrom(
httpQuery<number, { year: number; revenue: number }>(http, {
path: (year) => `reports/${year}`,
key: (year) => ['reports', year],
options: { timeout: 60_000, retry: 0 },
}),
)
Add hooks
ky hooks run around every request. Pass them in config/http.ts; yours run after kitr's, so beforeRequest sees the token and beforeError the KitrError:
// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
hooks: {
beforeRequest: [
({ request }) => {
request.headers.set('x-request-id', crypto.randomUUID())
},
],
afterResponse: [
({ response }) => {
const remaining = response.headers.get('x-ratelimit-remaining')
if (remaining === '0') console.warn('Acme API rate limit reached')
},
],
},
})
Handle errors
Every failure becomes a KitrError with code, status, message and, when the API sends them, fieldErrors and data. Components, forms and queries check code, never the raw response:
// app/lib/orders/place-order.ts
import { app } from '#app/app'
import { isKitrError } from '@kitr/kitr/app'
const http = app.container.use('http')
try {
await http.post('orders', { json: { customer: 'Ada', total: -5 } })
} catch (error) {
if (isKitrError(error) && error.code === 'VALIDATION') console.log(error.fieldErrors)
// => { total: ['Must be positive'] }
}
The client reads the error body in this order:
| The API responds with | The KitrError has |
|---|---|
kitr's own shape { code, message, status, fieldErrors? } |
Those fields as-is. |
{ errors: [{ field, message }] } (Adonis, VineJS) |
fieldErrors keyed by field, or path; entries without either go under _root. |
{ message: '...' } |
That message. |
{ error: { message: '...' } } or { error: '...' } |
That message. |
| No JSON body, such as an HTML error page | The status's code and a message from it, such as 'Not found'. |
The parsed body is kept in error.data. A 409 with { "message": "Invoice already paid" } becomes:
// => KitrError { code: 'CONFLICT', status: 409, message: 'Invoice already paid', data: { message: 'Invoice already paid' } }
Commands
| Command | What it does |
|---|---|
kitr make:queries <name> |
Writes app/queries/<name>.ts with keys, a list query, a detail query and an update mutation over @kitr/http. |
kitr make:queries ordersFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
It writes app/queries/orders.ts:
// app/queries/orders.ts
import { createKeys } from '@kitr/kitr/app'
import { mutationFrom, queryFrom } from '@kitr/query'
import { httpMutation, httpQuery } from '@kitr/http'
import { app } from '#app/app'
const http = () => app.container.use('http')
export const ordersKeys = createKeys('orders')
export interface Orders {
id: string
name: string
}
export const ordersListQuery = queryFrom(
httpQuery<void, Orders[]>(http, { path: 'orders', key: () => ordersKeys.list() }),
)
export const ordersQuery = queryFrom(
httpQuery<string, Orders>(http, { path: (id) => `orders/${id}`, key: (id) => ordersKeys.detail(id) }),
)
export const updateOrders = mutationFrom(
httpMutation<Partial<Orders> & { id: string }, Orders>(http, {
method: 'patch',
path: (input) => `orders/${input.id}`,
effects: (result) => ({ set: [{ key: ordersKeys.detail(result.id), data: result }], invalidate: [ordersKeys.lists()] }),
}),
)
Pass the singular (kitr make:queries order) for Order and orderQuery. --force overwrites an existing file; kitr eject stub queries lets you edit the template.
Errors
| Code | When | What to do |
|---|---|---|
BAD_REQUEST |
400, or any 4xx not listed below. |
Fix the request; the message usually says what is wrong. |
UNAUTHORIZED |
401 after refresh failed or was not set. onUnauthorized runs. |
Sign the user in again. |
FORBIDDEN |
403. |
Hide the action, or ask the API owner for access. |
NOT_FOUND |
404. |
Show a not-found state; the record was deleted or the path is wrong. |
TIMEOUT |
408 from the server. |
Retry later. |
CONFLICT |
409. |
Reload the record and let the user decide. |
PAYLOAD_TOO_LARGE |
413. |
Send a smaller body or file. |
VALIDATION |
422, or a response that fails the contract's schema. |
Show fieldErrors next to the inputs; for a schema failure, update the schema or the API. |
RATE_LIMITED |
429. |
Back off; ky already retried idempotent requests. |
INTERNAL |
500 or any other 5xx; also a network failure or a client-side timeout. |
Show a retry button. The message names the cause, such as Request timed out: GET https://api.acme.com/v1/orders. |
UNAVAILABLE |
503. |
Show a maintenance notice and retry later. |
normaliseHttpError(error) applies this mapping to errors from ky calls made outside the client:
// app/lib/errors.ts
import { normaliseHttpError } from '@kitr/http'
export async function describeError(error: unknown) {
const kitrError = await normaliseHttpError(error)
return `${kitrError.code} (${kitrError.status}): ${kitrError.message}`
}
// describeError(new TypeError('Failed to fetch')) => 'NETWORK (0): Network request failed'
Testing
createHttpClient takes ky's fetch option, so a test passes a fake fetch that sees the real Request and never touches the network:
// app/queries/orders.test.ts
import { createKeys } from '@kitr/kitr/app'
import { createHttpClient, httpMutation, httpQuery } from '@kitr/http'
import { describe, expect, it, vi } from 'vitest'
interface Order {
id: string
customer: string
status: 'open' | 'shipped'
}
const orderKeys = createKeys('orders')
function fakeApi(handler: (request: Request) => Response) {
const requests: Request[] = []
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request.clone())
return handler(request)
})
return { fetch: fetch as unknown as typeof globalThis.fetch, requests }
}
describe('orders endpoints', () => {
it('lists open orders with the token and filters', async () => {
const api = fakeApi(() => Response.json([{ id: 'ord_1', customer: 'Ada', status: 'open' }]))
const http = createHttpClient({ baseUrl: 'https://api.acme.com/v1', getToken: () => 'tok_ada', fetch: api.fetch })
const orders = httpQuery<{ status?: string }, Order[]>(http, {
path: 'orders',
key: (filters) => orderKeys.list(filters),
searchParams: ({ status }) => ({ status }),
})
expect(await orders.fetch({ status: 'open' }, {})).toEqual([{ id: 'ord_1', customer: 'Ada', status: 'open' }])
expect(api.requests[0]!.url).toBe('https://api.acme.com/v1/orders?status=open')
expect(api.requests[0]!.headers.get('authorization')).toBe('Bearer tok_ada')
})
it('turns a 422 into field errors', async () => {
const api = fakeApi(() =>
Response.json({ errors: [{ field: 'customer', message: 'Required' }] }, { status: 422 }),
)
const http = createHttpClient({ baseUrl: 'https://api.acme.com/v1', fetch: api.fetch })
const createOrder = httpMutation<{ customer: string }, Order>(http, { path: 'orders' })
await expect(createOrder.mutate({ customer: '' })).rejects.toMatchObject({
code: 'VALIDATION',
status: 422,
fieldErrors: { customer: ['Required'] },
})
expect(await api.requests[0]!.json()).toEqual({ customer: '' })
})
})
To use the app's own provider, boot a test app and pass the fake fetch in the http config:
// app/lib/orders/provider.test.ts
import { httpProvider } from '@kitr/http'
import { createTestApp } from '@kitr/testing'
import { expect, it } from 'vitest'
it('binds a client with the base URL', async () => {
const fetch = async (input: RequestInfo | URL) =>
Response.json({ url: input instanceof Request ? input.url : String(input) })
const app = await createTestApp({
providers: [httpProvider],
config: { http: { baseUrl: 'https://api.acme.com/v1', fetch: fetch as typeof globalThis.fetch } },
})
expect(await app.container.use('http').get('teams').json()).toEqual({
url: 'https://api.acme.com/v1/teams',
})
})
To fake the API for a whole app, use Mocks.
Devtools
With Devtools installed, each development request is logged under http, such as GET /orders → 200 (84ms); 4xx log as warnings and 5xx as errors.