Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ a binder that drafts a call or a loader that merges `allOf` is prose.
| **dependsOn** | The observation kind claiming a field is settable only when a second field is present, whatever that second field's value. The attribute is the dependent field, the value is the name of the field it requires. Extension key `x-tfpfgen-depends-on`. Learned from a `requires` adjustment the API forced and the retry accepted. |
| **mutuallyExclusive** | The observation kind claiming at most one of a set of fields may be set. Entity-level (empty attribute); the value is the sorted list of the mutually-exclusive field names. Extension key `x-tfpfgen-mutually-exclusive`. Learned when each field is accepted alone but the pair is refused together. |
| **backoff** | How the audit answers a rate-limit refusal (HTTP 429): it waits, retries, and permanently slows the rest of the run down. Three parts — jitter on every request so a run's traffic does not march in lock-step into the server's metering window; retry with exponential backoff and full jitter, honouring `Retry-After` when the server sends one; and a halving of the token bucket's rate once refusals recur, never a raising of it. Lives in `internal/audit/run/backoff.go`; the token bucket it slows stays in `ratelimit.go`. Bounds are fixed constants, not configuration — operators size load through `audit.rate_limit_rps`. Reported on the run summary as `rateLimited`, `slowdowns` and `rateLimitRps`, because findings gathered while an API was refusing traffic are thinner than the same findings off a quiet one. |
| **identifierProperty** | The observation kind naming the response property that carries the value an entity's item path addresses the object by. Entity-level (empty attribute); the value is the property name. Extension key `x-tfpfgen-identifier-property`, compiled onto the read operation; derivation gives the id attribute that wire name in place of the path parameter's. Learned by matching the id the run already extracted against the response body's own properties, never by naming rules — a path that says `{id}` and a body that says `aid` name one identifier and the document says so nowhere. Asserted only where the two disagree. |
| **listResponseShape** | The observation kind recording a collection response's structure: a wrapped envelope (with its key) versus a bare array, plus the pagination style (`cursor`, `offset`, `page`, `none`). Entity-level; read from the live response body, never from the document. Extension key `x-tfpfgen-list-response-shape`, compiled onto the entity's list operation; derivation reads it in preference to the list response schema, which is exactly what the observation exists to contradict. |

## Fixed spellings
Expand All @@ -62,7 +63,8 @@ a binder that drafts a call or a loader that merges `allOf` is prose.
`x-tfpfgen-update-style: patch-merge | put-full | replace-only`;
`x-tfpfgen-list-response-shape: {envelope: wrapped | bare, key: <wrapping
key, wrapped only>, pagination: cursor | offset | page | none}` — an
omitted `pagination` reads as `none`.
omitted `pagination` reads as `none`;
`x-tfpfgen-identifier-property: <property name>`, on a read operation.
- Shared workflows, stage-numbered in pipeline order:
`10-generate.yml`, `20-corrections.yml`, `30-ci.yml`,
`40-acceptance.yml`, `50-docs.yml`, `60-release.yml`.
Expand Down
6 changes: 6 additions & 0 deletions internal/audit/infer/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ type Evidence struct {
// the list-response-shape finding. Only the structure is read; no value
// is stored on the observation.
ListBodies [][]byte
// IdentifierProperty is the response property the run found carrying the
// object's id, empty when no object was created or none matched. It is a
// fact about the response, not the document, which is the point: a path
// parameter and the body property naming the same identifier need not
// share a spelling.
IdentifierProperty string
// RejectsUnknownFields is the summary's caution flag for this entity:
// when the API rejects unknown body fields, a removal-based signal is
// weaker, because a refusal might have been about the unknown field.
Expand Down
18 changes: 18 additions & 0 deletions internal/audit/infer/infer.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func Infer(ev Evidence, compiled *strategy.Strategy) []observe.Observation {
out = append(out, o)
}
out = append(out, m.listShape()...)
out = append(out, m.identifierProperty()...)
out = append(out, m.hypothesisGaps(confirmed)...)

return dedup(out)
Expand Down Expand Up @@ -353,6 +354,23 @@ func (m *model) listShape() []observe.Observation {
}
}

// identifierProperty names the response property carrying the id the item
// path addresses the object by, when the run found one that is not the plain
// "id" the derivation already assumes.
//
// Only the disagreeing case is asserted: an entity whose response spells it
// "id" needs no correction, and emitting one would state what the document
// already says.
func (m *model) identifierProperty() []observe.Observation {
property := m.ev.IdentifierProperty
if property == "" || property == "id" {
return nil
}
return []observe.Observation{
m.edgeAttr("", observe.KindIdentifierProperty, property, nil, observe.ProvenanceDerived, observe.OutcomeConfirmed),
}
}

// hypothesisGaps turns every strategy hypothesis the evidence did not confirm
// into an inconclusive observation. This is the false-edge guard: a hypothesis
// the run could not corroborate is recorded as tested-and-unconfirmed, so it
Expand Down
15 changes: 14 additions & 1 deletion internal/audit/observe/observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ const (
// executor captured, not from the document, because every API spells
// its envelope differently and the document often lies about it.
KindListResponseShape Kind = "listResponseShape"

// KindIdentifierProperty: the response property that carries the value
// the item path addresses the object by. Entity-level (empty Attribute);
// the Value is the property name. Learned by matching the id the run
// already extracted against the response body's own properties, because
// a path parameter named "id" and a body property named "aid" are the
// same identifier and nothing in the document says so.
KindIdentifierProperty Kind = "identifierProperty"
)

// knownKinds is the closed set, for validation.
Expand All @@ -209,7 +217,7 @@ var knownKinds = map[Kind]bool{
KindUndocumentedFieldInSpec: true,
KindValidConfiguration: true, KindValidWhen: true,
KindDependsOn: true, KindMutuallyExclusive: true,
KindListResponseShape: true,
KindListResponseShape: true, KindIdentifierProperty: true,
}

// Provenance records how strongly an inferred edge is grounded: a structural
Expand Down Expand Up @@ -477,6 +485,11 @@ func valueShape(kind Kind, v any) error {
}
case KindListResponseShape:
return listShape(v)
case KindIdentifierProperty:
s, ok := v.(string)
if !ok || s == "" {
return fmt.Errorf("value must be the name of the identifying property, got %v", v)
}
}
return nil
}
Expand Down
33 changes: 33 additions & 0 deletions internal/audit/run/adjust_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,36 @@ func TestUnit_Adjust_ParentRecreationHealsWithoutRecording(t *testing.T) {
t.Errorf("a silent heal recorded %d adjustment(s)", len(r.adjustments))
}
}

// TestUnit_Evidence_TheIdentifyingPropertyIsFoundByValue pins how an entity
// whose response spells its id differently from its path is identified: by
// matching the id the run already learned against the body, never by name.
func TestUnit_Evidence_TheIdentifyingPropertyIsFoundByValue(t *testing.T) {
t.Parallel()
cases := []struct {
name string
got map[string]any
id string
want string
}{
{"a plain id key wins outright",
map[string]any{"id": "7", "aid": "9"}, "9", "id"},
{"the property carrying the learned id",
map[string]any{"aid": "281474976717041", "accountGroupName": "x"}, "281474976717041", "aid"},
{"a number renders without a decimal point",
map[string]any{"roleId": float64(42)}, "42", "roleId"},
{"sorted, so one response names one property",
map[string]any{"bid": "5", "aid": "5"}, "5", "aid"},
{"no id learned names nothing",
map[string]any{"aid": "9"}, "", ""},
{"no property carries it",
map[string]any{"name": "x"}, "9", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := identifyingProperty(tc.got, tc.id); got != tc.want {
t.Errorf("identifyingProperty = %q, want %q", got, tc.want)
}
})
}
}
13 changes: 7 additions & 6 deletions internal/audit/run/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@ func (r *runner) runEntity(ctx context.Context, ep *plan.EntityPlan) {

r.finalizeEvidence(ent)
r.evidence[ep.Entity] = &infer.Evidence{
Entity: ep.Entity,
AcceptedBodies: ent.ev.acceptedBodies,
ListBodies: ent.ev.listBodies,
CombinedRefusals: ent.ev.combinedRefusals,
ConditionalValues: ent.ev.conditionalValues,
Entity: ep.Entity,
AcceptedBodies: ent.ev.acceptedBodies,
ListBodies: ent.ev.listBodies,
CombinedRefusals: ent.ev.combinedRefusals,
ConditionalValues: ent.ev.conditionalValues,
IdentifierProperty: ent.ev.idField,
}
r.summary.Entities = append(r.summary.Entities, EntityResult{
Entity: ep.Entity, Status: ent.status, Reason: ent.reason,
Expand Down Expand Up @@ -300,7 +301,7 @@ func (r *runner) createObject(ctx context.Context, ent *entityState, rec *entity
id := learnID(rec.entity, res)
r.ledger.resolve(seq, activityCreated, id, res.status)
r.summary.ObjectsCreated++
r.observeOmittedSamples(ent, resolved, res.object())
r.observeOmittedSamples(ent, resolved, res.object(), id)
if id == "" {
// The object exists but its id could not be learned from any known
// response shape. The prefix pass still deletes it, but nothing can
Expand Down
35 changes: 31 additions & 4 deletions internal/audit/run/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,12 @@ func jsonTypeOf(v any) string {

// observeOmittedSamples records what a create's response answered for
// every field its body did not send.
func (r *runner) observeOmittedSamples(ent *entityState, sent, got map[string]any) {
func (r *runner) observeOmittedSamples(ent *entityState, sent, got map[string]any, id string) {
if ent == nil || got == nil {
return
}
if ent.ev.idField == "" {
if v, ok := got["id"]; ok && v != nil {
ent.ev.idField = "id"
}
ent.ev.idField = identifyingProperty(got, id)
}
for k, v := range got {
if _, wasSent := sent[k]; wasSent || v == nil {
Expand All @@ -166,6 +164,35 @@ func (r *runner) observeOmittedSamples(ent *entityState, sent, got map[string]an
}
}

// identifyingProperty names the response property carrying the object's id:
// the plain "id" key when the response has one, otherwise the property whose
// value is the id the run already learned. Empty when neither answers.
//
// The run learns an id from a Location header or a self link as readily as
// from a body key, so an API whose path says {id} and whose body says "aid"
// is identified without either name having to match the other.
func identifyingProperty(got map[string]any, id string) string {
if v, ok := got["id"]; ok && v != nil {
return "id"
}
if id == "" {
return ""
}
// Sorted, so one response always names the same property when two carry
// the same value.
keys := make([]string, 0, len(got))
for k := range got {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if scalarString(got[k]) == id {
return k
}
}
return ""
}

// valuesFor is the per-attribute accumulator.
func (ev *evidence) valuesFor(attr string) *observe.Values {
v, ok := ev.values[attr]
Expand Down
76 changes: 76 additions & 0 deletions internal/intermediate_representation/constraints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,79 @@ func TestUnit_Attribute_CarriesTheDeclaredExample(t *testing.T) {
t.Errorf("a property declaring no example carries %#v", got)
}
}

// identifierPropertySpec addresses its item by {id} while its response spells
// the same identifier "aid" — the shape the extension exists to reconcile.
const identifierPropertySpec = `openapi: 3.0.3
info: {title: T, version: "1"}
paths:
/groups:
post:
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/Group'}
responses:
"201":
content:
application/json:
schema: {$ref: '#/components/schemas/Group'}
/groups/{id}:
get:
x-tfpfgen-identifier-property: aid
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
responses:
"200":
content:
application/json:
schema: {$ref: '#/components/schemas/Group'}
patch:
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/Group'}
responses:
"200":
content:
application/json:
schema: {$ref: '#/components/schemas/Group'}
delete:
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
responses:
"204": {description: gone}
components:
schemas:
Group:
type: object
properties:
aid: {type: string}
groupName: {type: string}
`

// TestUnit_Attribute_TheIdentifierPropertyNamesTheIdsWire proves the id
// attribute reads through the property the response actually carries. Without
// it the id binds to an accessor no model has, the binding is pruned as
// addressing, and the settling read addresses the object by an empty string.
func TestUnit_Attribute_TheIdentifierPropertyNamesTheIdsWire(t *testing.T) {
r := resourceByKey(t, mustDerive(t, identifierPropertySpec, testConfig()), "group")

if id := attribute(t, r.Schema, "id"); id.WireName != "aid" {
t.Errorf("the id attribute reads %q, want the property the response carries", id.WireName)
}
}

// TestUnit_Attribute_TheIdWireFallsBackToThePathParameter proves the same
// document without the extension still takes the path parameter's name, so
// the extension corrects rather than replaces the derivation.
func TestUnit_Attribute_TheIdWireFallsBackToThePathParameter(t *testing.T) {
plain := strings.Replace(identifierPropertySpec, " x-tfpfgen-identifier-property: aid\n", "", 1)
r := resourceByKey(t, mustDerive(t, plain, testConfig()), "group")

if id := attribute(t, r.Schema, "id"); id.WireName != "id" {
t.Errorf("the id attribute reads %q, want the path parameter's name", id.WireName)
}
}
8 changes: 8 additions & 0 deletions internal/intermediate_representation/derive.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,14 @@ func (derivation *deriver) resource(classification specmodel.Classification, nam
}
tree := buildTree(createBody, readBody, updateBody, classification.MissingUpdate)
keyParam, keyType := itemKeyParam(classification.ItemPath, readFull)
// The audit may have found that the response spells this identifier
// differently from the path parameter that addresses it. Where it has,
// that name is the one the response can actually be read through.
if readFull != nil {
if named, ok := readFull.Extensions.IdentifierProperty(); ok {
keyParam = named
}
}
ensureID(tree, keyParam, keyType)
refuseReservedRootNames(tree)
readOperation := derivation.operation(classification.Read, OperationRead)
Expand Down
42 changes: 42 additions & 0 deletions internal/spec/revise/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ func (c *compiler) compile(o observe.Observation) (compiled, error) {
return c.validConfiguration(loc, cls, o), nil
case observe.KindListResponseShape:
return c.listResponseShape(loc, cls, o)
case observe.KindIdentifierProperty:
return c.identifierProperty(loc, cls, o)
default:
// observe.Read validates kinds against the closed set, so reaching
// here means the sets have drifted — the failure mode an evidence
Expand Down Expand Up @@ -615,6 +617,46 @@ func (c *compiler) listResponseShape(loc *locator, cls specmodel.Classification,
}, nil
}

// identifierProperty annotates the read operation with the response property
// carrying the value its item path addresses the object by.
//
// It goes on the read because that is the operation whose path parameter the
// property answers: derivation reads the two together, and the correspondence
// exists nowhere else in the document.
func (c *compiler) identifierProperty(loc *locator, cls specmodel.Classification, o observe.Observation) (compiled, error) {
property, ok := o.Value.(string)
if !ok || property == "" {
return compiled{}, fmt.Errorf("observation %s: its value is not a property name", o.ID)
}
if cls.Read == nil {
return unplaceable(fmt.Sprintf("entity %s has no read operation to annotate", o.Entity)), nil
}
// The property must exist on what the read answers, or the correction
// would name a field nothing can read.
node, ptr, ok := loc.responseSchema(cls.Read)
if !ok {
return unplaceable(fmt.Sprintf("entity %s has no read response schema to check %q against",
o.Entity, property)), nil
}
if _, found := loc.findProperty(node, ptr, property); !found {
return unplaceable(fmt.Sprintf("the read response of %s declares no %q to identify it by",
o.Entity, property)), nil
}

opPtr := opPointer(cls.Read)
if ext := mapValue(loc.nodeAt(opPtr), specmodel.ExtIdentifierProperty); ext != nil && ext.Value == property {
return stated("the document already names this identifying property"), nil
}
return compiled{
ops: []correction.Operation{{
Op: "add", Path: opPtr + "/" + specmodel.ExtIdentifierProperty, Value: property,
}},
justification: fmt.Sprintf("the audit confirmed an identifierProperty observation on %s: "+
"the live response carries its id as %q, which its item path does not name (%s)",
o.Entity, property, specmodel.ExtIdentifierProperty),
}, nil
}

// shapeStated reports whether an existing x-tfpfgen-list-response-shape node
// already says exactly what the compiled value would say — key for key, so a
// re-audit of an unchanged API proposes nothing.
Expand Down
Loading