Skip to content
Open
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
3 changes: 0 additions & 3 deletions compilers/openapi/conformance_matrix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,6 @@ func TestConformance_MatrixRowNamesResolve(t *testing.T) {
// that reads as closable and is not.
func matrixRowsUncovered() map[string]string {
return map[string]string{
"open-enums": "OpenAPI has no open-enum keyword; the matrix's ⚠ is the " +
"anyOf: [{enum: [...]}, {type: string}] idiom, which lowers as an ordinary union " +
"and needs a spec pinning that the enum branch survives beside the open one",
"pagination": "OpenAPI states it only through links and x-*, and this compiler keeps both " +
"verbatim rather than reading either into ir.Pagination — response-links pins that they " +
"survive. Invariant 6 puts the inference in a pass rather than in the compiler, so this " +
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func conformanceCases() []conformanceCase {
{"path-item-docs", assertPathItemDocs, []string{"docs-summary-description"}},
{"path-item-operations", assertPathItemOperations, []string{"http-binding"}},
{"deprecation", assertDeprecation, []string{"deprecation"}},
{"extension-promotion", assertExtensionPromotion, []string{"deprecation"}},
{"extension-promotion", assertExtensionPromotion, []string{"deprecation", "open-enums"}},
{"examples", assertExamples, []string{"examples"}},
{"docs-summary-desc", assertDocsSummaryDesc, []string{"docs-summary-description"}},
{"extensions-x", assertExtensionsX, []string{"vendor-extensions"}},
Expand Down
93 changes: 89 additions & 4 deletions compilers/openapi/internal/lowering/promotion.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,28 @@ const (
TargetDeprecationMessage ExtensionTarget = "deprecation.message"
// TargetDeprecationSince fills ir.Deprecation.Since.
TargetDeprecationSince ExtensionTarget = "deprecation.since"
// TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion.
// TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion. No
// default key names it: the one convention in wide use for a scheduled
// removal, x-sunset, states a date, and a document that spells a removal
// *version* names its own key.
TargetDeprecationRemovalVersion ExtensionTarget = "deprecation.removalVersion"
// TargetDeprecationRemovalDate fills ir.Deprecation.RemovalDate.
TargetDeprecationRemovalDate ExtensionTarget = "deprecation.removalDate"

// TargetEnumOpen clears ir.Enum.Closed, saying the member set admits values
// the document does not list.
//
// It names the fact rather than the field, which the rest of this vocabulary
// does not, because openness is the only half of that bool a document ever
// declares: a schema's `enum` is closed by definition, so a target named for
// Closed could only ever be written false and would read as its own opposite
// at every mapping that names it.
//
// The key's presence is the statement. The established spelling,
// x-extensible-enum, writes the member list as its value, so there is no flag
// to read there — but a boolean value *is* a statement about openness, and an
// explicit `false` is honoured rather than inverted (extensionOpenness).
TargetEnumOpen ExtensionTarget = "enum.open"
)

// ExtensionPromotions is the vendor-extension promotion policy: which x-* keys
Expand Down Expand Up @@ -71,7 +91,12 @@ func DefaultExtensionPromotions() map[string]ExtensionTarget {
return map[string]ExtensionTarget{
"x-deprecated-reason": TargetDeprecationMessage,
"x-deprecated-since": TargetDeprecationSince,
"x-sunset": TargetDeprecationRemovalVersion,
// x-sunset echoes the RFC 8594 Sunset header, which is a date by
// definition, so it fills the date field and not the version one.
"x-sunset": TargetDeprecationRemovalDate,
// x-extensible-enum is the convention for an enum a service may add
// members to, so it says the set is open and not that it is closed.
"x-extensible-enum": TargetEnumOpen,
}
}

Expand Down Expand Up @@ -116,6 +141,44 @@ func (c Ctx) PromoteDeprecation(unmodeled ir.Unmodeled, dep *ir.Deprecation, pro
return diags
}

// PromoteEnumOpenness clears e.Closed when the vendor extensions kept in
// unmodeled include a key the policy maps to TargetEnumOpen, and marks prov
// with the heuristic when it does.
//
// It is PromoteDeprecation at a second carrier, with the same three properties:
// the entry it reads stays where it was, the node records that a heuristic
// wrote the field, and a disabled policy writes nothing. What differs is that
// the fact is stated by the key being present rather than by a value, so this
// reports nothing: the deprecation reading declines a value it cannot hold and
// says so, while here every value shape but an explicit `false` is a key that
// means what its name says (TargetEnumOpen, extensionOpenness).
//
// The order the policy's keys are visited in is not fixed, because it cannot
// matter: a key that states openness writes the same field the same value as
// any other, a key that does not is skipped rather than deciding anything, and
// none of them reports. Two keys disagreeing therefore read the same either
// way round — open, because one of them said so.
//
// Deliberately out of scope: a document that writes x-extensible-enum *instead*
// of `enum`, listing the members in the extension, lowers to no ir.Enum at all,
// so there is no node here to open. Reading a member list out of an extension
// would be minting an enum from a vendor key rather than promoting a field, and
// the entry survives verbatim for a consumer that wants to (GitHub #427).
func (c Ctx) PromoteEnumOpenness(unmodeled ir.Unmodeled, e *ir.Enum, prov *ir.Provenance) {
if e == nil || prov == nil || len(unmodeled) == 0 || len(c.promotions) == 0 {
return
}
for key, target := range c.promotions {
entry, declared := unmodeled[extensionKeyPrefix+key]
if target != TargetEnumOpen || !declared || !extensionOpenness(entry.Value) {
continue
}
e.Closed = false
markInferred(prov, ExtensionPromotionHeuristic)
return
}
}

// deprecationField returns the field target names on dep, or nil when target
// names something that is not a deprecation field. A policy may map a key to
// any target in the vocabulary, and most carriers answer for only some of it.
Expand All @@ -127,14 +190,19 @@ func deprecationField(dep *ir.Deprecation, target ExtensionTarget) *string {
return &dep.Since
case TargetDeprecationRemovalVersion:
return &dep.RemovalVersion
case TargetDeprecationRemovalDate:
return &dep.RemovalDate
default:
return nil
}
}

// extensionText reads a preserved extension value as a string. Every
// Deprecation field is prose or a version, so a value of any other JSON shape
// is a document meaning something else by the key.
// Deprecation field is prose, a version or a date, so a value of any other JSON
// shape is a document meaning something else by the key. Text of the right JSON
// shape is taken as written — a date is not parsed here, because the mapping is
// the caller's policy and a key it points at the date field is its statement
// that the key holds one.
func extensionText(raw ir.RawValue) (string, bool) {
var text string
if err := json.Unmarshal(raw, &text); err != nil {
Expand All @@ -143,6 +211,23 @@ func extensionText(raw ir.RawValue) (string, bool) {
return text, true
}

// extensionOpenness reads a preserved extension value as a statement that an
// enum's member set is open.
//
// The key's presence is the statement, so a value of any shape but a boolean
// reads as open: x-extensible-enum's established spelling writes the *members*
// as its value, and a list of members says nothing about openness that the key
// naming it has not already said. A boolean is the one shape that does state
// openness on its own, so an explicit false is read as written — a document
// saying the set is not extensible, which is not something to invert.
func extensionOpenness(raw ir.RawValue) bool {
var open bool
if err := json.Unmarshal(raw, &open); err != nil {
return true
}
return open
}

// markInferred adds one heuristic's name to a provenance, keeping any already
// there. Provenance.Inferred holds a single string and more than one heuristic
// can reach a node — an operation grouped by path prefix whose deprecation
Expand Down
151 changes: 141 additions & 10 deletions compilers/openapi/internal/lowering/promotion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ func vendorExtension(rawJSON string) ir.UnmodeledEntry {
}

// TestPromoteDeprecation_FillsTheFieldsThePolicyNames pins what each mapping
// writes, one field at a time, because the three share a struct and a promotion
// writing the wrong member of it would still look filled.
// writes, one field at a time, because they share a struct and a promotion
// writing the wrong member of it would still look filled. The removal pair is
// why that matters most: a date written into the version field is the defect
// GitHub #417 records, and it reads as a filled Deprecation either way.
func TestPromoteDeprecation_FillsTheFieldsThePolicyNames(t *testing.T) {
t.Parallel()
tests := []struct {
Expand All @@ -45,6 +47,7 @@ func TestPromoteDeprecation_FillsTheFieldsThePolicyNames(t *testing.T) {
{"message", lowering.TargetDeprecationMessage, ir.Deprecation{Message: "why"}},
{"since", lowering.TargetDeprecationSince, ir.Deprecation{Since: "why"}},
{"removal version", lowering.TargetDeprecationRemovalVersion, ir.Deprecation{RemovalVersion: "why"}},
{"removal date", lowering.TargetDeprecationRemovalDate, ir.Deprecation{RemovalDate: "why"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -264,6 +267,30 @@ func stringLiteral(value ast.Expr, typed bool) (string, bool) {
return lit.Value, true
}

// appliers is one closure per promote function the package exports. Each runs
// c's policy against a carrier of its own — the key "x-k", which every caller
// below maps — and reports whether that carrier changed.
//
// The census is hand-written, so a promote function this list does not know
// about is the same gap one level down: the check beneath it would then declare
// a target unapplied that a real lowering does apply.
func appliers() []func(lowering.Ctx) (bool, []ir.Diagnostic) {
return []func(lowering.Ctx) (bool, []ir.Diagnostic){
func(c lowering.Ctx) (bool, []ir.Diagnostic) {
var dep ir.Deprecation
var prov ir.Provenance
diags := c.PromoteDeprecation(ir.Unmodeled{"openapi:x-k": vendorExtension(`"v"`)}, &dep, &prov)
return dep != (ir.Deprecation{}), diags
},
func(c lowering.Ctx) (bool, []ir.Diagnostic) {
enum := ir.Enum{Closed: true}
var prov ir.Provenance
c.PromoteEnumOpenness(ir.Unmodeled{"openapi:x-k": vendorExtension(`"v"`)}, &enum, &prov)
return !enum.Closed, nil
},
}
}

// TestExtensionTarget_EveryDeclaredTargetHasAnApplier holds the vocabulary to
// the appliers, which is the half of "a target is a constant and an applier"
// that nothing else checks: the constant alone compiles, maps cleanly, and
Expand All @@ -274,21 +301,125 @@ func stringLiteral(value ast.Expr, typed bool) (string, bool) {
// vocabulary entry that fills nothing is the likeliest way this seam breaks.
//
// A target belonging to a family this package cannot yet apply fails here on
// purpose: adding one means adding its applier, and teaching this test which
// applier answers for it, exactly as a new census keyword means adding its arm.
// purpose: adding one means adding its applier, and teaching appliers() which
// promote function answers for it, exactly as a new census keyword means adding
// its arm.
func TestExtensionTarget_EveryDeclaredTargetHasAnApplier(t *testing.T) {
t.Parallel()
for _, target := range declaredTargets(t) {
c := promotionCtx(lowering.ExtensionPromotions{
Targets: map[string]lowering.ExtensionTarget{"x-k": target},
})
var dep ir.Deprecation
var prov ir.Provenance
diags := c.PromoteDeprecation(ir.Unmodeled{"openapi:x-k": vendorExtension(`"v"`)}, &dep, &prov)

assert.Empty(t, diags, "%s: a declared target reports nothing when it is applied", target)
assert.NotEqual(t, ir.Deprecation{}, dep,
var applied bool
for _, apply := range appliers() {
wrote, diags := apply(c)
assert.Empty(t, diags, "%s: a declared target reports nothing when it is applied", target)
applied = applied || wrote
}
assert.True(t, applied,
"%s is declared in the vocabulary but no applier fills it, so a policy naming it "+
"promotes nothing and says nothing", target)
}
}

// TestPromoteEnumOpenness_ClearsClosedAndMarksTheNode pins the promotion the
// vocabulary's one non-Deprecation target performs. Every enum the compiler
// builds is closed, so the write here is the whole of what x-extensible-enum
// buys a consumer, and the marker is what says a heuristic made it.
func TestPromoteEnumOpenness_ClearsClosedAndMarksTheNode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
}{
{"the member list the convention writes", `["a","b"]`},
{"an explicit true", `true`},
{"a value that states nothing", `"whatever"`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
enum := ir.Enum{Closed: true}
var prov ir.Provenance
promotionCtx(lowering.ExtensionPromotions{}).PromoteEnumOpenness(
ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(tc.value)}, &enum, &prov)

assert.False(t, enum.Closed, "the key says the member set is open")
assert.Equal(t, lowering.ExtensionPromotionHeuristic, prov.Inferred)
})
}
}

// TestPromoteEnumOpenness_LeavesTheEntryItRead is the losslessness half, for
// the same reason its deprecation twin is: the promotion is a second reading of
// a preserved entry, so a consumer that disagrees still has what was written —
// which for this key is the member list itself.
func TestPromoteEnumOpenness_LeavesTheEntryItRead(t *testing.T) {
t.Parallel()
unmodeled := ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`["a","b"]`)}
enum := ir.Enum{Closed: true}
promotionCtx(lowering.ExtensionPromotions{}).PromoteEnumOpenness(unmodeled, &enum, &ir.Provenance{})

entry, kept := unmodeled["openapi:x-extensible-enum"]
require.True(t, kept, "the entry survives its own promotion")
assert.Equal(t, ir.ReasonVendorExtension, entry.Reason)
assert.JSONEq(t, `["a","b"]`, string(entry.Value))
}

// TestPromoteEnumOpenness_WritesNothing pins every shape that must leave the
// enum closed. The last row is the one that is not an absence: a document
// writing the key with a boolean false says the set is *not* extensible, and
// reading presence alone there would record the opposite of what it said.
func TestPromoteEnumOpenness_WritesNothing(t *testing.T) {
t.Parallel()
filled := ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`["a","b"]`)}
tests := []struct {
name string
policy lowering.ExtensionPromotions
unmodeled ir.Unmodeled
}{
{"promotion disabled", lowering.ExtensionPromotions{Disabled: true}, filled},
{"no extensions kept", lowering.ExtensionPromotions{}, nil},
{"a key the document did not write", lowering.ExtensionPromotions{}, ir.Unmodeled{
"openapi:x-other": vendorExtension(`["a","b"]`),
}},
{
"a key mapped to another target",
lowering.ExtensionPromotions{Targets: map[string]lowering.ExtensionTarget{
"x-extensible-enum": lowering.TargetDeprecationMessage,
}},
filled,
},
{
"an explicit false",
lowering.ExtensionPromotions{},
ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`false`)},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
enum := ir.Enum{Closed: true}
var prov ir.Provenance
promotionCtx(tc.policy).PromoteEnumOpenness(tc.unmodeled, &enum, &prov)

assert.True(t, enum.Closed, "the enum stays as the format declared it")
assert.Empty(t, prov.Inferred, "nothing was inferred, so nothing is marked")
})
}
}

// TestPromoteEnumOpenness_NonEnumCarrierIsTheWholeAnswer pins the nil cases,
// which are the ordinary shape rather than a guard: most nodes an x-* key can
// sit on are not enums, and a node with no provenance could not record the
// guess (promotion rule 4).
func TestPromoteEnumOpenness_NonEnumCarrierIsTheWholeAnswer(t *testing.T) {
t.Parallel()
c := promotionCtx(lowering.ExtensionPromotions{})
unmodeled := ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`["a","b"]`)}
c.PromoteEnumOpenness(unmodeled, nil, &ir.Provenance{})

enum := ir.Enum{Closed: true}
c.PromoteEnumOpenness(unmodeled, &enum, nil)
assert.True(t, enum.Closed, "with nowhere to record the guess, none is made")
}
7 changes: 7 additions & 0 deletions compilers/openapi/internal/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,13 @@ func attachDeclaredAnnotations(c lowering.Ctx, ts *compile.Types, anchors *Ancho
}
common.Unmodeled = annotation.MergeUnmodeled(common.Unmodeled, a.Unmodeled)
diags = append(diags, c.PromoteDeprecation(common.Unmodeled, common.Deprecation, &common.Provenance)...)
// The enum-openness promotion is applied here rather than where the Enum is
// built, for the same reason the deprecation one is: a promotion reads the
// preserved Unmodeled entries, and this is the point at which the
// declaration's extensions have reached the node's map.
if enum, isEnum := td.(*ir.Enum); isEnum {
c.PromoteEnumOpenness(common.Unmodeled, enum, &common.Provenance)
}
if len(a.Examples) > 0 {
common.Examples = a.Examples
}
Expand Down
4 changes: 4 additions & 0 deletions compilers/openapi/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const (
TargetDeprecationSince = lowering.TargetDeprecationSince
// TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion.
TargetDeprecationRemovalVersion = lowering.TargetDeprecationRemovalVersion
// TargetDeprecationRemovalDate fills ir.Deprecation.RemovalDate.
TargetDeprecationRemovalDate = lowering.TargetDeprecationRemovalDate
// TargetEnumOpen clears ir.Enum.Closed.
TargetEnumOpen = lowering.TargetEnumOpen
)

// DefaultExtensionPromotions returns the extension-to-field mapping applied
Expand Down
Loading
Loading