restcompat

package
v0.23.0 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package restcompat diffs the REST contract two schemas generate, and classifies each delta as breaking, additive, or neutral for a deployed client. It is the engine behind `sqlb impact` (ADR-0039: a schema edit is an API edit, and the break is diffed).

It is a sibling of migrate.Diff, not a consumer of it. migrate.Diff reads the columns and types that produce DDL and ignores capabilities, because capabilities emit no SQL. This reads the capabilities — Filterable, the Op set, exposure — precisely because the sharpest API breaks produce no DDL at all: un-exposing a column, dropping an operation, or a rename that is a clean migration and a wire break at the same time. So the two functions run over the same pair of registries and read different projections of them.

Like migrate.Diff it is a pure function over two *schema.Registry values, with no database and no running server, which is what makes it golden-testable.

Most of the contract is per resource and per column, and one part of it is not: the schema's WireCase spells every field of every resource, so a change to it is a rename of the whole API with no column renamed and no DDL emitted (ADR-0036). It is captured once per snapshot and reported as one break with no resource, which sorts above everything else.

What is deliberately honest here

A change that is compatible for a reader and breaking for a writer — a column going NOT NULL widens the create body's required set while leaving responses untouched — is reported as two separate breaks, one per side, never folded into one. A classifier that reported the reader side and forgot the writer side would be a guard that fires sometimes, which reads as coverage it does not have (ADR-0016). Where a type change cannot be classified confidently in both directions, it is reported LevelUnknown rather than guessed as neutral.

Index

Constants

View Source
const SnapshotVersion = 1

SnapshotVersion is the format version of a captured contract. It is stamped into every Snapshot so a future format change can be recognised rather than misread — the snapshot is a checked-in artefact, and ADR-0039 flags its format as the expensive thing to change once teams have committed one. This format is still experimental and may change before it is frozen.

Variables

This section is empty.

Functions

This section is empty.

Types

type ActionPropSnap deprecated

type ActionPropSnap = BodyPropSnap

ActionPropSnap is the former name of BodyPropSnap.

Deprecated: use BodyPropSnap.

type ActionSnap

type ActionSnap struct {
	Name string `json:"name"`
	Path string `json:"path"`
	// Body is the request body's properties, in declaration order.
	Body []BodyPropSnap `json:"body,omitempty"`
	// Returns is the declared response body, in declaration order. Absent means
	// the default answer — the row for an item verb, nothing for a collection
	// one — and moving between the two is a change of response type, which is
	// why it is diffed as a whole and not only property by property.
	Returns []BodyPropSnap `json:"returns,omitempty"`
	// Writes names the columns the envelope persists. No client couples to it,
	// so a change here is neutral — but it widens or narrows what one route
	// can mutate, which is exactly the blast-radius question this tool is for.
	Writes []string `json:"writes,omitempty"`
	// Touches names the tables the verb writes through its transaction, as
	// declared. Also neutral, and for a sharper reason than Writes: the
	// declaration is unenforced, so a change here is a change in what the route
	// *claims* — which is the only thing a diff can see, and the thing a
	// reviewer most wants shown when a verb's reach grows.
	Touches []string `json:"touches,omitempty"`
}

ActionSnap is one declared verb's contract.

type BodyPropSnap

type BodyPropSnap struct {
	Name       string   `json:"name"`
	Type       string   `json:"type"`
	Enum       []string `json:"enum,omitempty"`
	Nullable   bool     `json:"nullable,omitempty"`
	HasDefault bool     `json:"has_default,omitempty"`
	// The declared format rules, recorded for the same reason the enum set is:
	// they say what a request may carry, so tightening one rejects input that
	// worked (#311).
	Pattern string   `json:"pattern,omitempty"`
	Min     *float64 `json:"min,omitempty"`
	Max     *float64 `json:"max,omitempty"`
	// MapValue is a map property's value type. Recorded because "map" alone
	// does not say it, so a change from a map of strings to a map of ints
	// would otherwise be a type change the diff could not see.
	MapValue string `json:"map_value,omitempty"`
}

BodyPropSnap is one declared property of a request — an action's body, the non-column half of a create's (#309), or a query's parameters.

It was ActionPropSnap until a create body could declare one too; the alias below keeps a baseline reader compiling, and the JSON is unchanged either way.

The name has since outgrown itself by one caller: a query's parameters are not a body, and they are this type because they are this shape — a name, a type, and the three flags that decide whether a request must carry it — and because the rules that classify a change to one are the rules that classify a change to any of them. Renaming it a second time inside one release would cost two deprecated aliases to buy one accurate word. If a fourth declaration arrives that is also not a body, that is the moment.

type Break

type Break struct {
	Level Level
	// Resource is the collection path, e.g. "/posts". It is empty for a
	// schema-level break, which belongs to no one resource because it belongs
	// to all of them.
	Resource string
	Facet    Facet
	Field    string // column or relation name; empty for resource- and ops-level
	Summary  string // one line, in the allow-list voice: what changed, for whom
}

Break is one classified delta between two generated contracts.

func Breaking

func Breaking(breaks []Break) []Break

Breaking returns only the breaks a strict gate would fail on — the breaking ones and the unknowns, which cannot be shown safe. This is what `--api-compat=error` would count.

func Diff

func Diff(old, new *schema.Registry) []Break

Diff reports how the REST contract changes moving from old to new. The result is deterministic: sorted by resource, then by facet, then by field. An empty result means the contract is byte-for-byte compatible.

This is the convenience form for two registries in hand. `sqlb impact` diffs a registry against a checked-in Snapshot instead — see DiffSnapshots — because "backward compatible relative to what?" needs a committed answer, not the other side of a comparison that only exists at generation time.

func DiffSnapshots

func DiffSnapshots(old, new Snapshot) []Break

DiffSnapshots is Diff over two captured contracts. It is what the CLI runs: the old side is read from a file in the repository, the new side is captured from the current schema.

func (Break) String

func (b Break) String() string

type Facet

type Facet string

Facet names the part of the contract a break sits in. Facets are ordered so that a resource-level break sorts above the field-level breaks under it, and the schema-level one above every resource.

const (
	FacetWire     Facet = "wire"        // how every column is spelled on the wire
	FacetResource Facet = "resource"    // the endpoint set as a whole
	FacetOps      Facet = "ops"         // which operations exist
	FacetResponse Facet = "response"    // the fields a read returns
	FacetFilter   Facet = "filter"      // ?column=op.value parameters
	FacetSort     Facet = "sort"        // ?sort columns
	FacetExpand   Facet = "expand"      // ?expand relations
	FacetCreate   Facet = "create-body" // the POST body
	FacetPatch    Facet = "patch-body"  // the PATCH body
	FacetAction   Facet = "action"      // a declared domain verb and its body
	FacetQuery    Facet = "query"       // a declared read and its parameters
)

type FieldSnap

type FieldSnap struct {
	Name string `json:"name"`
	Rel  string `json:"rel,omitempty"`
	// Type is omitempty because an inverse-relation entry (see the loop in
	// Capture that appends those) has none — it is a relation to another
	// table, not a scalar column — and every real column's Type is always
	// non-empty, so nothing that used to be recorded stops being recorded.
	Type  string   `json:"type,omitempty"`
	Array bool     `json:"array,omitempty"`
	Size  int      `json:"size,omitempty"`
	Enum  []string `json:"enum,omitempty"`
	// Pattern, Min and Max are the declared format rules on the value. They
	// are part of the contract for the same reason the enum set is: they say
	// what a request may carry, and tightening one rejects input that worked.
	Pattern     string   `json:"pattern,omitempty"`
	Min         *float64 `json:"min,omitempty"`
	Max         *float64 `json:"max,omitempty"`
	Nullable    bool     `json:"nullable,omitempty"`
	HasDefault  bool     `json:"has_default,omitempty"`
	Hidden      bool     `json:"hidden,omitempty"`
	WriteOnly   bool     `json:"write_only,omitempty"`
	ReadOnly    bool     `json:"read_only,omitempty"`
	Immutable   bool     `json:"immutable,omitempty"`
	Filterable  bool     `json:"filterable,omitempty"`
	Sortable    bool     `json:"sortable,omitempty"`
	SortNulls   string   `json:"sort_nulls,omitempty"`
	Expandable  bool     `json:"expandable,omitempty"`
	RenamedFrom string   `json:"renamed_from,omitempty"`
	// OneToOne marks a reverse (Inverse) relation whose forward Ref carries a
	// single-column unique constraint, so ?expand resolves it to the target row
	// or null rather than the {items, has_more} collection envelope every other
	// expand relation uses. Meaningless — and always false — on anything but an
	// inverse-relation entry; see the loop in Capture that appends those.
	OneToOne bool `json:"one_to_one,omitempty"`
}

FieldSnap is one column's contract-relevant shape. Storage-only properties — the primary-key flag, the index list, the constraint names — are deliberately absent: they do not change how a client couples to the field.

type Level

type Level int

Level is how a contract delta lands on a client that already exists.

const (
	// LevelNeutral: no client is affected. A response field going from nullable
	// to always-present is neutral for a reader that already handled the type.
	LevelNeutral Level = iota
	// LevelAdditive: a new capability. Nothing that a client sends or reads
	// today changes meaning; a client that ignores the addition is unaffected.
	LevelAdditive
	// LevelBreaking: an existing client can break — a request that worked now
	// fails, or a response field it relied on is gone or changed shape.
	LevelBreaking
	// LevelUnknown: the delta is real but its effect depends on how a specific
	// client generated its types (a widened integer overflows a narrow one), so
	// it is surfaced for review rather than claimed safe. Treat it as breaking
	// under a strict gate.
	LevelUnknown
)

func (Level) String

func (l Level) String() string

type QuerySnap

type QuerySnap struct {
	Name string `json:"name"`
	Path string `json:"path"`
	// Params is the query string's parameters, in declaration order.
	Params []BodyPropSnap `json:"params,omitempty"`
	// Reads names the tables the query declares it reads. See diffQueries for
	// why a change to it is neutral and why the honest summary names the
	// direction.
	Reads []string `json:"reads,omitempty"`
}

QuerySnap is one declared read's contract.

type ResourceSnap

type ResourceSnap struct {
	Path   string      `json:"path"`
	Ops    []string    `json:"ops"` // create, read, update, delete, list
	Fields []FieldSnap `json:"fields"`
	// Actions are the declared domain verbs. A snapshot recorded before they
	// existed has none, which reads correctly: every verb in the new schema is
	// an addition.
	Actions []ActionSnap `json:"actions,omitempty"`
	// CreateInput is the create body's declared properties that are not columns
	// (#309). They are part of the contract for the reason the columns are: a
	// deployed client sends this body, and adding a required property to it
	// fails every request that client already makes.
	//
	// Recorded under its own key rather than as more fields, because it is not
	// a column and nothing else about the field list is true of it — it is not
	// in a response, not filterable, and not something a rename could reach.
	CreateInput []BodyPropSnap `json:"create_input,omitempty"`
	// Queries are the declared reads, and carry the same omitempty Actions
	// does for the same two reasons: a baseline recorded before this field
	// existed stays byte-identical, and its absence reads correctly as "this
	// resource declared none" rather than as "not recorded". The first
	// snapshot taken after this field arrives reports every existing query as
	// an addition, which is the safe direction — an addition is never a gate
	// failure, and a re-record settles it.
	Queries []QuerySnap `json:"queries,omitempty"`
}

ResourceSnap is one exposed table's contract.

type Snapshot

type Snapshot struct {
	Version int `json:"version"`
	// WireCase is the schema's declared wire spelling (ADR-0036), recorded once
	// for the whole snapshot because that is what it is a property of. It is
	// absent when the schema is Verbatim, so a baseline recorded before this
	// field existed is byte-identical to one recorded after it, and every
	// committed restcontract.json stays valid without re-recording.
	//
	// Absent therefore reads as Verbatim rather than as "not recorded". That
	// costs a schema which *already* declared Camel one spurious break the first
	// time it is checked, and a re-record clears it. It is the safe direction:
	// reading absence as unknown would suppress the Verbatim -> Camel finding
	// against exactly the baselines that predate this check, which is the state
	// this field exists to stop being silent.
	WireCase  string         `json:"wire_case,omitempty"`
	Resources []ResourceSnap `json:"resources"`
}

Snapshot is the serialisable REST contract of a schema: one entry per exposed resource, holding exactly the capabilities a client couples to and nothing about storage. It is what `sqlb impact -write` records and what a later run diffs against.

func Capture

func Capture(r *schema.Registry) Snapshot

Capture projects a registry into its serialisable REST contract. It is the same projection Diff uses, exposed so the CLI can record and re-read it. Resources are sorted by path so a re-record produces a minimal file diff.

Jump to

Keyboard shortcuts

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