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
1 change: 1 addition & 0 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ func conformanceCases() []conformanceCase {
{"webhooks", assertWebhooks},
{"callbacks", assertCallbacks},
{"deprecation", assertDeprecation},
{"extension-promotion", assertExtensionPromotion},
{"examples", assertExamples},
{"docs-summary-desc", assertDocsSummaryDesc},
{"extensions-x", assertExtensionsX},
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func newLowerer(doc *load.Document, opts Options) *lowerer {
func newRawLowerer(doc *soa.OpenAPI) *lowerer {
rawTypes := compile.NewTypes(0)
l := &lowerer{
ctx: lowering.New(0, doc, ir.SourceInfo{}, "", overlay.Origin{}),
ctx: lowering.New(0, doc, ir.SourceInfo{}, "", lowering.ExtensionPromotions{}, overlay.Origin{}),
out: &ir.Document{Types: rawTypes.Registry()},
types: rawTypes,
operationIDs: make(map[string]string),
Expand Down
4 changes: 3 additions & 1 deletion compilers/openapi/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ func lowerSecurityScheme(c lowering.Ctx, name string, ss *soa.SecurityScheme,
diags = preserveUnreadFields(c, &scheme, ss, decl)
ext, extDiags := annotation.ExtensionsFrom(ss.GetExtensions(), c.SrcIndex, decl)
scheme.Unmodeled = annotation.MergeUnmodeled(scheme.Unmodeled, ext)
return scheme, true, append(diags, extDiags...)
diags = append(diags, extDiags...)
promoteDiags := c.PromoteDeprecation(scheme.Unmodeled, scheme.Deprecation, &scheme.Provenance)
return scheme, true, append(diags, promoteDiags...)
}

// mechanismRefusalDiag reports a securitySchemes entry that declares a scheme
Expand Down
33 changes: 23 additions & 10 deletions compilers/openapi/internal/lowering/lowering.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ type Ctx struct {
// SrcIndex is this source's index within the compile, stamped into every
// Provenance.
SrcIndex int
// Grouping selects how operations are grouped into OperationGroups. It is the
// only policy the context carries: everything else here is a fact about the
// document, and this is a fact about the caller.
// Grouping selects how operations are grouped into OperationGroups. It is one
// of the two caller policies the context carries; everything else here is a
// fact about the document.
//
// It arrives as the caller wrote it, normalized or not — the compiler's
// Options fills an unset one in before building a context, but nothing here
Expand All @@ -55,6 +55,14 @@ type Ctx struct {
// than a second spelling of the default to keep in step.
Grouping GroupingStrategy

// promotions is the vendor-extension promotion policy, normalized into the
// map PromoteDeprecation reads, and nil when the caller disabled it.
//
// It is the second caller policy, and it is unexported where Grouping is not
// because it holds a map: a struct copy would share it, which is the one
// thing keeping the other maps here unexported is for.
promotions map[string]ExtensionTarget

// schemas is the set of component-schema names the document declares.
//
// It is unexported and read through DeclaresSchema because a struct copy
Expand Down Expand Up @@ -99,19 +107,24 @@ type Ctx struct {
// document as a valid target. It stays nil for a document that declares no
// components, which reads the same as an empty set.
//
// The promotion policy is normalized into its map here for a related reason:
// the caller's map is copied once at entry rather than shared, so no lowering
// can write through the context into what the caller passed.
//
// The $dynamicAnchor index is deliberately not derived here, though GitHub #172
// asked for it. Building it emits a diagnostic when the walk hits its bounds, so
// building it is a lowering action rather than context: done at entry, that
// warning would reach documents that never write $dynamicRef, changing what the
// compiler reports about them. It stays where it is, built on first use.
func New(srcIndex int, doc *soa.OpenAPI, src ir.SourceInfo, grouping GroupingStrategy, origin overlay.Origin) Ctx {
func New(srcIndex int, doc *soa.OpenAPI, src ir.SourceInfo, grouping GroupingStrategy, promotions ExtensionPromotions, origin overlay.Origin) Ctx {
return Ctx{
Doc: doc,
Source: src,
SrcIndex: srcIndex,
Grouping: grouping,
schemas: declaredSchemaNames(doc),
overlay: origin,
Doc: doc,
Source: src,
SrcIndex: srcIndex,
Grouping: grouping,
promotions: promotionSet(promotions),
schemas: declaredSchemaNames(doc),
overlay: origin,
}
}

Expand Down
16 changes: 8 additions & 8 deletions compilers/openapi/internal/lowering/lowering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func TestNew_DerivesTheDeclaredSchemaNames(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := lowering.New(0, tc.doc, ir.SourceInfo{}, "", overlay.Origin{})
c := lowering.New(0, tc.doc, ir.SourceInfo{}, "", lowering.ExtensionPromotions{}, overlay.Origin{})
for _, n := range tc.declares {
assert.True(t, c.DeclaresSchema(n), "%q is declared", n)
}
Expand Down Expand Up @@ -123,7 +123,7 @@ func TestNew_KeepsTheDocumentItWasGiven(t *testing.T) {
doc := docDeclaring("User")
src := ir.SourceInfo{Format: "openapi@3.1", Path: "spec.yaml", Hash: "abc"}

c := lowering.New(7, doc, src, lowering.GroupByPathPrefix, overlay.Origin{})
c := lowering.New(7, doc, src, lowering.GroupByPathPrefix, lowering.ExtensionPromotions{}, overlay.Origin{})

assert.Same(t, doc, c.Doc, "the document is referenced, never copied")
assert.Equal(t, src, c.Source)
Expand All @@ -140,7 +140,7 @@ func TestWithAuth_ExtendsACopy(t *testing.T) {
t.Parallel()
doc := docDeclaring("User")
src := ir.SourceInfo{Format: "openapi@3.1", Path: "spec.yaml", Hash: "abc"}
before := lowering.New(7, doc, src, lowering.GroupByPathPrefix, overlay.Origin{})
before := lowering.New(7, doc, src, lowering.GroupByPathPrefix, lowering.ExtensionPromotions{}, overlay.Origin{})
schemes := map[ir.AuthID]ir.AuthScheme{"a/apiKey": {ID: "a/apiKey"}}

after := before.WithAuth(schemes)
Expand Down Expand Up @@ -203,7 +203,7 @@ func TestExclusiveBoundIsBoolean_FollowsTheDialect(t *testing.T) {
for _, tc := range tests {
t.Run(tc.version, func(t *testing.T) {
t.Parallel()
c := lowering.New(0, &soa.OpenAPI{OpenAPI: tc.version}, ir.SourceInfo{}, "", overlay.Origin{})
c := lowering.New(0, &soa.OpenAPI{OpenAPI: tc.version}, ir.SourceInfo{}, "", lowering.ExtensionPromotions{}, overlay.Origin{})
assert.Equal(t, tc.want, c.ExclusiveBoundIsBoolean())
})
}
Expand All @@ -215,7 +215,7 @@ func TestExclusiveBoundIsBoolean_FollowsTheDialect(t *testing.T) {
// decides whether an internal pointer names anything.
func TestRefScope_IsTheContextSeenAsAScope(t *testing.T) {
t.Parallel()
c := lowering.New(0, docDeclaring("User"), ir.SourceInfo{Path: "spec.yaml"}, "", overlay.Origin{})
c := lowering.New(0, docDeclaring("User"), ir.SourceInfo{Path: "spec.yaml"}, "", lowering.ExtensionPromotions{}, overlay.Origin{})

scope := c.RefScope()

Expand Down Expand Up @@ -281,7 +281,7 @@ func TestSources_ListsTheOverlayAfterTheSourceItPatched(t *testing.T) {
"overlay: 1.0.0\ninfo: {title: O, version: \"1\"}\nactions:\n"+
" - target: $.info\n update: {description: d}\n")

c := lowering.New(0, docDeclaring(), src, "", origin)
c := lowering.New(0, docDeclaring(), src, "", lowering.ExtensionPromotions{}, origin)

require.Len(t, c.Sources(), 2)
assert.Equal(t, src, c.Sources()[0], "the source being lowered comes first")
Expand All @@ -296,7 +296,7 @@ func TestSources_ListsOnlyTheSourceWhenNoOverlayApplied(t *testing.T) {
t.Parallel()
src := ir.SourceInfo{Format: "openapi@3.1", Path: "spec.yaml"}

c := lowering.New(0, docDeclaring(), src, "", overlay.Origin{})
c := lowering.New(0, docDeclaring(), src, "", lowering.ExtensionPromotions{}, overlay.Origin{})

assert.Equal(t, []ir.SourceInfo{src}, c.Sources())
}
Expand All @@ -312,7 +312,7 @@ func TestProvenanceAt_NamesTheOverlayForThePositionsItIntroduced(t *testing.T) {
"overlay: 1.0.0\ninfo: {title: O, version: \"1\"}\nactions:\n"+
" - target: $.info\n update: {description: d}\n")

c := lowering.New(0, docDeclaring(), ir.SourceInfo{}, "", origin)
c := lowering.New(0, docDeclaring(), ir.SourceInfo{}, "", lowering.ExtensionPromotions{}, origin)

assert.Equal(t, ir.Provenance{Source: 1, Pointer: "/info/description"},
c.ProvenanceAt("/info/description"), "the overlay introduced this position")
Expand Down
179 changes: 179 additions & 0 deletions compilers/openapi/internal/lowering/promotion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package lowering

import (
"encoding/json"
"maps"
"slices"
"strings"

"github.com/dexpace/morphic/compilers/openapi/internal/diag"
"github.com/dexpace/morphic/ir"
)

// ExtensionPromotionHeuristic is the name Provenance.Inferred carries on a node
// whose typed field was read out of a vendor extension. It is a constant
// because the marker is what an auditor greps for, and a spelling written at
// the producing site and again at a reading test can drift.
const ExtensionPromotionHeuristic = "extension-promotion"

// extensionKeyPrefix is the namespace an x-* key is kept under in Unmodeled.
// Promotion reads the preserved entry rather than the source node, so it has to
// spell the same namespace back.
const extensionKeyPrefix = "openapi:"

// ExtensionTarget names one typed IR field a vendor extension can be read into.
//
// It is a closed vocabulary rather than a free-form path because a promotion
// has to be applied by code that knows the field's type, and a name nothing
// implements would be a policy that silently does nothing.
type ExtensionTarget string

// The typed fields promotion can fill today. Every other field an extension is
// the only OpenAPI spelling for — Pagination, LongRunning, Idempotency,
// ErrorCase.Retryable/Throttling, Enum.Flags, EnumMember.Name, Sensitive and
// Secret — is a target this vocabulary is meant to grow, not a decision against
// it (GitHub #252).
const (
// TargetDeprecationMessage fills ir.Deprecation.Message.
TargetDeprecationMessage ExtensionTarget = "deprecation.message"
// TargetDeprecationSince fills ir.Deprecation.Since.
TargetDeprecationSince ExtensionTarget = "deprecation.since"
// TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion.
TargetDeprecationRemovalVersion ExtensionTarget = "deprecation.removalVersion"
)

// ExtensionPromotions is the vendor-extension promotion policy: which x-* keys
// are read into which typed IR field.
//
// It is a policy rather than a table in the lowering because OpenAPI assigns an
// x-* key no semantics whatsoever, so reading one as anything is a guess about
// a convention (architecture principle 6). A promoted field is marked
// ExtensionPromotionHeuristic in its node's provenance and the extension stays
// in Unmodeled untouched, which is what makes the guess auditable and
// reversible: a consumer that disagrees can ignore the typed field and read the
// entry itself.
type ExtensionPromotions struct {
// Disabled turns promotion off. Off means off: every extension is kept
// verbatim and no typed field is written from one.
Disabled bool `json:"disabled,omitempty"`
// Targets replaces the default map rather than extending it, so a caller who
// states a mapping gets exactly that mapping. Empty means the default. Keys
// are extension names as the document writes them, x- prefix included.
Targets map[string]ExtensionTarget `json:"targets,omitempty"`
}

// DefaultExtensionPromotions is the mapping the policy uses when the caller
// states none. It is a default and not a standard: OpenAPI defines none of
// these keys, and each is simply the spelling that has become common for a
// field the format never gave a keyword. A document using another spelling is
// not wrong — it names its own mapping.
func DefaultExtensionPromotions() map[string]ExtensionTarget {
return map[string]ExtensionTarget{
"x-deprecated-reason": TargetDeprecationMessage,
"x-deprecated-since": TargetDeprecationSince,
"x-sunset": TargetDeprecationRemovalVersion,
}
}

// PromoteDeprecation fills dep's fields from the vendor extensions kept in
// unmodeled, and marks prov with the heuristic when it writes anything.
//
// It reads the preserved Unmodeled entries rather than the source node, which
// is what makes "the extension survives its own promotion" structural instead
// of a rule each call site has to remember: there is nothing here that could
// consume an entry.
//
// A nil dep is the whole answer for a node that is not deprecated — the field
// describes a deprecation, so an x-deprecated-reason beside no `deprecated: true`
// annotates nothing and stays where it is.
func (c Ctx) PromoteDeprecation(unmodeled ir.Unmodeled, dep *ir.Deprecation, prov *ir.Provenance) []ir.Diagnostic {
if dep == nil || prov == nil || len(unmodeled) == 0 || len(c.promotions) == 0 {
return nil
}
var diags []ir.Diagnostic
var promoted bool
// Sorted, so a policy naming two keys the document writes badly always
// reports the same one first.
for _, key := range slices.Sorted(maps.Keys(c.promotions)) {
field := deprecationField(dep, c.promotions[key])
entry, declared := unmodeled[extensionKeyPrefix+key]
if field == nil || !declared {
continue
}
text, ok := extensionText(entry.Value)
if !ok {
diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct,
entry.Provenance.Pointer, "extension %q is not a string, so it does not fill %s",
key, c.promotions[key]))
continue
}
*field = text
promoted = true
}
if promoted {
markInferred(prov, ExtensionPromotionHeuristic)
}
return diags
}

// 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.
func deprecationField(dep *ir.Deprecation, target ExtensionTarget) *string {
switch target {
case TargetDeprecationMessage:
return &dep.Message
case TargetDeprecationSince:
return &dep.Since
case TargetDeprecationRemovalVersion:
return &dep.RemovalVersion
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.
func extensionText(raw ir.RawValue) (string, bool) {
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return "", false
}
return text, true
}

// 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
// reason was promoted is reached by two — so they are listed rather than one
// overwriting the other.
//
// Adding a name already listed is a no-op. A node reached by two references is
// annotated once per reference, and a marker repeated as many times as a
// component happens to be used would make the field depend on the document's
// reference count rather than on which heuristics ran.
func markInferred(prov *ir.Provenance, marker string) {
if prov.Inferred == "" {
prov.Inferred = marker
return
}
if slices.Contains(strings.Split(prov.Inferred, ","), marker) {
return
}
prov.Inferred += "," + marker
}

// promotionSet normalizes a policy into the map PromoteDeprecation reads, or
// nil when promotion is off. The caller's map is copied: the context is passed
// by value and a shared map would be the one part of it a callee could write
// through.
func promotionSet(p ExtensionPromotions) map[string]ExtensionTarget {
if p.Disabled {
return nil
}
if len(p.Targets) == 0 {
return DefaultExtensionPromotions()
}
return maps.Clone(p.Targets)
}
Loading
Loading