~ knotless Search/
↑↓ move↵ open⇧↵ all results

peterullrich.com/hatk

AT Protocol app framework

Forked from chadtmiller.com/hatk

0
0
main
190 lines · 8.9 KB Raw History
---
title: XRPC Handlers
description: Define typed API endpoints — queries for GET, procedures for POST.
---

# XRPC Handlers

XRPC handlers are typed API endpoints that extend your hatk server's API. They come in two kinds: **queries** (read-only GET requests) and **procedures** (POST requests that can modify data). Each handler maps to a lexicon that defines its parameter types, input/output schemas, and error cases.

## Defining a query

Use `defineQuery()` for read-only GET endpoints. The handler receives a typed context object and returns a response via `ctx.ok()`:

```typescript
// server/xrpc/getPlay.ts
import { defineQuery, NotFoundError, views, type Play, type Profile } from '$hatk'

export default defineQuery('xyz.appview.unspecced.getPlay', async (ctx) => {
  const { ok, params, resolve, lookup, blobUrl } = ctx
  const { uri } = params

  const records = await resolve<Play>([uri])
  if (records.length === 0) throw new NotFoundError('Play not found')

  const record = records[0]
  const profiles = await lookup<Profile>('app.bsky.actor.profile', 'did', [record.did])
  const profile = profiles.get(record.did)

  return ok({
    play: views.playView({
      record: {
        uri: record.uri,
        did: record.did,
        handle: record.handle,
        ...record.value,
      },
      author: profile
        ? {
            did: profile.did,
            handle: profile.handle,
            displayName: profile.value.displayName,
            avatar: blobUrl(profile.did, profile.value.avatar, 'avatar'),
          }
        : undefined,
    }),
  })
})
```

The `params` object is typed from your lexicon's parameter definitions. In this case, `params.uri` is a string because the lexicon declares it. The `ok()` function enforces the output schema at the type level -- if your return value doesn't match, TypeScript will error.

## Defining a procedure

Use `defineProcedure()` for POST endpoints that modify data. The request body is available via `ctx.input`, typed from your lexicon's input schema:

```typescript
// server/xrpc/doSomething.ts
import { defineProcedure } from '$hatk'

export default defineProcedure('dev.hatk.unspecced.doSomething', async (ctx) => {
  const { ok, db, viewer, input } = ctx

  if (!viewer) throw new Error('Authentication required')

  const { name, value } = input

  await db.run(
    `INSERT INTO my_table (did, name, value, created_at) VALUES ($1, $2, $3, $4)`,
    viewer.did,
    name,
    value,
    new Date().toISOString(),
  )

  return ok({})
})
```

## Context reference

Both `defineQuery` and `defineProcedure` handlers receive the same context object:

| Field                 | Type                                       | Description                                                           |
| --------------------- | ------------------------------------------ | --------------------------------------------------------------------- |
| `ok`                  | function                                   | Wraps your return value with type checking                            |
| `params`              | object                                     | Typed parameters from the lexicon schema                              |
| `input`               | object                                     | Request body (procedures only), typed from the lexicon's input schema |
| `db.query`            | function                                   | Run SQL queries against your SQLite database                          |
| `spaceFilter`         | function                                   | Permissioned-space gate for hand-written SQL                          |
| `db.run`              | function                                   | Execute SQL statements (INSERT, UPDATE, DELETE)                       |
| `viewer`              | `{ did: string; handle?: string }` \| null | The authenticated user, or null                                       |
| `limit`               | number                                     | Requested page size                                                   |
| `cursor`              | string \| undefined                        | Pagination cursor                                                     |
| `resolve`             | function                                   | Resolve AT URIs into full records                                     |
| `getRecords`          | function                                   | Fetch records by URI from another collection                          |
| `lookup`              | function                                   | Look up records by a field value                                      |
| `count`               | function                                   | Count records by field value                                          |
| `exists`              | function                                   | Check if a record exists matching field filters                       |
| `search`              | function                                   | Full-text search a collection                                         |
| `labels`              | function                                   | Query labels for a list of URIs                                       |
| `blobUrl`             | function                                   | Resolve a blob reference to a CDN URL                                 |
| `packCursor`          | function                                   | Encode a `(primary, cid)` pair into a cursor string                   |
| `unpackCursor`        | function                                   | Decode a cursor back into `{ primary, cid }`                          |
| `isTakendown`         | function                                   | Check if a DID has been taken down                                    |
| `filterTakendownDids` | function                                   | Filter a list of DIDs, returning those taken down                     |
| `createRecord`        | function                                   | Write a record to the viewer's PDS and index locally                  |
| `putRecord`           | function                                   | Create or update a record on the viewer's PDS                         |
| `deleteRecord`        | function                                   | Delete a record from the viewer's PDS and local index                 |

### `ctx.ok()`

Every handler must return `ctx.ok(data)`. This wraps your response with type checking against the lexicon's output schema. If the shape doesn't match, TypeScript catches it at compile time.

### `ctx.db.query()` and `ctx.db.run()`

Run SQL against your SQLite database. Use `db.query()` for SELECT statements that return rows, and `db.run()` for INSERT/UPDATE/DELETE:

::: warning Hand-written SQL and permissioned spaces
If your instance indexes [permissioned spaces](/guides/spaces), a raw query over a table a space writes into is refused unless it carries `ctx.spaceFilter` — the error names the table. Queries over any other table, and every typed helper below, are unaffected. `ctx.db.unfiltered` is the explicit door for a result never served to a viewer.
:::

```typescript
// Query — returns rows
const rows = await db.query(
  `SELECT CAST(COUNT(*) AS INTEGER) AS play_count
   FROM "fm.teal.alpha.feed.play"
   WHERE did = $1`,
  [params.actor],
)

// Run — executes a statement
await db.run(`INSERT INTO my_table (did, value) VALUES ($1, $2)`, viewer.did, input.value)
```

### `ctx.resolve()` and `ctx.lookup()`

These helpers fetch records without writing raw SQL:

```typescript
// Resolve AT URIs into full records
const records = await resolve<Play>([uri])

// Look up records by a field value — returns a Map keyed by the field
const profiles = await lookup<Profile>('app.bsky.actor.profile', 'did', [did1, did2])
const profile = profiles.get(did1)
```

### `ctx.viewer`

`viewer` is `{ did: string; handle?: string }` when the request comes from an authenticated user, or `null` for unauthenticated requests. Check it to protect endpoints that require authentication:

```typescript
if (!viewer) throw new Error('Authentication required')
```

## Error handling

Import error classes from your generated types to throw standard XRPC errors:

```typescript
import { NotFoundError, InvalidRequestError } from '$hatk'

// 404 — record not found
throw new NotFoundError('Play not found')

// 400 — bad request
throw new InvalidRequestError('Missing required field')
```

These map to standard XRPC error responses that clients can handle predictably.

## Lexicon pairing

Each handler must have a matching lexicon definition. The file path mirrors the NSID:

```
lexicons/dev/hatk/unspecced/getPlay.json  →  server/xrpc/getPlay.ts
```

The lexicon defines parameter types, input/output schemas, and whether the endpoint is a query or procedure. See the [AT Protocol lexicon docs](https://atproto.com/specs/lexicon) for schema details.

## Generating a handler

Use the CLI to scaffold a new handler:

```bash
hatk generate xrpc dev.hatk.unspecced.getPlay
```

This creates the handler file with the right imports and structure.