How-to › Describe your API

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.

Audience
API producer
Level
beginner
Topic
Model resources, relations and actions
Verified

The first path in your OpenAPI document is /sendInvoice, because the endpoint list came from the buttons in the UI. Nobody has decided whether an invoice can be deleted, whether a payment lives under its invoice, or what an invoice id looks like, so three endpoints answer those questions three ways. The SDK generated from the spec has a sendInvoice() function beside invoices.create().

What you get

You will end up with a one-table inventory of every resource, a TypeSpec model compiled from it, and a check that every row’s operations appear under paths. This is for you if you are about to write the first path of a spec.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Markdown or spreadsheet resource tableThe first week, when the nouns are still being argued over and nobody wants a toolchainNothing checks it, so a spec change that forgets the table leaves a row that is wrongThe table and the spec have disagreed once already
Smithy resource shapesA Java shop, or a team that wants identifiers, lifecycle, and child resources checked by a buildA Java toolchain and a Gradle or CLI build, plus a protocol trait before OpenAPI comes outYour team writes OpenAPI by hand and has no Java
Stripe API reference as a worked inventoryYou want a finished example of the shape before you draw your own tableSomebody else’s nouns, and a reference that shows the result rather than the decisions behind itYour domain shares no nouns with payments
TypeSpec @resource modelsYou emit OpenAPI from TypeSpec already, or will, and want the table compiled rather than copiedAn npm toolchain, a compile step, and a resource library whose version moves on its ownThe 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

| 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, 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 the Key column, and @parentResource the Parent column, which is what puts the parent’s key into the child’s path.

@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, and the Actions column is an @action per verb:

@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.

npx tsp compile main.tsp --emit @typespec/openapi3 --option "@typespec/openapi3.file-type=json"
Compilation completed successfully.
node routes.mjs
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.

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
}
node check-inventory.mjs
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 verbatim. The lifecycle bindings are the Operations column, operations is the Actions column, and resources is the Parent column read from the other side.

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 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:

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}'])
})
node --test inventory.test.mjs
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:

node pitfall.mjs
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:

node check-inventory.mjs inventory-drift.md
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:

npx tsp compile pitfall-nokey.tsp --emit @typespec/openapi3 --option "@typespec/openapi3.file-type=json"
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 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.

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.

Footnotes

  1. Stripe’s reference lists each object once, with its operations beneath it: create, update, retrieve, list, delete, and search for a customer. 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. ↩︎ Back to text

  2. Tables are not Markdown, strictly. The GFM specification 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. ↩︎ Back to text

  3. Smithy is vendor neutral, and the conversion guide 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. ↩︎ Back to text

  4. Google’s AIP-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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.