# How to write a resource inventory before you write the spec

> List every resource, its key, parent, operations, and actions in one table before the first path, then compile the table and check that every path matches a row.

Source: https://voxgig.com/howto/write-a-resource-inventory-before-the-spec

- Audience: api-producer
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Before you write a path, list every resource in one table. A row holds its name and collection segment, its key and id format, its parent, which of list, get, create, update, and delete it supports, its actions, and the resources it references. Keep the table in Markdown, or as TypeSpec `@resource` models or Smithy resource shapes once you want a compiler to check it. A row named for a verb is an endpoint, not a resource.

---
## You will need

Node 22 or later, and a list of the nouns in your product's UI or database schema, which is the
raw material the table sorts. Verified 2026-10-21 against Node 22.22.0, `@typespec/compiler`
1.17.0, `@typespec/http` 1.17.0, `@typespec/rest` 0.87.0, and `@typespec/openapi3` 1.17.0. The
sample inventories a small invoicing API three ways: a Markdown table, a TypeSpec model that
compiles to OpenAPI, and a Smithy model written against the specification and not compiled. A
check compares the table with the emitted paths.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Markdown or spreadsheet resource table](https://github.github.com/gfm/#tables-extension-) | The first week, when the nouns are still being argued over and nobody wants a toolchain | Nothing checks it, so a spec change that forgets the table leaves a row that is wrong | The table and the spec have disagreed once already |
| [Smithy resource shapes](https://smithy.io/2.0/spec/service-types.html#resource) | A Java shop, or a team that wants identifiers, lifecycle, and child resources checked by a build | A Java toolchain and a Gradle or CLI build, plus a protocol trait before OpenAPI comes out | Your team writes OpenAPI by hand and has no Java |
| [Stripe API reference as a worked inventory](https://docs.stripe.com/api/customers) | You want a finished example of the shape before you draw your own table | Somebody else's nouns, and a reference that shows the result rather than the decisions behind it | Your domain shares no nouns with payments |
| [TypeSpec @resource models](https://typespec.io/docs/libraries/rest/reference/decorators/#@TypeSpec.Rest.resource) | You emit OpenAPI from TypeSpec already, or will, and want the table compiled rather than copied | An npm toolchain, a compile step, and a resource library whose version moves on its own | The spec is the source of truth and nothing generates it |

The table costs nothing to start, and the two modeling languages each cost a toolchain. TypeSpec
brings npm and a compile step. Smithy brings a Java build and a protocol trait. What they buy is
the check, because a row in a `.tsp` file is either a path in the emitted document or a compiler
error. Stripe's reference is the shape to copy,[^1] with nouns you do not get to keep. Write the
table on day one, and move it into a model the first time it disagrees with the spec.

## List the nouns, one row each

A row is a noun, never an endpoint. The Operations column takes only the five entity operations,
the Actions column takes whatever is left, and the References column names the resources whose
ids this one carries. The Markdown table is the format a pull request can review.[^2]

```text title="inventory.md"
| Resource | Collection | Key | Id format | Parent | Operations | Actions | References |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Customer | customers | customer_id | `cus_` + 14 chars | - | list, get, create, update, delete | - | - |
| Card | cards | card_id | `card_` + 14 chars | Customer | list, get, create, delete | - | Customer |
| Invoice | invoices | invoice_id | `inv_` + 14 chars | - | list, get, create, update, delete | send, void | Customer |
| Payment | payments | payment_id | `pay_` + 14 chars | Invoice | list, get, create | refund | Invoice, Customer |
```

Four rows settle the questions the problem statement left open. A card has no update, so there
is no `PATCH` for it to grow later. A payment lives under its invoice, so its path carries
`invoice_id`. Sending is an action on an invoice, which is where Stripe puts
[its own send](https://docs.stripe.com/api/invoices/send), and not a resource of its own.

## Compile the same rows as TypeSpec models

Each row becomes a model. `@resource` carries the Collection column,
[`@key`](https://typespec.io/docs/standard-library/built-in-decorators/#@key) the Key column, and
[`@parentResource`](https://typespec.io/docs/libraries/rest/reference/decorators/#@TypeSpec.Rest.parentResource)
the Parent column, which is what puts the parent's key into the child's path.

```typespec title="main.tsp"
@resource("customers")
model Customer {
  @key customer_id: string;
  name: string;
}

@resource("cards")
@parentResource(Customer)
model Card {
  @key card_id: string;
  last4: string;
}
```

The Operations column is a choice of
[interface templates](https://typespec.io/docs/libraries/rest/reference/interfaces/), and the
Actions column is an `@action` per verb:

```typespec title="main.tsp"
@autoRoute
interface Customers extends ResourceOperations<Customer, Error> {}

@autoRoute
interface Cards
  extends ResourceCollectionOperations<Card, Error>,
    ResourceRead<Card, Error>,
    ResourceDelete<Card, Error> {}

@autoRoute
interface Invoices extends ResourceOperations<Invoice, Error> {
  @action("send")
  send(...ResourceParameters<Invoice>): Invoice | Error;

  @action("void")
  voidInvoice(...ResourceParameters<Invoice>): Invoice | Error;
}
```

`ResourceOperations` is all five. `Cards` composes the collection operations with read and
delete, so the missing update is a template that was not extended rather than a path somebody
forgot.

```bash
npx tsp compile main.tsp --emit @typespec/openapi3 --option "@typespec/openapi3.file-type=json"
```

```text output
Compilation completed successfully.
```

```bash
node routes.mjs
```

```text output
POST    /customers                                     Customers_create
GET     /customers                                     Customers_list
GET     /customers/{customer_id}                       Customers_get
PATCH   /customers/{customer_id}                       Customers_update
DELETE  /customers/{customer_id}                       Customers_delete
POST    /customers/{customer_id}/cards                 Cards_create
GET     /customers/{customer_id}/cards                 Cards_list
GET     /customers/{customer_id}/cards/{card_id}       Cards_get
DELETE  /customers/{customer_id}/cards/{card_id}       Cards_delete
POST    /invoices                                      Invoices_create
GET     /invoices                                      Invoices_list
GET     /invoices/{invoice_id}                         Invoices_get
PATCH   /invoices/{invoice_id}                         Invoices_update
DELETE  /invoices/{invoice_id}                         Invoices_delete
POST    /invoices/{invoice_id}/payments                Payments_create
GET     /invoices/{invoice_id}/payments                Payments_list
GET     /invoices/{invoice_id}/payments/{payment_id}   Payments_get
POST    /invoices/{invoice_id}/payments/{payment_id}/refund Payments_refund
POST    /invoices/{invoice_id}/send                    Invoices_send
POST    /invoices/{invoice_id}/void                    Invoices_voidInvoice
```

Twenty operations, every one of them a cell in the table. The card paths carry `customer_id`
because the model said `@parentResource(Customer)`, and the two invoice actions came out as
`POST` under the item without being told a method.

## Check every row against the emitted paths

The check reads the table, works out the method and path each cell promises, and compares that
list with the paths in the emitted document in both directions.

```js title="check-inventory.mjs"
export function expectedPaths(rows) {
  const byName = new Map(rows.map((r) => [r.resource, r]))
  const chain = (row) => {
    if (!row.parent) return [row]
    const parent = byName.get(row.parent)
    if (!parent) throw new Error(`${row.resource} names parent ${row.parent}, which has no row`)
    return [...chain(parent), row]
  }
  const out = []
  for (const row of rows) {
    const parents = chain(row).slice(0, -1).map((r) => `/${r.collection}/{${r.key}}`).join('')
    const collection = `${parents}/${row.collection}`
    const item = `${collection}/{${row.key}}`
    for (const op of row.operations) {
      const [method, where] = ENTITY_OPS[op]
      out.push({ resource: row.resource, op, method, path: where === 'item' ? item : collection })
    }
    for (const action of row.actions) out.push({ resource: row.resource, op: action, method: 'post', path: `${item}/${action}` })
  }
  return out
}
```

```bash
node check-inventory.mjs
```

```text output
resource  collection path                    operations and actions
Customer  /customers                         list, get, create, update, delete
Card      /customers/{customer_id}/cards     list, get, create, delete
Invoice   /invoices                          list, get, create, update, delete, send, void
Payment   /invoices/{invoice_id}/payments    list, get, create, refund

20 of 20 promised operations are under paths
  every path in the spec belongs to a row
```

Both directions matter. A promised operation with no path is a row the model forgot. A path with
no row is an endpoint that got into the spec without a decision, which is the failure the
inventory exists to prevent.

## Write the rows as Smithy resource shapes

Smithy records the same table as resource shapes. `identifiers` is the Key column,
[and a child repeats every identifier of its parent](https://smithy.io/2.0/spec/service-types.html#resource-identifiers)
verbatim. The
[lifecycle bindings](https://smithy.io/2.0/spec/service-types.html#resource-lifecycle-operations)
are the Operations column, `operations` is the Actions column, and `resources` is the Parent
column read from the other side.

```smithy title="invoicing.smithy"
resource Customer {
    identifiers: { customerId: CustomerId }
    properties: { name: String }
    read: GetCustomer
    list: ListCustomers
    create: CreateCustomer
    update: UpdateCustomer
    delete: DeleteCustomer
    resources: [Card]
}

resource Card {
    identifiers: { customerId: CustomerId, cardId: CardId }
    properties: { last4: String }
    read: GetCard
    list: ListCards
    create: CreateCard
    delete: DeleteCard
}
```

Each binding names an operation shape with its own `@http` route, so the file runs to a few
hundred lines where the TypeSpec runs to seventy. The Smithy model here was written against the
specification and not built, because the build needs a Java toolchain. Its
[OpenAPI conversion](https://smithy.io/2.0/guides/model-translations/converting-to-openapi.html)
also needs a protocol on the service, and the guide configures `aws.protocols#restJson1`.[^3]

## Check it worked

Five tests pin the parser, the path derivation, the comparison in both directions, the drifted
table, and the verb check. The one that matters most is the drift:

```js title="inventory.test.mjs"
test('a table that drifts from the model reports the operation the model lacks', () => {
  const openapi = JSON.parse(readFileSync(SPEC, 'utf8'))
  const drifted = parseInventory(readFileSync('inventory-drift.md', 'utf8'))
  const { missing } = compare(expectedPaths(drifted), openapi)
  assert.deepEqual(missing.map((m) => `${m.resource} ${m.op} ${m.method} ${m.path}`), ['Card update patch /customers/{customer_id}/cards/{card_id}'])
})
```

```bash
node --test inventory.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 101.604402
```

`# pass 5`, with the emitted document compiled from `main.tsp` before the tests read it, so a
stale build cannot pass for a changed model.

## When it goes wrong

The table has six rows and half of them are verbs. The inventory was written from the
endpoint list, so `SendInvoice` sits beside `Invoice` as though it were a noun with a collection.
A verb check over the resource names catches the shape:

```bash
node pitfall.mjs
```

```text output
SendInvoice: a verb, so an action on Invoice, not a resource. Move it to the Actions column of Invoice.
VoidInvoice: a verb, so an action on Invoice, not a resource. Move it to the Actions column of Invoice.
RefundPayment: a verb, so an action on Payment, not a resource. Move it to the Actions column of Payment.
3 of 6 rows are endpoints written as resources
```

The list of verbs is a starting point, so extend it with the verbs your own endpoint list uses.
Each flagged row moves into the Actions column of the resource it references, and the decision
the row was hiding, what the action returns and what it changes, gets made.

The table says one thing and the spec says another. A design review added card updates to the
table, but the model was left as it was, which is the drift a Markdown table invites:

```bash
node check-inventory.mjs inventory-drift.md
```

```text output
resource  collection path                    operations and actions
Customer  /customers                         list, get, create, update, delete
Card      /customers/{customer_id}/cards     list, get, create, update MISSING, delete
Invoice   /invoices                          list, get, create, update, delete, send, void
Payment   /invoices/{invoice_id}/payments    list, get, create, refund

20 of 21 promised operations are under paths
  every path in the spec belongs to a row
```

Exit code 1, and the cell that drifted is named. Run the check where the spec is built, so the
table cannot change on its own.

The compiler refuses a child resource. A parent model without `@key` cannot be addressed, so
nothing can live under it:

```bash
npx tsp compile pitfall-nokey.tsp --emit @typespec/openapi3 --option "@typespec/openapi3.file-type=json"
```

```text output
pitfall-nokey.tsp:13:7 - error @typespec/rest/resource-missing-key: Type 'Customer' is used as a resource and therefore must have a key. Use @key to designate a property as the key.
```

The Key column was empty for that row, and the compiler is the first reader to insist on it.

## When not to do this

Do not inventory your database tables. A join table, an audit log, and a denormalized view are
nouns in the schema and not resources in the API.
[AIP-121](https://google.aip.dev/121) names an API identical to its database schema as an
anti-pattern.[^4] Start from what a client needs to name, and let the storage follow.

Do not adopt TypeSpec or Smithy to hold four rows. A compile step, a package to pin, and a second
language earn their place once the table and the spec have disagreed, or once the table passes
forty rows. Until then the Markdown table and the check are enough, and both fit in a pull
request.

Do not let the table replace the spec. It holds the decisions, and the spec holds the contract:
response shapes, error bodies, and parameters live in the spec alone, and a table that tries to
carry them stops being readable.

Do not list an endpoint as a row because the UI has a button for it. The button is the evidence
that an action exists; the row it belongs in is the resource it acts on.

## Related how-tos

- [Decide if an endpoint is an entity operation or an action](/howto/entity-operation-or-standalone-action)

- [Write operationIds that generate clean SDK method names](/howto/operationids-that-generate-clean-sdk-method-names)

- [Reconcile an extracted entity model with a domain model](/howto/reconcile-an-extracted-model-with-a-domain-model)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@typespec/compiler` 1.17.0, `@typespec/http` 1.17.0,
`@typespec/rest` 0.87.0, and `@typespec/openapi3` 1.17.0, installed from npm in the page's code
directory. Every output block is what the command preceding it printed. The Smithy model was
written against the specification and not built, because the build needs a Java toolchain that
is not part of this page. The Stripe reference was read, not called.

[^1]: Stripe's reference lists [each object once](https://docs.stripe.com/api/customers), with
its operations beneath it: create, update, retrieve, list, delete, and search for a customer.
[Send](https://docs.stripe.com/api/invoices/send) for an invoice is filed under the invoice
rather than at the top level. Read that way, the reference is an inventory published after the
fact, with the Parent and Actions columns filled in by where each entry was put.

[^2]: Tables are not Markdown, strictly. The
[GFM specification](https://github.github.com/gfm/#tables-extension-) describes itself as a
strict superset of CommonMark and files tables under its extensions. The table is a leaf block
made of a header row, a delimiter row, and cells split on pipes. A table that renders on GitHub
can therefore come out as a paragraph of pipes in a renderer that stopped at CommonMark. That is
one way an inventory kept in Markdown stops being read.

[^3]: Smithy is vendor neutral, and the
[conversion guide](https://smithy.io/2.0/guides/model-translations/converting-to-openapi.html)
configures its OpenAPI plugin with `aws.protocols#restJson1`, from a traits package the guide
marks as required for that trait. The converter needs a protocol to know how an operation goes
over the wire. So the first thing a neutral model acquires on its way to OpenAPI is an AWS trait,
which says where the converter grew up.

[^4]: Google's [AIP-121](https://google.aip.dev/121) defines resource-oriented design as
individually named resources, the nouns, with a small number of standard methods, the verbs, and
custom methods where those do not fit. The same page calls an API identical to the underlying
database schema an anti-pattern, because it couples the surface to the storage. An inventory
drawn from table names starts in exactly that position.