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
13 changes: 13 additions & 0 deletions compilers/compile/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const (
authKind = ir.IDKindAuth
serviceKind = ir.IDKindService
groupKind = ir.IDKindGroup
paramKind = ir.IDKindParam

enumMemberKind = ir.IDKindEnumMember
)

// TypeID returns the ID of the type at path within space.
Expand Down Expand Up @@ -67,6 +70,16 @@ func GroupID(space Space, path string) ir.GroupID {
return ir.GroupID(idFor(groupKind, space, path))
}

// ParamID returns the ID of the parameter at path within space.
func ParamID(space Space, path string) ir.ParamID {
return ir.ParamID(idFor(paramKind, space, path))
}

// EnumMemberID returns the ID of the enum member at path within space.
func EnumMemberID(space Space, path string) ir.EnumMemberID {
return ir.EnumMemberID(idFor(enumMemberKind, space, path))
}

// idFor joins a kind prefix, a space and a path with single separators.
//
// The path's leading separator is supplied here rather than assumed, so a
Expand Down
11 changes: 11 additions & 0 deletions compilers/compile/ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ func TestIDGrammar_KindPrefixes(t *testing.T) {
compile.AuthID(space, "/components/securitySchemes/apiKey"))
assert.Equal(t, ir.ServiceID("s/openapi/0"), compile.ServiceID(space, "0"))
assert.Equal(t, ir.GroupID("g/tags/pets"), compile.GroupID("tags", "/pets"))
assert.Equal(t, ir.ParamID("param/openapi/paths/~1pets/get/parameters/limit/query"),
compile.ParamID(space, "/paths/~1pets/get/parameters/limit/query"))
}

// TestEnumMemberID_UsesTheEnumMemberKind pins the kind an enum member's ID opens
// with, written out so it cannot agree with a change to the constant.
func TestEnumMemberID_UsesTheEnumMemberKind(t *testing.T) {
t.Parallel()
assert.Equal(t, ir.EnumMemberID("e/openapi/components/schemas/Status/s:active"),
compile.EnumMemberID("openapi", "/components/schemas/Status/s:active"))
assert.Equal(t, "e", ir.IDKindEnumMember)
}

// TestIDGrammar_PathSeparatorIsSuppliedOnce pins that the framework owns the
Expand Down
92 changes: 88 additions & 4 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ func conformanceCases() []conformanceCase {
{"literal-const", assertLiteralConst, []string{"literal-types"}},
{"tags-grouping", assertTagsGrouping, []string{"operation-grouping"}},
{"group-identity", assertGroupIdentity, []string{"operation-grouping"}},
{"param-identity", assertParamIdentity, []string{"http-binding"}},
{"enum-member-identity", assertEnumMemberIdentity, []string{"enums-string", "enums-numeric"}},
{"http-binding", assertHTTPBinding, []string{"http-binding"}},
{"param-styles", assertParamStyles, []string{"param-styles"}},
{"param-style-matrix", assertParamStyleMatrix, []string{"param-styles"}},
Expand Down Expand Up @@ -1869,6 +1871,88 @@ func assertGroupIdentity(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
}, got)
}

// assertParamIdentity pins what identifies a parameter: the operation it is
// mounted under, its name as one escaped token, and its location. Two
// parameters named id in one operation get two IDs; a path-item parameter is one
// parameter per operation it is copied into; a path item reached from two paths
// roots each mount's parameters at that mount. The IDs are written out rather
// than derived, so the test cannot agree with a change to the derivation, and
// every binding must name its own parameter's ID.
func assertParamIdentity(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
require.Len(t, doc.Services, 1)
got := map[ir.OpID][]ir.ParamID{}
svc := doc.Services[0]
for _, g := range svc.Groups {
for _, op := range g.Operations {
for _, p := range op.Params {
got[op.ID] = append(got[op.ID], p.ID)
}
require.Len(t, op.Bindings.HTTP, 1)
var bound []ir.ParamID
for _, pb := range op.Bindings.HTTP[0].ParamBindings {
bound = append(bound, pb.Param)
}
assert.ElementsMatch(t, got[op.ID], bound, "%s: each binding names its own parameter", op.ID)
}
}
assert.Equal(t, map[ir.OpID][]ir.ParamID{
"op/openapi/paths/~1items~1{id}/get": {
"param/openapi/paths/~1items~1{id}/get/parameters/id/query",
"param/openapi/paths/~1items~1{id}/get/parameters/a~1b/header",
"param/openapi/paths/~1items~1{id}/get/parameters/id/path",
},
"op/openapi/paths/~1items~1{id}/post": {
"param/openapi/paths/~1items~1{id}/post/parameters/id/path",
},
"op/openapi/paths/~1a/get": {"param/openapi/paths/~1a/get/parameters/limit/query"},
"op/openapi/paths/~1b/get": {"param/openapi/paths/~1b/get/parameters/limit/query"},
}, got)
}

// assertEnumMemberIdentity pins what identifies an enum member: the value it
// holds, tagged by kind, under the enum that declares it. The list repeats a
// value, includes names that spell one canonical word sequence, and carries
// the characters the key escapes, so a derivation from the name, the position or
// the unescaped value changes an ID. The IDs are written out rather than
// derived, so the test cannot agree with a change to the derivation. The repeat
// is kept, takes a numbered suffix and is reported once at its own entry.
func assertEnumMemberIdentity(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
memberIDs := func(id ir.TypeID) []ir.EnumMemberID {
enum, ok := doc.Types[id].(*ir.Enum)
require.True(t, ok, "%s is an enum", id)
got := make([]ir.EnumMemberID, 0, len(enum.Members))
for _, m := range enum.Members {
got = append(got, m.ID)
}
return got
}
assert.Equal(t, []ir.EnumMemberID{
"e/openapi/components/schemas/E/s:a-b",
"e/openapi/components/schemas/E/s:a_b",
"e/openapi/components/schemas/E/s:A B",
"e/openapi/components/schemas/E/s:a~1b",
"e/openapi/components/schemas/E/s:a%23b",
"e/openapi/components/schemas/E/s:a-b#2",
}, memberIDs("t/openapi/components/schemas/E"))
assert.Equal(t, []ir.EnumMemberID{
"e/openapi/components/schemas/N/n:-1",
"e/openapi/components/schemas/N/n:1",
"e/openapi/components/schemas/N/n:0",
}, memberIDs("t/openapi/components/schemas/N"))
assert.Equal(t, []ir.EnumMemberID{
"e/anon/paths/~1status/get/responses/200/content/application~1json/schema/s:open",
"e/anon/paths/~1status/get/responses/200/content/application~1json/schema/s:closed",
}, memberIDs("t/anon/paths/~1status/get/responses/200/content/application~1json/schema"))

var repeats []string
for _, d := range diags {
if d.Code == "openapi/duplicate-enum-value" {
repeats = append(repeats, string(d.Provenance.Pointer))
}
}
assert.Equal(t, []string{"/components/schemas/E/enum/5"}, repeats)
}

func assertHTTPBinding(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
op, ok := opByName(doc, "getItem")
require.True(t, ok)
Expand All @@ -1884,7 +1968,7 @@ func assertParamStyles(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
require.Len(t, op.Bindings.HTTP, 1)
byParam := map[string]ir.HTTPParamBinding{}
for _, pb := range op.Bindings.HTTP[0].ParamBindings {
byParam[pb.Param] = pb
byParam[pb.WireName] = pb
}
q := byParam["q"]
assert.Equal(t, "form", q.Style, "query default style is form")
Expand Down Expand Up @@ -2095,8 +2179,8 @@ func assertParamStyleMatrix(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {

got := make(map[paramID]paramWire, len(op.Bindings.HTTP[0].ParamBindings))
for _, pb := range op.Bindings.HTTP[0].ParamBindings {
require.NotNil(t, pb.Explode, "%s: explode resolves to a value, never to nothing", pb.Param)
id := paramID{Param: pb.Param, Location: pb.Location}
require.NotNil(t, pb.Explode, "%s: explode resolves to a value, never to nothing", pb.WireName)
id := paramID{Param: pb.WireName, Location: pb.Location}
require.NotContains(t, got, id, "two parameter bindings share the identity %+v", id)
got[id] = paramWire{Style: pb.Style, Explode: *pb.Explode}
}
Expand Down Expand Up @@ -2128,7 +2212,7 @@ func assertQuerystringParam(t *testing.T, doc *ir.Document) {

binding := op.Bindings.HTTP[0].ParamBindings[0]
require.Equal(t, ir.HTTPLocationQuerystring, binding.Location)
require.Equal(t, "querystringWhole", binding.Param, "the querystring parameter binds")
require.Equal(t, "querystringWhole", binding.WireName, "the querystring parameter binds")
assert.Equal(t, "application/x-www-form-urlencoded", binding.ContentType,
"its media type is where its serialization is actually stated")
assert.Empty(t, binding.Style, "style is not a legal keyword at in: querystring")
Expand Down
4 changes: 2 additions & 2 deletions compilers/openapi/conformance_unmodeled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -866,12 +866,12 @@ func assertCoDeclaredSchemaContent(t *testing.T, doc *ir.Document, diags []ir.Di
diagsAt(diags, "openapi/degraded-construct", "/paths/~1x/get/responses/200/headers/X-H/schema"))
}

// indexByParam indexes HTTP parameter bindings by the logical parameter they
// indexByParam indexes HTTP parameter bindings by wire name, the name of the logical parameter they
// bind.
func indexByParam(bindings []ir.HTTPParamBinding) map[string]ir.HTTPParamBinding {
out := make(map[string]ir.HTTPParamBinding, len(bindings))
for _, b := range bindings {
out[b.Param] = b
out[b.WireName] = b
}
return out
}
8 changes: 8 additions & 0 deletions compilers/openapi/internal/diag/diag.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ const (
// filled. Not an error: the document is well-formed, and harness.Check stops
// at the first error diagnostic, hiding later findings.
EmptyEnum = "openapi/empty-enum"
// DuplicateEnumValue reports an `enum` member whose value an earlier member
// already holds. Every occurrence is kept, since dropping one would lose a
// declaration, but only the first can hold the bare member ID: the repeats
// take a numbered suffix, which is an artefact worth telling the author of.
//
// Warning: the document is well-formed, and JSON Schema's own uniqueness
// requirement on enum is a SHOULD.
DuplicateEnumValue = "openapi/duplicate-enum-value"
// NumericPrecision reports a numeric bound literal that is not a finite
// number (error severity: Morphic owns these keywords, so this is the sole
// diagnostic for the defect — see boundLiteralDiag).
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/internal/diag/diag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func codes() []string {
diag.UndecodableSource,
diag.OverlayInvalid, diag.OverlayFailed,
diag.OverlayAction, diag.OverlayOriginIncomplete,
diag.ValidationOnlyKeyword, diag.FalseSchema, diag.EmptyEnum,
diag.ValidationOnlyKeyword, diag.FalseSchema, diag.EmptyEnum, diag.DuplicateEnumValue,
diag.NumericPrecision, diag.ExclusiveBoundForm, diag.InvalidStatusKey,
diag.DuplicateStatusKey,
diag.InvalidMethodKey, diag.DegradedConstruct,
Expand Down
41 changes: 35 additions & 6 deletions compilers/openapi/internal/ids/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,14 @@ func WebhookGroup() ir.GroupID { return compile.GroupID(WebhookGroupSpace, "") }
// compiler's tests hold the list to the namespaces its corpus really uses.
func Namespaces() compile.Namespaces {
return compile.Namespaces{
ir.IDKindType: {OpenAPISpace, AnonSpace, ComposedSpace},
ir.IDKindOp: {OpenAPISpace},
ir.IDKindProp: {OpenAPISpace},
ir.IDKindAuth: {OpenAPISpace},
ir.IDKindService: {OpenAPISpace},
ir.IDKindGroup: {TagGroupSpace, PathPrefixGroupSpace, DefaultGroupSpace, WebhookGroupSpace},
ir.IDKindType: {OpenAPISpace, AnonSpace, ComposedSpace},
ir.IDKindOp: {OpenAPISpace},
ir.IDKindProp: {OpenAPISpace},
ir.IDKindAuth: {OpenAPISpace},
ir.IDKindService: {OpenAPISpace},
ir.IDKindGroup: {TagGroupSpace, PathPrefixGroupSpace, DefaultGroupSpace, WebhookGroupSpace},
ir.IDKindParam: {OpenAPISpace},
ir.IDKindEnumMember: {OpenAPISpace, AnonSpace},
}
}

Expand Down Expand Up @@ -145,6 +147,33 @@ func Prop(pointer jsontext.Pointer) ir.PropID {
return compile.PropID(OpenAPISpace, string(pointer))
}

// Param returns the stable ID of the parameter called name in location in, as
// declared on the operation mounted at mount.
//
// The mount is the pointer the operation lowers at, so a path item $ref'd from
// two paths gives each mount's parameters IDs of their own. The name is one
// escaped token and in is the source's location, so a name and a location
// cannot be confused and a reordered list renames nothing. A parameter is
// identified by its name and location together: two may share a name.
func Param(mount jsontext.Pointer, name, in string) ir.ParamID {
return compile.ParamID(OpenAPISpace, string(mount+Ptr("parameters", name, in)))
}

// EnumMember returns the stable ID of the member of the enum at enumPointer
// that holds key.
//
// It lives in the namespace of the enum's own TypeID, chosen the way ForPointer
// chooses it, so the member sits under the type that declares it. The key is
// escaped as one RFC 6901 token; the caller builds it from the member's value,
// never from its name or position.
func EnumMember(enumPointer jsontext.Pointer, key string) ir.EnumMemberID {
path := string(enumPointer + Ptr(key))
if _, ok := ComponentSchemaName(enumPointer); ok {
return compile.EnumMemberID(OpenAPISpace, path)
}
return compile.EnumMemberID(AnonSpace, path)
}

// Auth returns the stable ID of the named security scheme.
func Auth(name string) ir.AuthID {
return compile.AuthID(OpenAPISpace, string(Ptr("components", "securitySchemes", name)))
Expand Down
64 changes: 64 additions & 0 deletions compilers/openapi/internal/ids/ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,5 +396,69 @@ func TestNamespaces_NameEveryNamespaceThisCompilerMints(t *testing.T) {
ir.IDKindAuth: {"openapi"},
ir.IDKindService: {"openapi"},
ir.IDKindGroup: {"default", "path-prefix", "tags", "webhooks"},
ir.IDKindParam: {"openapi"},

ir.IDKindEnumMember: {"anon", "openapi"},
}, ids.Namespaces().Declaration())
}

// TestParam_ScopesByOperationNameAndLocation pins the spelling of a parameter ID
// and what it is scoped by, written out rather than derived. The mount, the name
// and the location each change it; the name is one escaped token, so a slash in
// it never reads as two segments.
func TestParam_ScopesByOperationNameAndLocation(t *testing.T) {
t.Parallel()
const mount = jsontext.Pointer("/paths/~1items/get")
tests := []struct {
name string
got ir.ParamID
want ir.ParamID
}{
{name: "a query parameter", got: ids.Param(mount, "id", "query"), want: "param/openapi/paths/~1items/get/parameters/id/query"},
{name: "the same name in the path", got: ids.Param(mount, "id", "path"), want: "param/openapi/paths/~1items/get/parameters/id/path"},
{name: "another operation", got: ids.Param("/paths/~1items/post", "id", "query"), want: "param/openapi/paths/~1items/post/parameters/id/query"},
{name: "a slash in the name", got: ids.Param(mount, "a/b", "header"), want: "param/openapi/paths/~1items/get/parameters/a~1b/header"},
{name: "a tilde in the name", got: ids.Param(mount, "a~b", "header"), want: "param/openapi/paths/~1items/get/parameters/a~0b/header"},
}
seen := map[ir.ParamID]string{}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, tc.got)
assert.True(t, ir.WellFormedID(ir.IDKindParam, string(tc.got)), "%q must be a well-formed param ID", tc.got)
})
require.NotContains(t, seen, tc.got, "%s and %s share an ID", tc.name, seen[tc.got])
seen[tc.got] = tc.name
}
}

// TestEnumMember_LivesInItsEnumsSpaceAndPath pins the namespace of a member to
// that of its enum's TypeID, for a named enum and an anonymous one, and its path
// to the enum's pointer followed by one escaped key.
func TestEnumMember_LivesInItsEnumsSpaceAndPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pointer jsontext.Pointer
key string
want ir.EnumMemberID
enum ir.TypeID
}{
{"a component schema", "/components/schemas/E", "s:a", "e/openapi/components/schemas/E/s:a", "t/openapi/components/schemas/E"},
{"an inline schema", "/components/schemas/S/properties/p", "s:a", "e/anon/components/schemas/S/properties/p/s:a", "t/anon/components/schemas/S/properties/p"},
{"a key holding a slash", "/components/schemas/E", "s:a/b", "e/openapi/components/schemas/E/s:a~1b", "t/openapi/components/schemas/E"},
{"a key holding a tilde", "/components/schemas/E", "s:a~b", "e/openapi/components/schemas/E/s:a~0b", "t/openapi/components/schemas/E"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := ids.EnumMember(tc.pointer, tc.key)
assert.Equal(t, tc.want, got)
assert.True(t, ir.WellFormedID(ir.IDKindEnumMember, string(got)))
memberSpace, _ := ir.IDSpace(ir.IDKindEnumMember, string(got))
enumSpace, _ := ir.IDSpace(ir.IDKindType, string(ids.ForPointer(tc.pointer)))
assert.Equal(t, enumSpace, memberSpace, "a member lives in its enum's own namespace")
assert.Equal(t, tc.enum, ids.ForPointer(tc.pointer))
})
}
}
2 changes: 1 addition & 1 deletion compilers/openapi/internal/operation/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ func lowerOperation(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd
if src.GetDeprecated() {
op.Deprecation = &ir.Deprecation{}
}
params, bindings, paramDiags := lowerParameters(c, ts, anchors, opCtx.params)
params, bindings, paramDiags := lowerParameters(c, ts, anchors, mount, opCtx.params)
diags = append(diags, paramDiags...)
op.Params = params
responses, errs, responseDiags := lowerResponses(c, ts, anchors, src, decl)
Expand Down
13 changes: 8 additions & 5 deletions compilers/openapi/internal/operation/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import (
// order (ir-design §7.2, §8.1). The logical side carries the protocol-neutral
// input; the binding side carries the location, style, and explode facts.
// Each parameter lowers at its declaration: the $ref target for a referenced
// entry, else the list entry itself (issue #107).
func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, params []sourcedParam) ([]ir.Parameter, []ir.HTTPParamBinding, []ir.Diagnostic) {
// entry, else the list entry itself (issue #107). Its ID is rooted at mount, the
// operation's mount pointer, not at the declaration it was copied from.
func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, mount jsontext.Pointer, params []sourcedParam) ([]ir.Parameter, []ir.HTTPParamBinding, []ir.Diagnostic) {
if len(params) == 0 {
return nil, nil, nil
}
Expand All @@ -36,7 +37,7 @@ func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIn
if p == nil {
continue
}
param, binding, paramDiags := lowerParameter(lowering.Within[soa.Parameter](c, sp.ref), ts, anchors, p, pptr)
param, binding, paramDiags := lowerParameter(lowering.Within[soa.Parameter](c, sp.ref), ts, anchors, mount, p, pptr)
diags = append(diags, paramDiags...)
logical = append(logical, param)
bindings = append(bindings, binding)
Expand All @@ -47,16 +48,18 @@ func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIn
// lowerParameter lowers one resolved parameter into its logical Parameter and
// HTTP binding. Path parameters are always required regardless of the declared
// flag (OpenAPI requires it).
func lowerParameter(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, p *soa.Parameter, pptr jsontext.Pointer) (ir.Parameter, ir.HTTPParamBinding, []ir.Diagnostic) {
func lowerParameter(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, mount jsontext.Pointer, p *soa.Parameter, pptr jsontext.Pointer) (ir.Parameter, ir.HTTPParamBinding, []ir.Diagnostic) {
name, in := p.GetName(), p.GetIn()
id := ids.Param(mount, name, string(in))
param := ir.Parameter{
ID: id,
Name: compile.NamingFor(name),
Required: p.GetRequired() || in == soa.ParameterInPath,
Provenance: c.ProvenanceAt(pptr),
}
style, explode := resolveStyleExplode(p, in)
binding := ir.HTTPParamBinding{
Param: name,
Param: id,
Location: httpLocation(in),
WireName: name,
Style: style,
Expand Down
Loading
Loading