recipe

package
v0.0.0-...-85425c5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package recipe defines the Recipe format — the description of how Cauldron emulates one external dependency.

A Recipe is not a mock. A mock returns a shape; a Recipe models behaviour: what resources exist, how they change, what the provider emits afterwards, and how it fails. The format is declarative on purpose, so that the majority of Recipes are data a contributor can read and review rather than code.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Bundled

func Bundled() []string

Bundled returns the names of every Recipe compiled into this binary.

func IsPath

func IsPath(name string) bool

IsPath reports whether a name is a well-formed dotted path rather than a literal key that happens to contain a dot.

The distinction is not academic. Dropbox names a field ".tag" -- the leading dot is part of the name, not a separator -- so treating every dotted name as a path turns it into an object under an empty key. A path is at least two segments and every one of them is a name.

func Suggest

func Suggest(name string) []string

Suggest names the bundled Recipes closest to what somebody typed.

A hundred and twenty-seven names is too many to print. It was a reasonable thing to do at four, and the message that once listed everything now fills a terminal and buries the answer, which is worse than saying nothing: somebody who typed "stripo" has to read a wall of text to find "stripe" in it.

Names like gocardlessbank, moderntreasury, npmregistry and secretsmanager make a typo close to certain, so the useful answer is the two or three nearest ones and a pointer to the full list.

func ValidAuthSchemes

func ValidAuthSchemes() []string

ValidAuthSchemes returns the credential schemes a Recipe may declare.

Exported so the runtime's test suite can assert that every scheme the validator accepts is one the handler actually checks. The two are separate pieces of code that have to agree and nothing else makes them: adding a scheme here without adding a case there would silently authorise every request against every Recipe using it.

Types

type Auth

type Auth struct {
	// Scheme is one of: bearer, basic, header, query, none.
	//
	// A query credential travels in the URL, which is worth reproducing
	// precisely because it is a bad idea: URLs end up in access logs, browser
	// history and error reports. Trello and a good deal of older software do
	// it anyway, and an emulator that quietly accepted a header instead would
	// hide the exposure.
	Scheme string `yaml:"scheme"`
	// Header is the header carrying the credential, when scheme is header.
	Header string `yaml:"header"`
	// Param is the query parameter carrying the credential, when the scheme
	// is query.
	Param string `yaml:"param"`
	// Prefix is stripped from the credential before comparison, e.g. "Bearer ".
	Prefix string `yaml:"prefix"`
	// Credential says which half of a basic credential carries the secret:
	// "username" (the default, which is what Twilio does with the account SID)
	// or "password" (Mailgun, whose username is the constant "api"). Checking
	// the wrong half means a bad key is never rejected at all.
	Credential string `yaml:"credential"`
	// Keys are the credentials the emulator accepts. Test keys only — a Recipe
	// must never carry a real secret.
	Keys []string `yaml:"keys"`
	// Pattern accepts any credential matching this regular expression, for
	// schemes where the value is computed per request and cannot be compared
	// against a fixed list.
	//
	// AWS signs every request with SigV4, so the Authorization header is
	// different each time and there is no key to hold. Verifying the signature
	// would mean implementing the algorithm, which is not what this project is
	// for. Checking the shape catches the failure that actually happens —
	// credentials not configured, or the header missing entirely — and the
	// Recipe header has to say plainly that a wrongly signed request is
	// accepted. Silence about that would be worse than the gap.
	Pattern string `yaml:"pattern"`
}

Auth describes how the provider authenticates callers.

type Case

type Case struct {
	Name string `yaml:"name"`
	// Source cites the provider documentation or transcript the expectation
	// came from. Required: an uncited claim about someone else's API is a
	// guess wearing a test's clothing.
	Source string `yaml:"source"`
	// Verified is the date this case was last checked against the real API,
	// as YYYY-MM-DD. Empty means the expectation was read, not observed, and
	// the report says so rather than quietly counting it as proof.
	Verified string `yaml:"verified"`
	// Fixture is seeded before the case runs. Empty leaves the sandbox as it is,
	// which lets a group of cases build on each other in order.
	Fixture string `yaml:"fixture"`
	// Arm names an entry in the Recipe's errors table to install before this
	// case's request, and only for it.
	//
	// Without this a Recipe's error table is a list of unverified claims.
	// Every failure a conformance suite could reach was one the runtime
	// produces on its own: a 404 for a missing record, a 401 for a bad
	// credential. The interesting entries, the ones describing a declined
	// card or an expired sync token or a rate limit, were declared and never
	// once exercised, so a field could be renamed, a status changed or a
	// nested detail dropped and nothing anywhere would notice.
	//
	// The fault is armed for exactly one request and cleared afterwards, so a
	// case cannot leak a failure into the next one.
	Arm     string      `yaml:"arm"`
	Request Request     `yaml:"request"`
	Expect  Expectation `yaml:"expect"`
}

Case is one checkable claim about the provider's behaviour.

The point of a conformance case is not that the emulator passes it. Any fake passes its own tests. The point is provenance: every case cites where the expectation came from, and records whether it was observed against the real API or only read in the documentation. A developer deciding whether to trust this emulator can then read the evidence rather than the marketing.

type ChangeEmit

type ChangeEmit struct {
	Event string `yaml:"event"`
	// Field is compared before and after the write. Absent from the request
	// means unchanged, and a write that sets a field to the value it already
	// held is not a change either -- providers key these events off the
	// transition, not off the request naming the field.
	Field string `yaml:"field"`
}

ChangeEmit is one conditional emission: an event, and the field whose change triggers it.

type ErrNotBundled

type ErrNotBundled struct {
	Name string
}

ErrNotBundled is returned when a Recipe name is not compiled in.

func (*ErrNotBundled) Error

func (e *ErrNotBundled) Error() string

type Error

type Error struct {
	Status int    `yaml:"status"`
	Code   string `yaml:"code"`
	// Type is the provider's error category, which is often a much smaller set
	// than the codes. Stripe has four types and dozens of codes, and client
	// libraries switch on the type. Empty falls back to the code, which is
	// wrong often enough that every Recipe should set it.
	Type    string `yaml:"type"`
	Message string `yaml:"message"`
	// Style overrides the Recipe-wide error envelope for this failure alone,
	// because a provider can answer two shapes and the npm registry does.
	//
	// Checked against registry.npmjs.org on 2026-08-22: a package that does
	// not exist answers {"error":"Not found"}, and a version that does not
	// exist on a package that does answers the bare JSON string "version not
	// found: 99.99.99". Same status, same registry, one object and one
	// string. Code reading body.error off the second finds undefined, and
	// code that reports body.error as the reason reports "undefined".
	Style string `yaml:"style"`
	// Key overrides the Recipe-wide envelope key for this failure alone,
	// because a provider can answer two failures in two different places and
	// Shopify's GraphQL API does.
	//
	// A GraphQL request that cannot be served at all -- a bad token, a
	// throttled shop, a malformed query -- comes back as {"errors": [...]} at
	// the top level. A request that was served and refused on business
	// grounds comes back as
	// {"data": {"productCreate": {"userErrors": [...]}}}, nested under the
	// mutation's own name, with no top-level errors at all. Both are HTTP
	// 200, and a client that checks only one of the two channels misses every
	// failure in the other.
	//
	// A dotted name nests, the same way the Recipe-wide key does.
	Key string `yaml:"key"`
	// MessageField overrides the Recipe-wide field carrying the sentence, for
	// this failure alone.
	//
	// Shopify needs it and the reason is worth stating. A throttled GraphQL
	// request answers 200 with {"errors": [{"message": ...}]} -- an array of
	// objects. A request with a bad token answers 401 with
	// {"errors": "[API] Invalid API key or access token"} -- the same key,
	// holding a bare string. So errors[0].message reads the sentence on one
	// and reads the character "[" on the other, because indexing a string in
	// JavaScript succeeds, and .message on that is undefined. Nothing throws
	// and nothing is logged.
	//
	// Without this a Recipe could describe one of the two and would be
	// claiming the other does not happen.
	MessageField string `yaml:"message_field"`
	// CodeField overrides the Recipe-wide field carrying the code, for this
	// failure alone, and "-" removes it.
	//
	// The same asymmetry MessageField already fixed, on the other half of the
	// pair. A Recipe with two error envelopes could say where the prose lives
	// in each and could not say that one of them has no code at all -- so the
	// failure carried its own name as a code, which is worse than a wrong
	// code because it looks like a real one.
	//
	// VTEX is the case. Its OMS endpoints answer {"error": {code, message,
	// exception}} and its newer document endpoints answer an RFC 9110 problem
	// detail -- {"type", "title", "status", "traceId"} -- which has no code
	// anywhere in it. A client switching on a code has nothing to switch on,
	// and that is the thing worth serving.
	CodeField string            `yaml:"code_field"`
	Headers   map[string]string `yaml:"headers"`
	// Fields are extra body properties this failure carries, merged over the
	// Recipe-wide ones. Dropbox describes each failure with its own nested
	// union, so a single set of constants would make every error claim to be
	// the same one.
	Fields map[string]any `yaml:"fields"`
}

Error is a named failure mode that `cauldron fault` can inject.

type ErrorResponse

type ErrorResponse struct {
	// Style is nested (Stripe, the default), flat (GitHub), list (SendGrid,
	// which sends {"errors": [{...}]} because one request can fail several
	// ways at once), string_list (Datadog, which sends the same array with
	// bare strings in it rather than objects) or text (Trello, whose failures
	// are not JSON at all, so a client calling .json() on one throws).
	Style string `yaml:"style"`
	// Key is the property holding the array when the style is list. A dotted
	// name nests, which QuickBooks needs: its failures arrive under
	// Fault.Error rather than at the top level. "-" removes the envelope
	// entirely, which Salesforce needs: its failures are a bare top-level
	// array, so a client reading .message off the response finds undefined and
	// has to index before it can read anything at all.
	Key string `yaml:"key"`
	// MessageField names the property carrying the human-readable message when
	// the style is flat. Empty means "message". Set it to "-" to omit the
	// message entirely, which Slack does: its errors are a code and nothing
	// else, and inventing prose the provider never sends is still infidelity.
	MessageField string `yaml:"message_field"`
	// CodeField names the property carrying the error code in a flat envelope.
	// Twilio sends one and its clients switch on it; GitHub does not send one
	// at all, so this stays empty unless a Recipe claims otherwise. As with
	// MessageField, "-" omits the code, which the nested style needs too:
	// Airtable nests its error but sends only a type and a message.
	CodeField string `yaml:"code_field"`
	// CodeType says whether the code is sent as a number or as a string.
	//
	// Empty infers it from the value: all digits becomes a number, anything
	// else stays text. That inference is right for Twilio, whose codes really
	// are integers, and wrong for Adyen, whose "000" is a string and loses its
	// leading zeros on the way through. Inferring a provider's behaviour from
	// the shape of a literal is a guess, so a Recipe that knows can say, and
	// one that says overrides the guess.
	CodeType string `yaml:"code_type"`
	// StatusField names a property echoing the HTTP status inside the body,
	// which Twilio does.
	StatusField string `yaml:"status_field"`
	// TypeField names a property carrying the error category in a flat
	// envelope. Plaid sends error_type and its clients switch on that before
	// they look at the code, because the category decides whether to retry,
	// re-authenticate or give up. As with CodeField, "-" omits the category,
	// which the nested style needs too: Vercel nests its error and sends only
	// a code and a message.
	TypeField string `yaml:"type_field"`
	// Fields are constants the provider adds to every error, such as GitHub's
	// documentation_url.
	Fields map[string]any `yaml:"fields"`
}

ErrorResponse describes the envelope a provider puts failures in.

Stripe nests under "error" with a type and a code; GitHub sends a flat object with a message and a documentation link. Code that unwraps one and receives the other does not report a helpful failure, it panics.

type Expectation

type Expectation struct {
	Status  int               `yaml:"status"`
	Headers map[string]string `yaml:"headers"`
	Body    map[string]any    `yaml:"body"`
	// Matches holds dotted field paths to regular expressions, for values that
	// are correct in shape rather than exact, such as generated identifiers.
	Matches map[string]string `yaml:"matches"`
	// HeaderMatches holds response header names to regular expressions, for
	// headers that carry a generated value. A plain `headers` entry compares
	// substrings, which cannot assert that a header is merely present and
	// well-formed.
	HeaderMatches map[string]string `yaml:"header_matches"`
	// AbsentHeaders lists response headers that must not appear.
	//
	// The absence of a header is a claim as real as its presence, and for
	// paging it is the one that terminates the loop: a provider advertises
	// the next page in Link and sends no Link on the last page, so a client
	// that keeps following one until it is gone stops exactly there. An
	// emulator that sent Link on every page would loop forever, and there
	// was no way to write that down.
	AbsentHeaders []string `yaml:"absent_headers"`
	// Absent lists fields that must not appear. Providers are as specific about
	// what they omit as what they send.
	Absent []string `yaml:"absent"`
	// BodyMatches is a regular expression applied to the raw response body,
	// without parsing it.
	//
	// A provider whose failures are plain text has no assertable body
	// otherwise: matches walks a decoded document, so the only thing Trello's
	// text-error case could pin down was its Content-Type. The prose is the
	// part support threads quote and the part a client ends up regex-matching
	// in anger, so it is worth being able to claim.
	BodyMatches string `yaml:"body_matches"`
	// NoBody asserts the response body is empty.
	//
	// This is a positive claim, not the absence of one. Salesforce answers an
	// update with 204 and nothing at all, so a client calling .json() on it
	// throws rather than seeing that the update worked, and an emulator that
	// helpfully returned an object would hide that. An `absent` list cannot
	// express it: absences are vacuously true against an empty body, so a case
	// built from them would pass whatever the emulator sent.
	NoBody bool `yaml:"no_body"`
	// Webhook asserts what the request emitted, which nothing could assert
	// before.
	//
	// Webhook payloads were the largest unverified surface in the project: 85
	// Recipes emit them, the record went in raw rather than shaped, and no
	// case could look. An application's handler written against the emulator
	// could read a field the provider never sends and be entirely green.
	Webhook *WebhookExpectation `yaml:"webhook"`
}

Expectation is what the provider is claimed to answer.

Body matching is a subset: a case asserts the fields it is making a claim about and ignores the rest, so a Recipe can grow a field without invalidating every case ever written about it.

type Field

type Field struct {
	// Type is string, integer, boolean, timestamp (a Unix integer in seconds,
	// which is what Stripe and Twilio send), timestamp_ms (milliseconds, which
	// is what Clerk and most JavaScript-first APIs send) or datetime (an RFC
	// 3339 string, which is what GitHub, HubSpot and most newer APIs send).
	// The difference is not cosmetic: one parses as a number and the other
	// does not, and a factor of a thousand puts a date in 1970 or in the year
	// 55000.
	// list is a sequence of anything, and it exists because several providers
	// send one where a client expects an object. WooCommerce's meta_data is an
	// array of {id, key, value} objects, so order.meta_data.some_key finds
	// undefined and the value is sitting in the array under a key that has to
	// be searched for. Declaring the field as a list says that on purpose;
	// leaving it untyped would emit the same bytes while claiming nothing.
	Type     string `yaml:"type"`
	Required bool   `yaml:"required"`
	Default  any    `yaml:"default"`
	// Stamped decides whether a timestamp or datetime field is filled in
	// automatically when the caller does not supply it. Nil means yes, which
	// is right for created_at and updated_at.
	//
	// Set it to false for a field whose absence is the meaningful state: a
	// Webflow site that has never been published has no lastPublished, and a
	// Typeform response that was abandoned has no submitted_at. Stamping
	// those makes the emulator claim an event happened that did not, which is
	// the kind of infidelity a test can never catch.
	Stamped *bool `yaml:"stamped"`
	// NullWhenUnset sends the field as null rather than leaving it out.
	//
	// Absent and null are different on the wire and providers disagree about
	// which they use, so the format has to be able to say both. Bandwidth
	// leaves errorCode out of the messages that worked, so a client testing
	// for null never sees one. Alpaca sends every timestamp on every order and
	// leaves the ones that have not happened as null, so a client testing for
	// the key's existence finds it and reads nothing out of it. Each of those
	// is a real bug in code written against the other assumption.
	//
	// It implies no stamping, because a field with a value is not unset.
	NullWhenUnset bool `yaml:"null_when_unset"`
	// In nests this field under a sub-object on the wire. HubSpot puts every
	// business attribute under "properties" and leaves only id, timestamps and
	// archived at the top level, so a client reads contact.properties.email.
	// The store stays flat; only the shape on the wire changes, and requests
	// are flattened back on the way in.
	//
	// "-" nests it nowhere: the record holds the field and the wire never
	// carries it. A route's scope needs this. A partition that lives in the
	// path has to be a field, because that is how the record is partitioned,
	// and most providers do not repeat it in the body -- Fly does not send
	// app_name on a machine, Hetzner does not send its collection on a point,
	// Tradier does not say which account an order is in.
	//
	// Before this the only way to say so was a route's returns naming every
	// other field, which was twenty-three names to hide one on Fly, and which
	// says nothing at all when the same resource is served by two routes. An
	// audit found 115 scope fields across 37 Recipes going onto the wire with
	// no case mentioning them; some of those providers really do echo the
	// partition and each one has to be read before it is changed, so this is
	// the tool for the ones that have been.
	In string `yaml:"in"`
	// As is the name this field takes on the wire, when it differs from the
	// name it is stored under.
	//
	// Nesting alone was not enough. A field's own name is the key inside the
	// sub-object, so two fields could not share a key under different parents:
	// a resource wanting both title.rendered and content.rendered had to call
	// one of them something else, and what it got called leaked onto the wire.
	// Thirty-one fields across six Recipes ended up emitting amount.amount_value
	// where Adyen sends amount.value, and title.title_rendered where WordPress
	// sends title.rendered. Every conformance case about them passed, because
	// they asserted the shape the emulator produced.
	As string `yaml:"as"`
}

Field is a single attribute on a resource.

A field of type map is free-form: it accepts whatever keys the caller sends and answers with them. Stripe's metadata is the reason it exists -- arbitrary key-value pairs the provider stores and echoes without knowing what they mean -- and it has to be declared rather than assumed, because a create that echoes any field it is sent cannot tell a Recipe's model from a typo.

func (Field) WireName

func (f Field) WireName(name string) string

WireName is the key this field takes in a response.

type Filter

type Filter struct {
	// Param is the query parameter's name.
	Param string `yaml:"param"`
	// Field is the record field it matches against.
	Field string `yaml:"field"`
	// Default is the value applied when the parameter is absent. Empty means
	// the filter only applies when the caller supplies it, which is the less
	// interesting half: a filter nobody asked for is the one that surprises
	// people.
	Default string `yaml:"default"`
	// All is the value that turns the filter off, for providers that have one.
	// GitHub and Alpaca both spell it "all". Empty means there is no way to
	// ask for everything, which is itself worth knowing.
	All string `yaml:"all"`
	// Values expands a parameter value into the set of field values it
	// covers, for the filters whose vocabulary is not the field's.
	//
	// Alpaca's order listing takes status=open, and "open" is not a status
	// any order holds. It is a bucket: new and partially_filled are open,
	// filled and canceled are closed, and nothing in the record says which
	// bucket its status belongs to. A filter that matched the word literally
	// would hide every partially filled order, which is precisely the order
	// that most needs to be visible, since it is a real position.
	//
	// A value with no entry here matches itself, so most filters need none.
	Values map[string][]string `yaml:"values"`
}

Filter is a query parameter that narrows a listing to records whose field matches, and usually narrows it whether or not the caller asked.

type Fixture

type Fixture map[string][]map[string]any

Fixture is a named seed dataset: resource name to a list of records.

type ID

type ID struct {
	// Style is one of: prefixed (cus_abc123), numeric (1, 2, 3), timestamp
	// (1767225600.000100, which is how Slack identifies a message), opaque
	// (a bare random string, which is what SendGrid returns as a message id)
	// uuid (Notion, and most APIs designed after about 2015), hex (Intercom,
	// and anything whose identifiers came out of MongoDB) or digits (Discord
	// snowflakes, and any provider whose ids are long numeric strings that must
	// not be parsed as numbers).
	// Empty means prefixed.
	Style  string `yaml:"style"`
	Prefix string `yaml:"prefix"`
	// OtherPrefixes are prefixes a record may legitimately carry that this
	// Recipe does not mint, for providers whose identifiers do not all have
	// one shape.
	//
	// Auth0 is the reason. A user_id encodes the connection the user came
	// from: auth0|abc is a database user, google-oauth2|123 signed in with
	// Google, samlp|... came from an enterprise connection. Code that parses
	// the identifier assuming auth0| breaks on the first social login, which
	// is the first thing the Auth0 Recipe says. Its fixture holds a Google
	// user on purpose.
	//
	// Without this the fixture and the declaration have to disagree, and the
	// only ways to settle it are both lies: drop the social user, which
	// deletes the trap, or drop the prefix, which claims Auth0 mints bare
	// strings. Minting still uses Prefix -- there is one shape a new record
	// takes -- and these are the others a real account already contains.
	OtherPrefixes []string `yaml:"other_prefixes"`
	Length        int      `yaml:"length"`
	// Field is the property the provider returns the identifier in. Empty means
	// "id". Twilio calls it "sid" everywhere, and code that reads response.id
	// against Twilio gets nothing at all. A dotted name nests, which is how
	// Contentful keeps the identifier at sys.id.
	//
	// "-" means the provider does not echo it at all. Some resources are
	// addressed by a key that appears only in the path: Marqeta's balance is
	// fetched at /v3/balances/{token} and the body that comes back carries no
	// token anywhere. Cauldron still keys the record internally, because it
	// has to be found somehow, but emitting an identifier the provider never
	// sends would put a field on the wire that real code cannot rely on.
	Field string `yaml:"field"`
	// Type is the JSON type the identifier travels as: "string" (the default)
	// or "number".
	//
	// Identifiers are minted and stored as strings, because that is the only
	// form every style shares and the only form a path parameter arrives in.
	// What they are on the wire is a separate question, and the two answers
	// disagree more often than they agree. GitHub sends an issue id as the
	// number 1. HubSpot sends a contact id as the string "1". Meilisearch
	// sends a task uid as a number and Jira sends an issue id as a string.
	//
	// It is not cosmetic. id === 1 fails against a string, typeof id ===
	// "number" fails, and a schema declaring "type": "integer" rejects the
	// response outright. An emulator answering with a string where the
	// provider answers with a number commits the exact class of bug it exists
	// to catch.
	//
	// The default stays "string" because changing it silently would rewrite
	// the wire shape of every shipped Recipe at once, and each one has to be
	// checked against its provider rather than assumed.
	Type string `yaml:"type"`
	// Pattern is the shape an identifier has to have for the provider to look
	// it up at all, as a regular expression anchored by the Recipe.
	//
	// Declaring it says the provider checks before it searches, which is a
	// distinction the collection could not make: every absence was a 404.
	// Squarespace documents both answers on one route -- 404 "The requested
	// Order was not found" for an id that could exist and does not, and 400
	// "The id is not in the expected format" for one that could not -- and
	// Stripe, Intercom and everything else built on ObjectIds behave the same
	// way.
	//
	// It matters because the two failures are not interchangeable to the code
	// receiving them. A 404 is a fact about the account: the order was
	// deleted, or belongs to somebody else, and retrying will not help. A 400
	// is a fact about the caller: an id from the wrong provider, a truncated
	// string, an empty variable interpolated into the path. An emulator that
	// answers 404 to both teaches an application to treat its own bugs as
	// missing data -- and the test that proves the handler works asks for
	// "nonexistent", which is exactly the id that does not behave this way.
	//
	// Empty means the provider looks up whatever it is given, which is the
	// majority and stays the default.
	Pattern string `yaml:"pattern"`
	// CarriedBy names the field that holds the identifier when the provider
	// does not send it under a name of its own.
	//
	// Dwolla is HAL: there is no id property on anything, and identity lives
	// in _links.self.href with the identifier as its last segment. So the
	// record is addressable and the id is genuinely absent, which are two
	// things that are usually not true together, and a Recipe that only said
	// field: "-" would be claiming a listing hands back records nothing can
	// identify.
	//
	// It is a declaration rather than a behaviour: nothing reads it at
	// runtime. What it does is answer the question a reader of the Recipe
	// asks first -- if there is no id, how do I address one of these -- and
	// let the validator tell a described absence from an undescribed one.
	CarriedBy string `yaml:"carried_by"`
}

ID describes how the provider mints identifiers. Getting this right matters more than it looks: applications routinely parse or prefix-match IDs.

type ListResponse

type ListResponse struct {
	// Style is one of: envelope (Stripe), bare (GitHub), wrapped (Shopify),
	// map (Pusher, whose channels arrive as an object keyed by channel name
	// rather than an array, so looping over it as a list finds nothing and a
	// channel with no occupants is absent from the object entirely rather
	// than present with a zero).
	// Empty means envelope, which keeps existing Recipes working.
	Style string `yaml:"style"`
	// Key is the wrapping property name, required when style is wrapped. A
	// dotted name nests, so a collection can sit two levels down: Segment
	// answers with data.sources rather than a top-level array.
	Key string `yaml:"key"`
	// URL asks the envelope to echo the request path, which Stripe does.
	URL bool `yaml:"url"`
	// CursorField names a property carrying the next cursor. Most providers do
	// not send one: Stripe expects the caller to pass the last id back as
	// starting_after. Leaving it empty is therefore the faithful default, and
	// setting it is a deliberate claim that the provider really sends it.
	//
	// A dotted name nests, so Slack's response_metadata.next_cursor is
	// expressible without a second mechanism.
	CursorField string `yaml:"cursor_field"`
	// CursorNull sends the cursor field as null on the last page rather than
	// leaving it out.
	//
	// Absent and null are different on the wire, and for a paging loop the
	// difference decides whether it terminates. Metronome's customer listing
	// declares next_page required and nullable and its own example shows
	// "next_page": null on the last page; Notion's next_cursor has the same
	// shape. A loop written as `while (body.next_page !== undefined)` stops
	// against a provider that omits the key and runs for ever against one
	// that nulls it, and a loop written the other way round fails in exactly
	// the opposite circumstances.
	//
	// Omitting stays the default, for the same reason CursorField is opt in
	// at all: sending a field the provider does not send is the more
	// dangerous of the two mistakes.
	CursorNull bool `yaml:"cursor_null"`
	// CursorURL says the cursor field carries an address rather than a token,
	// and which kind: "absolute" for a whole URL, "path" for the path and
	// query alone.
	//
	// The difference is not cosmetic. Salesforce sends a path because its
	// clients join it to the instance URL they authenticated against, so an
	// absolute address would be joined to that and produce nonsense -- the
	// same concatenation bug as a token, arrived at from the other side.
	//
	// Eight Recipes describe their paging pointer as a full URL and emitted an
	// opaque cursor, so the fake taught the mistake the Recipe warned about.
	// Merge's own comment put it exactly: "both full URLs rather than opaque
	// cursors ... a client concatenating a base URL to next builds a URL that
	// does not exist" -- and a client written against a token does precisely
	// that concatenation.
	//
	// The URL is this request with its position moved on, which is what the
	// Link header already renders, so a Recipe saying so gets the same value
	// in its body.
	CursorURL string `yaml:"cursor_url"`
	// CountField names a property carrying how many records matched in total,
	// which is not the same as how many are on this page. Zendesk sends one and
	// a pagination UI cannot be built without it.
	CountField string `yaml:"count_field"`
	// CountMeans says what the count field counts, for the providers where it
	// is not how many records matched.
	//
	// Empty is the whole matching set, which is what every Recipe before this
	// assumed and what nearly every provider sends. Two other quantities
	// arrive under the same name:
	//
	// "page" is the length of the page in front of you. Shopware sends this
	// by default, from a field called total, because computing a real total
	// costs a second query and it does not run one unless asked. So a shop
	// with four hundred products answers a ten-record page with total: 10 --
	// a number that is not wrong about anything except the question it looks
	// like it is answering. A client that stops when it has read total
	// records reads one page; a client that divides total by the page size
	// finds one page; and neither errors, because ten really is a number of
	// products.
	//
	// "lookahead" is a bounded count: the provider fetches a few pages past
	// this one, counts what it found, and stops. Shopware's next-pages mode
	// reads limit * 6 + 1 rows and reports how many came back, so the same
	// four hundred products report 61. That is neither the page nor the
	// total, and it is the most misleading of the three, because it is large
	// enough to look real.
	//
	// The distinction cannot be left to the fixture. A fixture small enough
	// to fit on one page makes all three modes agree, which is exactly why a
	// Recipe can describe one and serve another and no case notices.
	CountMeans string `yaml:"count_means"`
	// CountLookahead is how many pages a lookahead count reaches, including
	// the one being served. Shopware's is six.
	//
	// The count is limit * CountLookahead + 1 where the collection is larger
	// than that, and the real total where it is not. The extra row is the
	// provider's sentinel: its presence is how the shop knows there is
	// anything beyond the window, and it is why the number ends in a 1 rather
	// than landing on a page boundary.
	CountLookahead int `yaml:"count_lookahead"`
	// PageCountField names a property carrying how many records are on this
	// page, for the providers that send that beside the total rather than
	// instead of it.
	//
	// count_means exists because Shopware sends one number and it is
	// sometimes the page; this exists because commercetools sends both, and
	// the format could previously say only one of them. Its listings carry
	// count -- "actual number of results returned" -- next to total, which is
	// the whole matching set and which its own description warns is an
	// estimate rather than a strongly consistent figure.
	//
	// Naming them apart is the honest half of the same problem. A provider
	// that sends both has told the caller which is which; the trap is only
	// there when one name has to carry both meanings.
	PageCountField string `yaml:"page_count_field"`
	// PagesField names a property carrying how many pages the whole set makes
	// at this page size, which is a different quantity from CountField.
	//
	// Documenso's list envelope is {documents, totalPages} and nothing else,
	// and totalPages was declared as the count field -- so three documents at
	// ten per page reported three rather than one. That is worse than an
	// invented field: the name is real and the number is plausible, so a
	// client looping while page <= totalPages asks for two pages that do not
	// exist and reads them as empty results rather than as a mistake.
	//
	// An empty set is nought pages here. Providers differ about whether it is
	// nought or one, and nought is the reading that stops a loop rather than
	// sending it after a page with nothing in it.
	PagesField string `yaml:"pages_field"`
	// EntryField makes each entry in the collection that one field's value
	// rather than the whole record.
	//
	// Plenty of APIs answer a listing with an array of identifiers and keep
	// the object for the fetch beside it. DynamoDB's ListTables sends
	// TableNames as an array of strings; SQS's ListQueues sends QueueUrls the
	// same way. Both Recipes emitted arrays of objects under those names, so
	// a client doing TableNames.forEach(name => describe(name)) received
	// objects and called describe([object Object]).
	//
	// It belongs on the route rather than the Recipe, because the listing and
	// the fetch disagree by design: DescribeTable answers with the table and
	// ListTables answers with its name.
	EntryField string `yaml:"entry_field"`
	// LinkHeader advertises the next page in an RFC 5988 Link response
	// header rather than in the body.
	//
	// Five providers modelled here page that way and it is the mechanism
	// their own documentation leads with: GitHub, Ably, WordPress, Greenhouse
	// and Buildkite. Buildkite's says it plainly -- "the pagination
	// information can be found in the Link HTTP response header" -- and Ably
	// pages by nothing else at all.
	//
	// Without it the page size works and the next page is unreachable, which
	// is the quietest way for a listing to be wrong: one page comes back, it
	// is a correct page, and the loop that should have asked for the second
	// one has nothing to follow.
	//
	// Only next is emitted. Providers also advertise prev, first and last,
	// and last needs a total this does not have -- so a client that follows
	// next walks the whole collection here, and one that reads last finds
	// nothing. That is stated rather than guessed at.
	LinkHeader bool `yaml:"link_header"`
	// PrevLink adds a rel="prev" beside the next link, for the providers that
	// send one.
	//
	// Not implied by LinkHeader, because providers disagree and the
	// disagreement is the whole point of asking. GitHub's last page carries a
	// Link header holding rel="prev" and no next; Basecamp's own README
	// describes rel="next" alone, so its last page carries no header at all.
	// A client that stops when the header is missing works against Basecamp
	// and never terminates against GitHub.
	//
	// Only offset and page numbering can have one. A cursor names a position
	// the caller was handed and cannot be arithmetic'd backwards.
	PrevLink bool `yaml:"prev_link"`
	// PageField and LimitField name properties echoing the page number and
	// the page size the request asked for.
	//
	// A constant cannot do this job, and putting one there is worse than
	// leaving the field out. Algolia answers every search with the page it
	// served and the page size it used, and the Recipe declared them as the
	// constants 0 and 20 -- so a client that asked for page 3 was told it was
	// looking at page 0, by a field whose entire purpose is to say where you
	// are. Paging code that trusts the response rather than its own counter
	// reads that as "still on the first page" forever.
	PageField  string `yaml:"page_field"`
	LimitField string `yaml:"limit_field"`
	// CountAsString sends the counts as strings. Docusign does, and code that
	// compares totalSetSize to a number never matches, so emitting a number
	// here would quietly fix a bug the caller has to handle.
	CountAsString bool `yaml:"count_as_string"`
	// HasMoreField names a boolean saying whether more pages remain. The
	// envelope style always sends has_more because Stripe does; other styles
	// send one only when the Recipe says so.
	HasMoreField string `yaml:"has_more_field"`
	// OmitWhenEmpty leaves the collection key out entirely when there is
	// nothing to send, rather than sending an empty array.
	//
	// SQS does this, and it is the difference between a consumer that waits on
	// an idle queue and one that throws. ReceiveMessage with nothing to give
	// answers 200 with no Messages key at all, so
	// `for (const m of response.Messages)` fails on the quietest possible
	// input. An emulator sending [] is the helpful kind of wrong: every test
	// passes and the first quiet minute in production does not.
	OmitWhenEmpty bool `yaml:"omit_when_empty"`
	// FinalField names a field sent only on the last page of a list, and left
	// out of every page before it.
	//
	// Google Calendar sends nextSyncToken this way and Microsoft Graph sends
	// @odata.deltaLink. The two tokens are not interchangeable: a page token
	// resumes the listing you are in the middle of, and a sync token starts a
	// later incremental one. Only one of them is ever present, so code that
	// reads whichever it finds on the first response and calls it "the token"
	// stores a page token, and the next sync either replays from the
	// beginning or fails with an error that names neither field.
	//
	// Sending it on every page would be the helpful kind of wrong. The
	// caller's storage logic would work locally against any list short enough
	// to fit one page, and break on the first calendar busy enough to need
	// two.
	FinalField string `yaml:"final_field"`
	// CompleteField names a boolean saying the opposite: that no pages remain.
	//
	// Salesforce sends done, and false is the interesting value. Modelling it
	// as a negated has_more would be a lie about the field's name, and
	// modelling it as has_more would invert its meaning, so a query that
	// matched more rows than it returned would claim to be finished. Code that
	// ignores done processes a prefix of its own result set and is never told.
	CompleteField string `yaml:"complete_field"`
	// Fields are constants added to a list response only. Notion stamps
	// {"object": "list"} on a collection and {"object": "page"} on a single
	// page, so the two cannot share one set of envelope constants.
	Fields map[string]any `yaml:"fields"`
	// EntryStyle wraps each item in the collection under the resource's own
	// name when set to "wrapped". Chargebee answers a subscription list with
	// {"list": [{"subscription": {...}}]}, so a client reads
	// list[0].subscription.id and anyone indexing straight into the item
	// finds nothing.
	EntryStyle string `yaml:"entry_style"`
	// CollapseSingle sends a collection of one as the object rather than as a
	// list of one.
	//
	// Tradier documents it in its own words: if you have a single order, it
	// will be returned as a JSON obj/dict whereas multiple orders will be
	// returned as an array. Every API that grew out of XML does this, because
	// a single child element and a repeated one are the same thing there and
	// are not the same thing in JSON.
	//
	// It is the dangerous half of an axis this format already had. Xero sends
	// one invoice as a list of one, which resource.array describes, and that
	// is the safe direction: a client written for the list keeps working. This
	// is the other way round, and a client written against a fixture with two
	// records in it crashes the first time production has one.
	CollapseSingle bool `yaml:"collapse_single"`
}

ListResponse describes how a collection is returned.

type Pagination

type Pagination struct {
	// Style is one of: cursor, offset, page.
	Style string `yaml:"style"`
	Limit int    `yaml:"limit"`
	// MaxLimit is the largest page a provider will serve, for the ones that
	// cap it and answer with less rather than refusing.
	//
	// Printify is the reason. Its own description says "default: 10, maximum:
	// 10", so a client asking for a hundred orders is answered with ten and
	// not told -- and a paging loop that stops when it receives fewer records
	// than it asked for stops on the first page. A shop with four hundred
	// orders reports ten, and nothing errored.
	//
	// Without this the declared Limit is only a default and the caller always
	// wins, so a Recipe could describe the cap in a comment and not serve it.
	// Zero means the provider serves whatever is asked for, which is what
	// every Recipe written before this assumed.
	MaxLimit int `yaml:"max_limit"`
	// OverLimit names the failure a route answers with when the caller asks
	// for a bigger page than MaxLimit, for the providers that refuse instead
	// of trimming.
	//
	// Both answers are common and they are not interchangeable. Printify
	// trims: ask for a hundred, receive ten, hear nothing. Shopware's entity
	// route refuses: ask for two hundred and fifty, receive
	// FRAMEWORK__QUERY_LIMIT_EXCEEDED and a 400 naming the ceiling. A client
	// written against one is broken against the other in opposite
	// directions -- one silently under-reads a collection, the other throws
	// on a request it thought was fine.
	//
	// Shopware is also why this belongs on the route rather than the Recipe.
	// It does both, on two listings of the same resource: /store-api/product
	// refuses and /store-api/product-listing/{categoryId} trims at the same
	// hundred, because the second is the storefront's own listing and runs
	// through a processor that calls min() on its way past.
	//
	// Empty keeps the trimming behaviour, which is what every Recipe written
	// before this assumed.
	OverLimit string `yaml:"over_limit"`
	// LimitParam names the query parameter carrying the page size, for the
	// providers that do not call it "limit". Google Calendar calls it
	// maxResults, GitHub calls it per_page, Salesforce does not accept one at
	// all.
	//
	// "-" says the provider accepts no name for it, which is different from
	// leaving it empty: empty falls back to reading "limit", and "-" reads
	// nothing and keeps the declared page size. Datadog's event listing fixes
	// the page at a thousand and takes no size parameter.
	//
	// It matters more than it looks. An emulator that only understands "limit"
	// ignores the size the caller asked for and answers with its own default,
	// and for a fixture of four records that default is the whole collection.
	// The response has no next page in it, so the paging loop the client
	// carefully wrote executes exactly once and every test of it passes
	// without ever taking the branch. The first collection large enough to
	// page is in production.
	//
	// Declaring it also makes "limit" inert, which is the faithful part:
	// Google does not accept limit, and an emulator that quietly honours both
	// spellings lets a typo work locally.
	LimitParam string `yaml:"limit_param"`
	// CursorParam names the query parameter carrying the position to resume
	// from, for the providers that call it neither cursor nor starting_after.
	// Google calls it pageToken and Shopify calls it page_info.
	CursorParam string `yaml:"cursor_param"`
	// FirstPage is the number the provider gives its first page, for the page
	// style. Empty means one.
	//
	// Providers disagree, and the disagreement is invisible: Algolia,
	// Elasticsearch and everything shaped like them count from nought, so
	// page 1 is the second page. Read as though it were the first, a client
	// asking for page 1 is handed page 0 again -- the same record twice, no
	// error, and a loop that either never terminates or quietly returns
	// duplicates. That is the off-by-one-page bug positionOf already warned
	// about, in the direction nobody had checked.
	FirstPage *int `yaml:"first_page"`
	// In is where the parameters travel: "query" (the default) or "body".
	//
	// A listing reached by POST usually carries its paging in the JSON body,
	// and reading it from the query string means reading nothing at all. What
	// that produced was worse than an error: the caller's limit was ignored,
	// so the first request answered with the entire collection and no next
	// page, and a paging loop written against it ran exactly once and looked
	// correct. Dropbox shipped that way, and its own conformance case sent
	// ?limit=1 -- a parameter Dropbox does not read -- because the case was
	// written against what came out rather than against the provider.
	//
	// A dotted name nests, because a provider that puts paging in the body
	// often puts it inside something: Plaid's count and offset live under
	// options.
	In string `yaml:"in"`
}

Pagination describes how a list endpoint pages.

func (Pagination) FirstPageNumber

func (p Pagination) FirstPageNumber() int

FirstPageNumber is the number this provider gives its first page. One unless the Recipe says otherwise.

type Recipe

type Recipe struct {
	Name    string `yaml:"recipe"`
	Version string `yaml:"version"`
	// Capability is what kind of thing this provider is, so a hundred Recipes
	// can be found by what they do rather than by whether you remember the
	// company's name.
	//
	// One word from a fixed list, not a free string. The value of a category
	// is that two people reaching for it independently land on the same one,
	// and a free string gives you "payments", "payment", "billing" and "money"
	// within a month. Adding a word is a deliberate change to the list.
	//
	// Deliberately not part of the Recipe's name. Renaming stripe to
	// payments.stripe would break every configuration and every command
	// anybody has already written, to buy a grouping a field gives for
	// nothing.
	Capability string              `yaml:"capability"`
	Upstream   Upstream            `yaml:"upstream"`
	Auth       Auth                `yaml:"auth"`
	Resources  map[string]Resource `yaml:"resources"`
	Routes     []Route             `yaml:"routes"`
	Webhooks   Webhooks            `yaml:"webhooks"`
	Responses  Responses           `yaml:"responses"`
	Errors     map[string]Error    `yaml:"errors"`
	Fixtures   map[string]Fixture  `yaml:"fixtures"`
	// RequiredHeaders are headers a request must carry, mapped to the error
	// name to raise when one is missing. Forgetting Notion-Version is the
	// classic Notion integration bug, and a fake that does not enforce it lets
	// code ship that fails on the first real call.
	RequiredHeaders map[string]RequiredHeader `yaml:"required_headers"`
	// Conformance is the evidence that this Recipe resembles the real provider.
	Conformance []Case `yaml:"conformance"`
}

Recipe is a complete emulation description for one provider.

func Load

func Load(path string) (*Recipe, error)

Load reads and validates a Recipe from a YAML file.

func Open

func Open(name string) (*Recipe, error)

Open loads a bundled Recipe by name.

func Parse

func Parse(contents []byte) (*Recipe, error)

func (Recipe) EnvelopeFor

func (r Recipe) EnvelopeFor(route Route) ResourceResponse

ListFor returns the list envelope a route answers with: the Recipe-wide one, with the route's own overrides applied.

Empty means inherit and "-" means clear, so a route can both add a field the Recipe does not declare and remove one it does. A boolean can only be turned on, because an unset boolean and a false one are the same value in YAML and guessing which was meant is how a Recipe ends up asserting something nobody wrote. EnvelopeFor is how this route wraps a single object.

The Recipe's own setting unless the route overrides it. Empty inherits and "-" clears, so a Recipe that wraps everything can say that one route does not -- which is the shape Datadog and Vercel both have, wrapping some of their resources and not others.

func (*Recipe) Events

func (r *Recipe) Events() []string

Events returns the webhook event names this Recipe can emit.

func (Recipe) GuessedPagination

func (r Recipe) GuessedPagination() int

GuessedPagination counts the routes whose paging the runtime has to guess at: a declared page size with neither a style nor a parameter name beside it.

The runtime then reads "limit", which is right for some providers and wrong for plenty, and the wrongness is invisible -- the page size is ignored, one full page comes back, and the caller's paging loop runs once and passes. A Recipe in that state is making a claim nobody checked, and the point of counting them is that the number should be visible rather than buried.

Naming either the style or the parameter is what marks a route as checked, because neither is a name anybody writes down by accident.

func (Recipe) ListFor

func (r Recipe) ListFor(route Route) ListResponse

func (Recipe) UnstatedPagination

func (r Recipe) UnstatedPagination() int

UnstatedPagination counts the listings that say nothing about paging at all.

The runtime pages every listing: a route with no page size declared is given ten and reads "limit", exactly as a route declaring a size with no name is. GuessedPagination cannot see these, because it starts from a declared page size -- so the figure it reports has always been the smaller half of its own justification. Sixty routes page by a parameter nobody named; another hundred and eight page by a parameter and a page size nobody named.

Nothing is truncated by it today, because no fixture behind one of these holds more than ten records. That is the reason it stayed invisible and not a reason it is fine: the claim is about the provider, and the fixture is not the provider. A listing the Recipe describes as unpaged answers at most ten and offers a cursor, and the first collection large enough to notice is not going to be one of ours.

Counted apart rather than folded in, because they are not the same omission. One Recipe looked at paging and did not finish; the other has not looked.

func (*Recipe) Validate

func (r *Recipe) Validate() error

Validate checks the Recipe is internally consistent.

func (*Recipe) Verified

func (r *Recipe) Verified() (observed, documented int)

Verified reports how many conformance cases were observed against the real API, and how many rest on documentation alone. The distinction is the whole value of the suite, so it is reported rather than averaged away.

type Request

type Request struct {
	Method  string            `yaml:"method"`
	Path    string            `yaml:"path"`
	Query   map[string]string `yaml:"query"`
	Headers map[string]string `yaml:"headers"`
	// Form sends application/x-www-form-urlencoded, which is what Stripe's own
	// SDKs send. JSON sends a JSON body. A case may set at most one.
	Form map[string]string `yaml:"form"`
	JSON map[string]any    `yaml:"json"`
}

Request is the call a conformance case makes.

type RequiredHeader

type RequiredHeader struct {
	// Error names the error to raise when the header is missing.
	Error string `yaml:"error"`
	// Methods limits the requirement to those HTTP methods. Empty means all.
	Methods []string `yaml:"methods"`
}

RequiredHeader is one header a request must carry.

It reads from YAML either as a bare error name, meaning every request needs the header, or as a mapping with a methods list, meaning only those methods do. The second form exists because Greenhouse only wants On-Behalf-Of on a write: reads work without it, so an integration passes every test it has and then gets a 403 the first time it tries to change something.

func (RequiredHeader) Applies

func (h RequiredHeader) Applies(method string) bool

Applies reports whether the header is required for this HTTP method.

func (*RequiredHeader) UnmarshalYAML

func (h *RequiredHeader) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML accepts either a bare error name or the full mapping.

type Resource

type Resource struct {
	// Collection is the plural name the provider wraps lists in, e.g. "orders"
	// for an order. Declared rather than derived: guessing English plurals is
	// exactly the kind of cleverness that produces a fake which is subtly
	// wrong for "person", "category" or "status".
	Collection string `yaml:"collection"`
	ID         ID     `yaml:"id"`
	// Alias names a second field a path may address this resource by.
	//
	// Jira answers /issue/10001 and /issue/PLAT-42 with the same issue. The
	// two identifiers are not interchangeable underneath: the numeric one is
	// permanent and the key changes when the issue moves project, so anything
	// that stored the readable one holds a dangling reference and gets no
	// error saying so. An emulator accepting only the identifier would reject
	// half the calls that work against the real API.
	Alias string `yaml:"alias"`
	// VersionField names a field the provider keeps as an optimistic lock: a
	// number that moves on every write, which a caller has to quote back
	// before the provider will accept the next one.
	//
	// commercetools is the reason. Every resource it serves carries a
	// version, every update body is {version, actions} rather than a
	// document, and writing over a version that is not the current one is
	// refused with the current one in the reply so the retry can be
	// scripted. Without a way to say that, a Recipe describing such an API
	// serves an emulator that takes any write at all -- and the code written
	// against it passes every test, because a test suite is the one place
	// where nothing else is writing.
	//
	// That is the failure this is for. Ignoring the version is invisible
	// until two things touch one record at once, and then it is a silent
	// overwrite rather than an error: the later write wins and the earlier
	// one is gone, with nothing logged anywhere.
	VersionField string `yaml:"version_field"`
	// VersionConflict names the failure a stale write is refused with.
	VersionConflict string `yaml:"version_conflict"`
	// VersionMissing names the failure a write carrying no version at all is
	// refused with, for the providers that require one.
	//
	// Separate from VersionConflict because providers separate them, and
	// commercetools does: a stale version is a 409 that hands back the
	// current one, and an absent version is a 400 about a required field.
	// A client that retries on 409 and gives up on 400 needs them to be
	// different, and folding the two together here would teach it that every
	// rejected write is worth retrying.
	//
	// Empty lets a write with no version through, which is what a provider
	// that treats the field as optional does.
	VersionMissing string           `yaml:"version_missing"`
	Fields         map[string]Field `yaml:"fields"`
	// Constants are fields the provider always sends with a fixed value, such
	// as Stripe's object discriminator and livemode flag. Unlike a default they
	// cannot be overridden by the caller, because the provider does not let you
	// override them either. Applications really do branch on these.
	Constants map[string]any `yaml:"constants"`
}

Resource is an object type the provider exposes.

type ResourceResponse

type ResourceResponse struct {
	// Style is bare (the default) or wrapped.
	Style string `yaml:"style"`
	// Key is the wrapping property name. Empty uses the resource's own name,
	// which is what Shopify does. Cloudflare wraps everything under "result"
	// regardless of what the object is, so the name has to be declarable.
	Key string `yaml:"key"`
	// Array wraps the single object in a list. Xero answers a request for one
	// invoice with {"Invoices": [{...}]}, so client code reads Invoices[0] and
	// anyone expecting an object gets an array with no warning. With Array
	// set, Key defaults to the resource's plural collection name rather than
	// its singular one, because a list of one is still a collection.
	Array bool `yaml:"array"`
}

ResourceResponse describes how a single object comes back.

Shopify wraps it under the singular resource name, so a client reads body.order.id. Stripe and GitHub return the object itself. Getting this wrong is not a cosmetic difference: every field access is one level out.

type Responses

type Responses struct {
	List     ListResponse     `yaml:"list"`
	Error    ErrorResponse    `yaml:"error"`
	Resource ResourceResponse `yaml:"resource"`
	Success  SuccessResponse  `yaml:"success"`
}

Responses describes the envelopes a provider wraps its payloads in.

This exists because providers genuinely disagree: Stripe returns {object, data, has_more}, GitHub returns a bare array, Shopify nests under a resource key. Hardcoding one of them would make every other provider a second-class citizen.

type Route

type Route struct {
	Method   string `yaml:"method"`
	Path     string `yaml:"path"`
	Resource string `yaml:"resource"`
	// NotFound names the error to raise when this route is asked for a record
	// that is not there, instead of resource_missing.
	//
	// One provider can answer a missing thing two ways, and the npm registry
	// does. A package that does not exist answers {"error":"Not found"}; a
	// version that does not exist on a package that does answers the bare JSON
	// string "version not found: 99.99.99". Same status, same registry, and
	// nothing but the route to tell the emulator which is which.
	NotFound string `yaml:"not_found"`
	// Emits names the webhook event this route fires, for the providers whose
	// event names are not resource.created.
	//
	// The runtime emits resource.action and nothing else, so a Recipe declaring
	// Freshdesk's ticket_create, Bitbucket's repo:push or Zoom's meeting
	// .started -- all of them the provider's real names -- had those events
	// declared and never fired. Creating a record produced no webhook at all,
	// silently, and the only way to see one was to ask for it by hand.
	//
	// Naming the event here is what connects a change to the notification a
	// provider would actually send.
	//
	// Most of the collection is now wired. What is left declared and unfired
	// is mostly not wirable this way: an event like Freshdesk's
	// ticket_status_change or Recurly's renewed_subscription_notification is
	// not what an update route does, it is what one particular change to one
	// field does. EmitsWhen is for those.
	Emits string `yaml:"emits"`
	// EmitsWhen names events that fire only when a particular field changes,
	// rather than on every write.
	//
	// A great many declared events are this shape and no other: Freshdesk
	// sends ticket_status_change when a ticket's status moves and stays quiet
	// when its subject is edited, and ClickUp does the same for
	// taskStatusUpdated. Hanging those off the update route unconditionally
	// would be worse than leaving them unfired, because an application would
	// see the event on every edit here and on almost none in production --
	// the emulator would teach the handler to run when it will not.
	//
	// A list, because one route can owe several: the same Freshdesk update
	// answers for status and priority separately. It composes with Emits, so
	// a route may send an unconditional ticket_updated and a conditional
	// ticket_status_change from the same write, which is what Freshdesk does.
	EmitsWhen []ChangeEmit `yaml:"emits_when"`
	// Operation is one of: create, get, list, update, delete.
	Operation string `yaml:"operation"`
	// Scope names the path parameters that partition this resource, e.g.
	// [owner, repo] for /repos/{owner}/{repo}/issues. Scoped requests only
	// ever see records whose matching fields agree, and creates stamp them.
	//
	// Path parameters left out of scope are ignored, which is how an API
	// version segment like /admin/api/{version}/orders stays a path parameter
	// without becoming a filter.
	Scope []string `yaml:"scope"`
	// Status is the success status this route returns. Empty means 200. GitHub
	// answers a create with 201, Stripe with 200, and a client checking for one
	// exact code is not being unreasonable.
	Status int `yaml:"status"`
	// Fields are constants this route adds to its response body, on top of
	// whatever the Recipe-wide response constants already put there.
	//
	// A one-path API needs them. ShipHero puts request_id and complexity
	// beside each connection -- data.orders.complexity, data.products.complexity
	// -- so the key depends on which query was asked, and a Recipe-wide
	// constant would stamp the orders metadata onto a products response. A
	// dotted name nests, the same way the Recipe-wide ones do.
	Fields map[string]any `yaml:"fields"`
	// Selects disambiguates several routes that share one path by what the
	// request body asks for.
	//
	// A GraphQL API is one path and one method, so the path cannot say which
	// route should answer. What can is the query itself: a request naming
	// `orders` wants the orders route and one naming `products` wants that
	// one. Selects holds the root field to look for, and the route matches
	// only when the body's query mentions it.
	//
	// This does not parse GraphQL and does not pretend to. It looks for the
	// field named as a whole word -- a substring match sent `viewer` queries
	// to a route selecting `me`, because "name" contains it -- which is
	// enough to pick a fixture and is exactly the bargain every Recipe here
	// already makes: model what comes back, not how the provider decided it.
	//
	// Seven providers were unreachable without this -- Linear, Monday, Attio,
	// New Relic, Railway, ShipHero, and half of Fly.io -- and each had been
	// recorded as its own judgement call rather than as one missing feature.
	Selects string `yaml:"selects"`
	// SelectsBody does the same job as Selects and looks anywhere in the
	// request body rather than only in a GraphQL query.
	//
	// It is a separate field on purpose. Selects is fed the `query` property
	// of a GraphQL envelope and nothing else, and seven Recipes depend on
	// that narrowness: a marker word that today matches only inside a query
	// would start matching variable names and argument values if the search
	// were widened underneath them.
	//
	// What it is for is the providers whose response shape depends on what
	// the request asked for rather than on where it was sent. Gemini answers
	// a blocked prompt with a 200 and no candidates array at all, and a
	// permitted one with candidates and no block reason -- one path, one
	// method, two shapes, and nothing outside the body to tell them apart.
	// That was written up in the backlog as unservable before this existed.
	//
	// The match is the same whole-word one Selects makes, over the raw body
	// rather than a parsed field, because no emulator here can decide which
	// answer a model would have given and the marker is how a fixture says
	// which one to serve.
	SelectsBody string `yaml:"selects_body"`
	// IDFrom says where the identifier comes from when it is not a path
	// parameter: "query:channel" or "body:channel". A body name may be
	// dotted, because a provider that puts the identifier in the body does
	// not always put it at the top of one -- DynamoDB's GetItem takes
	// {"Key": {"id": {"S": "..."}}}, which is body:Key.id.S. Slack and every other
	// RPC-shaped API put it in the query string or the body, and without this
	// the format could only describe APIs that happen to be RESTful.
	//
	// "auth" is the third case and it is not a location: it says the request
	// carries no identifier at all and the provider answers about whoever the
	// credentials belong to. GitHub's /user, Stripe's /v1/account, Slack's
	// auth.test and Backblaze's b2_authorize_account are all this shape, and
	// none of them could be described before, because both other forms name a
	// place to read from and the whole point of these routes is that there is
	// nothing to read.
	IDFrom string `yaml:"id_from"`
	// EmptyBody sends no body at all. SendGrid accepts a send with 202 and
	// nothing else, and a client that calls .json() on that response throws.
	// An emulator that helpfully returns an object hides the bug.
	EmptyBody bool `yaml:"empty_body"`
	// Headers are response headers this route sets. "{id}" is replaced with
	// the record's identifier, which is how SendGrid hands back the message id
	// a client needs to correlate a later event with the send.
	Headers    map[string]string `yaml:"headers"`
	Pagination Pagination        `yaml:"pagination"`
	// Filters are query parameters that narrow a listing, and the reason they
	// exist is the default rather than the filtering.
	//
	// GitHub's issue listing answers with open issues unless you ask for
	// otherwise. Alpaca's order listing answers with open orders. Both are
	// documented and both are forgotten, and the failure they produce is the
	// worst-shaped one there is: a client places an order, the order fills, the
	// client lists its orders and sees nothing, and concludes the order never
	// existed. Nothing errored. The list was correct.
	//
	// An emulator that returns everything is being helpful in the direction
	// that hides this. Cauldron did exactly that until this existed, and
	// GitHub's own Recipe had a closed issue in its fixture that the listing
	// returned and the real API would not.
	Filters []Filter `yaml:"filters"`
	// Beside names other resources whose records travel in the same response
	// body, each under its own collection name.
	//
	// One endpoint, several collections, and the fact that it is one endpoint
	// is the point. GoCardless Bank Account Data answers a request for
	// transactions with a booked array and a pending array in one body: the
	// same purchase appears in pending first and booked later, with a
	// different identifier, so code that merges the two arrays counts it
	// twice. Describing that as two endpoints would lose the thing worth
	// describing, and describing only one of the arrays would answer with a
	// shape no bank sends.
	//
	// The scope applies to all of them, because they are one request. Paging
	// applies only to the route's own resource, because that is the one the
	// cursor refers to.
	Beside []string `yaml:"beside"`
	// LookupBy names the field the value from IDFrom is matched against, for
	// the routes that address a record by something that is not its
	// identifier.
	//
	// SQS deletes a message by the receipt handle from a receive, and a
	// receipt handle is deliberately not a message id: it is issued per
	// receive, two consumers holding two handles for the same message is
	// normal, and a handle from an earlier receive is stale. Anything keyed
	// by a natural key -- an email address, an external reference, a slug --
	// has the same shape.
	//
	// IDFrom says where the value comes from; this says what it is compared
	// with. Without it a handle was looked up as though it were an id, found
	// nothing, and every delete failed.
	LookupBy string `yaml:"lookup_by"`
	// MatchesHeader names request headers whose values pick this route, for
	// the APIs where the path does not say which operation you meant.
	//
	// The AWS JSON protocol is the reason it exists: every operation is a
	// POST to the root and the operation is named in X-Amz-Target. Without a
	// way to route on that, the three AWS Recipes here encoded the operation
	// in the path instead -- /ListSecrets, /tables, /queues -- and served
	// URLs AWS does not have. A client can be written entirely against those
	// paths, pass every test, and be entirely wrong.
	//
	// It is the same shape as selects, which tells GraphQL routes apart by a
	// word in the query body: one path, several routes, distinguished by
	// something that is not the path. A route declaring it beats an
	// equally-scoring route that declares nothing, so a Recipe can have a
	// fallback for the operations it does not model.
	MatchesHeader map[string]string `yaml:"matches_header"`
	// MatchesQuery names query parameters whose values pick this route, for
	// the APIs where asking for more changes the shape of what comes back.
	//
	// Clover is the reason it exists. An order carries no line items unless
	// the request asks for them with ?expand=lineItems, so the same path
	// answers two different shapes and the compact one looks like an order
	// with nothing in it. Asana does the same with opt_fields, and its Recipe
	// says in as many words that Cauldron could not express it.
	//
	// A declared value matches when it appears among the comma-separated
	// members of the parameter, because that is what both providers send:
	// ?expand=lineItems,payments asks for two things and a route selecting
	// either one should answer. Equality is the single-member case of the
	// same rule.
	//
	// It is the third spelling of one idea -- selects reads the body,
	// matches_header reads a header, this reads the query string -- and a
	// route declaring any of them beats an equally-scoring route that
	// declares nothing, so a Recipe can have a compact fallback and an
	// expanded route above it.
	MatchesQuery map[string]string `yaml:"matches_query"`
	// List overrides the Recipe-wide list envelope for this route.
	//
	// A provider's listings do not always share a shape. Clerk's users and
	// sessions answer with bare arrays and its organisations with
	// {data, total_count}; Algolia's browse carries a cursor its search does
	// not have. A Recipe-wide envelope makes one of those wrong, and the
	// wrongness is the expensive kind: code written against the emulator
	// reads response.data.map(...) and receives an array from the provider,
	// where .data is undefined.
	//
	// Only the fields set here are overridden; the rest are inherited. A
	// string field set to "-" is cleared rather than inherited, which is how
	// a route says the provider sends nothing there.
	List *ListResponse `yaml:"list"`
	// Envelope overrides how this route wraps a single object, for the
	// providers that do not wrap every resource the same way.
	//
	// responses.resource is one setting for a whole Recipe, and two providers
	// have already needed it to be two. Datadog wraps a created event under
	// "event" with a status beside it and wraps nothing around a created
	// monitor. Vercel wraps a single domain under "domain" and wraps neither a
	// project nor a deployment. Both were written down as gaps rather than
	// modelled, because saying it for one resource said it for all of them.
	//
	// Empty inherits the Recipe's, and "-" clears it, the same way a route's
	// list override works.
	Envelope *ResourceResponse `yaml:"envelope"`
	// Returns limits the response to the named fields, for the routes that
	// answer with less than the record they touched.
	//
	// Jira's create hands back an id, a key and a URL and none of them is the
	// issue, so anything reading created.fields.summary gets undefined and a
	// suite asserting on the create response is asserting on almost nothing.
	// Plenty of APIs do this and it is always the same surprise, because the
	// convention everywhere else is that a create echoes what you sent.
	//
	// Echoing the whole record would be the helpful kind of wrong: the caller
	// would read fields back that the provider never sends, locally, for as
	// long as the test suite is the only thing calling it.
	Returns []string `yaml:"returns"`
	// DeletedBody says what a delete answers with, for the providers that
	// answer with something.
	//
	// Empty means no body and a 204, which is what most providers do and what
	// this used to do for none of them. Every delete fabricated Stripe's
	// receipt — an id, an object discriminator and deleted: true — using keys
	// no Recipe declares, on 31 of 35 routes whose providers send nothing at
	// all. So await response.json() succeeded locally and threw
	// SyntaxError: Unexpected end of JSON input in production, and code
	// branching on response.deleted === true was reading undefined against the
	// real API.
	//
	// "receipt" is that Stripe shape, for the providers it is actually true
	// of. "record" answers with the deleted object, for the providers that
	// hand it back.
	//
	// Three more, each written because a Recipe was left wrong without them
	// and the note saying so is still in its file:
	//
	// "flagged" is the receipt without Stripe's discriminator, so a provider
	// that calls it something else can supply the name as a constant.
	// Intercom sends {type: contact, id, deleted: true}, and only the object
	// key was ever Stripe's.
	//
	// "id" is the identifier alone. Cloudflare answers {"result": {"id": ...}}
	// once its envelope is on.
	//
	// "empty" is an object with nothing in it. Asana answers {"data": {}},
	// which is not the same as no body at all: a client calling .json()
	// succeeds against one and throws on the other, which is exactly the kind
	// of difference this format exists to record.
	DeletedBody string `yaml:"deleted_body"`
	// DeletedKey names the key the identifier arrives under, for the "id"
	// body. Datadog answers a monitor delete with deleted_monitor_id rather
	// than id, so code reading response.deleted_monitor_id finds nothing
	// unless the key can be said.
	DeletedKey string `yaml:"deleted_key"`
	// IDAs renames the identifier on this route alone, for the providers that
	// call it one thing here and another everywhere else.
	//
	// Documenso answers a document create with documentId and sends id on
	// every other route. Modelling that as an ordinary create answered with a
	// document, which taught a one-step flow that does not exist -- so the
	// route was removed and the gap written down until this existed.
	IDAs string `yaml:"id_as"`
	// Error names a failure from the Recipe's own table that this route always
	// answers with, whatever the request. It is how a retired endpoint is
	// described.
	//
	// Jira's old search path answers 410 Gone to the thousands of integrations
	// still calling it, and 410 rather than 404 is the entire message: the
	// path was right, the endpoint is gone, and retrying will not help. An
	// emulator that let the path fall through to its unknown-route handler
	// would answer 404, and a client branching on the difference would take
	// the wrong branch locally and the right one in production, which is the
	// hardest kind of disagreement to notice.
	//
	// A route declaring one needs no resource and no operation, because it
	// never reaches either.
	Error string `yaml:"error"`
}

Route binds an HTTP method and path to an operation on a resource.

type Signing

type Signing struct {
	// Scheme is one of: hmac-sha256, none.
	Scheme string `yaml:"scheme"`
	Header string `yaml:"header"`
	Secret string `yaml:"secret"`
	// Over, Encoding and Value are how a signature is built: what string the
	// digest is taken over, how the digest is written down, and how it is
	// wrapped before it goes in the header.
	//
	// This was an enum of named shapes and outgrew it. Nine providers here
	// vary along three axes -- the separator between the timestamp and the
	// body, hex or base64, and what prefix the value carries -- and naming
	// each combination gives a list where most entries have exactly one user.
	// Two templates and one word say all nine, and would say the tenth.
	//
	//   Over     {body} {timestamp} {id}, e.g. "v0:{timestamp}:{body}"
	//   Encoding hex or base64
	//   Value    {digest} {timestamp} {id}, e.g. "sha256={digest}"
	//
	// Empty means Stripe's, which is what every Recipe sent before any of
	// this existed: the digest over "{timestamp}.{body}", in hex, written as
	// "t={timestamp},v1={digest}". A Recipe nobody has looked at keeps the
	// shape it had rather than changing under it.
	//
	// It matters more than an envelope does because nobody parses a signature
	// by hand. An application passes the header to the provider's own SDK,
	// and a signature in the wrong shape fails that check every time -- a
	// fake handing an application a signature its verifier rejects is worse
	// than one sending no signature at all.
	Over     string `yaml:"over"`
	Encoding string `yaml:"encoding"`
	Value    string `yaml:"value"`
	// TimestampHeader is the header the signed timestamp travels in, for the
	// providers whose signature covers a timestamp the value does not carry.
	//
	// Slack signs "v0:<ts>:<body>" and sends the timestamp in
	// X-Slack-Request-Timestamp; Zoom does the same in
	// x-zm-request-timestamp. Without it a verifier has the signature and no
	// way to reconstruct what was signed, so the delivery carries a value
	// that cannot be checked -- which is the failure the whole signing
	// surface exists to avoid, arrived at from a different direction.
	//
	// Stripe needs none of this because its timestamp is inside the value.
	TimestampHeader string `yaml:"timestamp_header"`
}

Signing describes webhook payload signing.

type SuccessResponse

type SuccessResponse struct {
	Fields map[string]any `yaml:"fields"`
}

SuccessResponse describes what a provider adds to every successful body.

Slack stamps {"ok": true} on everything and its clients check it before looking at anything else. A fake that omits it fails at the first line of every handler written against the real API.

type Summary

type Summary struct {
	Name       string
	Capability string
	Version    string
	API        string
	Resources  int
	Routes     int
	Events     int
}

Summary is the condensed view `cauldron recipe list` prints.

func Summarise

func Summarise() ([]Summary, error)

Summarise loads every bundled Recipe and reduces it to a listing row.

type Upstream

type Upstream struct {
	API  string `yaml:"api"`
	Docs string `yaml:"docs"`
}

Upstream records which real API version this Recipe targets. Without it, a Recipe silently rots as the provider moves on.

type ValidationError

type ValidationError struct {
	Problems []string
}

ValidationError collects every problem with a Recipe, so an author sees all of them at once instead of fixing one and rerunning.

func (*ValidationError) Error

func (e *ValidationError) Error() string

type WebhookExpectation

type WebhookExpectation struct {
	// Event is the type the delivery must carry.
	Event string `yaml:"event"`
	// Body asserts dotted paths in the payload, envelope included, so a
	// Recipe declaring its own envelope can pin that too.
	Body map[string]any `yaml:"body"`
	// Matches asserts regular expressions against payload paths.
	Matches map[string]string `yaml:"matches"`
	// Absent names paths the payload must not carry. This is the half that
	// catches an internal field name leaking into a payload.
	Absent []string `yaml:"absent"`
	// None claims the request emitted nothing at all, which is worth being
	// able to say: an event that fires when it should not is as wrong as one
	// that does not fire.
	None bool `yaml:"none"`
	// AbsentEvents names events the request must not emit, for when it does
	// emit something.
	//
	// None says a request emitted nothing; this says it emitted the right
	// thing and not the wrong one, and without it the whole point of
	// emits_when is unassertable. The claim a conditional emission makes is
	// that editing a ticket's subject leaves ticket_status_change unsent, and
	// a case naming only the event it wants passes just as happily when both
	// arrive.
	AbsentEvents []string `yaml:"absent_events"`
	// Signature is a pattern the signature value must match.
	//
	// The name of the header was assertable and the value was not, which left
	// the more consequential half unchecked: an application does not read a
	// signature, it hands the header to the provider's SDK, and a value in
	// the wrong shape fails there rather than anywhere a Recipe could see.
	//
	// A pattern rather than a value, because the digest depends on the body
	// and the body carries generated identifiers. The shape is the claim --
	// sha256= and hex, or bare base64, or Stripe's t= and v1= pair.
	Signature string `yaml:"signature"`
	// HeaderMatches asserts patterns against the delivery's other headers.
	//
	// A pattern rather than a value because what travels beside a signature
	// is usually the timestamp it was taken over, which moves.
	HeaderMatches map[string]string `yaml:"header_matches"`
	// SignatureHeader is the header a case claims the signature travels in.
	//
	// The name a handler reads before it can verify anything, and until this
	// existed no case could assert it: seventy-four Recipes name one and the
	// name was only applied when something was listening, which a conformance
	// case never is.
	SignatureHeader string `yaml:"signature_header"`
}

WebhookExpectation is what a case claims about the webhook its request emitted.

Naming an event picks that delivery out of the ones the request caused, rather than examining the last. A request can emit more than one now that a route may carry emits_when beside emits, and "the last" would silently mean a different event depending on the order the runtime sent them.

type Webhooks

type Webhooks struct {
	Events  []string `yaml:"events"`
	Signing Signing  `yaml:"signing"`
	// Payload is the envelope the provider wraps the changed record in.
	//
	// Empty keeps Cauldron's default, which is Stripe's shape: an id, a type,
	// a created timestamp and the record under data.object. That default was
	// fine while Stripe was the only Recipe and became a quiet lie as the
	// collection grew, because almost nobody else uses it. Adyen wraps every
	// notification in an array of NotificationRequestItem and reports success
	// as the string "true", so a truth test on it is also true for "false" —
	// which is exactly the sort of thing this project exists to reproduce, and
	// could not be expressed at all until a Recipe could describe its own
	// envelope.
	//
	// Four placeholders are substituted anywhere inside the template:
	//
	//   {event}       the event name
	//   {id}          the delivery identifier
	//   {created}     the sandbox clock as a Unix timestamp, as a number
	//   {created_iso} the same instant as RFC 3339 text
	//
	// A string value of exactly "{object}" is replaced by the record. A map
	// *key* of "{object}" merges the record's fields into that map instead,
	// which is what Adyen needs: its notification item carries the payment's
	// fields alongside eventCode and success rather than under them.
	// any rather than a map, because a payload is not always an object.
	// SendGrid's Event Webhook posts an array and batches several events into
	// one delivery, HubSpot does the same, and QuickBooks sends an array of
	// change notifications -- three providers whose defining behaviour could
	// not be described while this was a map, and all three would have arrived
	// as a single object under Stripe's envelope.
	Payload any `yaml:"payload"`
}

Webhooks describes what the provider sends back, and how it signs it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL