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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Markdown or spreadsheet resource table | 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 | 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 | 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 | 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
| 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.
Related how-tos
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
-
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
-
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
-
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 -
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