From 9ecf4c3c39060b73c3526bef48a6cca7ed593027 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 02:37:36 +0300 Subject: [PATCH 01/18] test: share the test helpers written more than once Four test helpers had two copies with the same body, and one spec was written out twice: - e2e's compilePromotionSpec and compileStreamingSpec, now one compileClean in its helpers_test.go; - annotation's jsonSchemaFromYAML and resolve's schemaFromYAML, now openapitest.SchemaFromYAML, which both packages' tests can import; - archtest's and irtest's repoRoot, now irtest.RepoRoot; - irverify's and pass's opDocNested, now irtest.NestedOverloadDoc; - the CLI's warning spec, which compile_test.go wrote out beside validate_test.go's warnSpec, which it now uses. irtest's RepoRoot takes the working directory's lookup through rootFrom, so its two failures, an unreadable working directory and a walk that reaches the filesystem root, are tested with the package's recording stub; openapitest's SchemaFromYAML is tested with its own. --- cmd/morphic/compile_test.go | 10 +---- compilers/openapi/e2e/helpers_test.go | 12 ++++++ compilers/openapi/e2e/promotion_test.go | 21 ++------- compilers/openapi/e2e/streaming_test.go | 21 ++------- .../annotation/annotation_internal_test.go | 27 ++++-------- .../openapi/internal/openapitest/spec.go | 13 ++++++ .../openapi/internal/openapitest/spec_test.go | 18 ++++++++ .../internal/resolve/locate_internal_test.go | 3 +- .../internal/resolve/refsite_internal_test.go | 22 ++-------- internal/archtest/arch_test.go | 25 +++-------- internal/archtest/doccomment_test.go | 4 +- internal/archtest/goroutineleak_test.go | 4 +- internal/archtest/methodcap_test.go | 4 +- internal/archtest/recursion_test.go | 6 ++- internal/archtest/resolution_test.go | 5 ++- internal/archtest/suppression_test.go | 4 +- internal/archtest/testnames_test.go | 3 +- ir/irtest/golden.go | 3 +- ir/irtest/lineendings_test.go | 26 +++-------- ir/irtest/nesting.go | 22 ++++++++++ ir/irtest/nesting_test.go | 31 +++++++++++++ ir/irtest/repo.go | 40 +++++++++++++++++ ir/irtest/repo_internal_test.go | 43 +++++++++++++++++++ ir/irtest/repo_test.go | 25 +++++++++++ ir/irverify/refs_test.go | 24 ++--------- pass/validate_refs_internal_test.go | 24 ++--------- 26 files changed, 266 insertions(+), 174 deletions(-) create mode 100644 ir/irtest/nesting.go create mode 100644 ir/irtest/nesting_test.go create mode 100644 ir/irtest/repo.go create mode 100644 ir/irtest/repo_internal_test.go create mode 100644 ir/irtest/repo_test.go diff --git a/cmd/morphic/compile_test.go b/cmd/morphic/compile_test.go index a2ab1255..0e9719b3 100644 --- a/cmd/morphic/compile_test.go +++ b/cmd/morphic/compile_test.go @@ -89,15 +89,7 @@ func TestRun_SpecProblemsExitOne(t *testing.T) { func TestRun_DiagnosticsGateExitCode(t *testing.T) { t.Parallel() - // A parseable spec that produces at least one warning-or-info diagnostic - // but no errors: validation-only keyword. - spec := writeFile(t, "warn.yaml", `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: {} -components: - schemas: - S: {type: object, not: {required: [x]}} -`) + spec := writeFile(t, "warn.yaml", warnSpec) var stdout, stderr bytes.Buffer require.Equal(t, 0, run([]string{"compile", spec}, &stdout, &stderr), "info diagnostics must not fail the default gate") diff --git a/compilers/openapi/e2e/helpers_test.go b/compilers/openapi/e2e/helpers_test.go index 3a66a058..90deb2d7 100644 --- a/compilers/openapi/e2e/helpers_test.go +++ b/compilers/openapi/e2e/helpers_test.go @@ -411,3 +411,15 @@ func unresolvedRefs(diags []ir.Diagnostic) []ir.Diagnostic { } return out } + +// compileClean compiles src with opts and requires no error diagnostic. +func compileClean(t *testing.T, src string, opts openapi.Options) *ir.Document { + t.Helper() + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: "spec.yaml", Data: []byte(src)}}, + compilers.Options{FormatOptions: opts}) + require.NoError(t, err) + require.NotNil(t, doc) + assertNoErrorDiags(t, diags) + return doc +} diff --git a/compilers/openapi/e2e/promotion_test.go b/compilers/openapi/e2e/promotion_test.go index e60f1685..3100aefb 100644 --- a/compilers/openapi/e2e/promotion_test.go +++ b/compilers/openapi/e2e/promotion_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" @@ -176,24 +175,12 @@ paths: description: ok ` -// compilePromotionSpec compiles src with opts and requires no error diagnostic. -func compilePromotionSpec(t *testing.T, src string, opts openapi.Options) *ir.Document { - t.Helper() - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: "spec.yaml", Data: []byte(src)}}, - compilers.Options{FormatOptions: opts}) - require.NoError(t, err) - require.NotNil(t, doc) - assertNoErrorDiags(t, diags) - return doc -} - // TestPromotion_DisabledKeepsExtensionsAndNothingElse pins the off switch // invariant 6 requires. Disabled means the typed field is not written at all, // and the document compiles to exactly what it did before promotion existed. func TestPromotion_DisabledKeepsExtensionsAndNothingElse(t *testing.T) { t.Parallel() - doc := compilePromotionSpec(t, deprecatedOpSpec, + doc := compileClean(t, deprecatedOpSpec, openapi.Options{Promotions: openapi.ExtensionPromotions{Disabled: true}}) op, ok := opByName(doc, "getX") require.True(t, ok) @@ -214,7 +201,7 @@ func TestPromotion_TargetsReplaceTheDefaults(t *testing.T) { "x-gone-in": openapi.TargetDeprecationRemovalVersion, }, }} - doc := compilePromotionSpec(t, deprecatedOpSpec, own) + doc := compileClean(t, deprecatedOpSpec, own) op, ok := opByName(doc, "getX") require.True(t, ok) require.NotNil(t, op.Deprecation) @@ -255,7 +242,7 @@ components: type: string enum: [a, b] ` + keys - doc := compilePromotionSpec(t, spec, openapi.Options{}) + doc := compileClean(t, spec, openapi.Options{}) op, ok := opByName(doc, "getX") require.True(t, ok) require.NotNil(t, op.Deprecation) @@ -320,7 +307,7 @@ paths: "200": description: ok ` - doc := compilePromotionSpec(t, spec, both) + doc := compileClean(t, spec, both) op, ok := opByName(doc, "getX") require.True(t, ok) require.NotNil(t, op.Deprecation) diff --git a/compilers/openapi/e2e/streaming_test.go b/compilers/openapi/e2e/streaming_test.go index 10c88247..deb8b502 100644 --- a/compilers/openapi/e2e/streaming_test.go +++ b/compilers/openapi/e2e/streaming_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" @@ -35,18 +34,6 @@ paths: ` } -// compileStreamingSpec compiles src with opts and requires no error diagnostic. -func compileStreamingSpec(t *testing.T, src string, opts openapi.Options) *ir.Document { - t.Helper() - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: "spec.yaml", Data: []byte(src)}}, - compilers.Options{FormatOptions: opts}) - require.NoError(t, err) - require.NotNil(t, doc) - assertNoErrorDiags(t, diags) - return doc -} - // TestStreaming_PolicyDisabledLeavesOnlyWhatIsDeclared pins the off switch // invariant 6 requires, through the option a caller actually sets. Disabled // means the media-type reading stops entirely, while a 3.2 itemSchema — a @@ -55,7 +42,7 @@ func TestStreaming_PolicyDisabledLeavesOnlyWhatIsDeclared(t *testing.T) { t.Parallel() off := openapi.Options{StreamingMedia: openapi.StreamingMedia{Disabled: true}} - doc := compileStreamingSpec(t, eventStreamSpec("text/event-stream"), off) + doc := compileClean(t, eventStreamSpec("text/event-stream"), off) op, ok := opByName(doc, "getEvents") require.True(t, ok) assert.Empty(t, string(op.Streaming), "the media-type reading is off") @@ -75,7 +62,7 @@ paths: text/event-stream: itemSchema: {type: object, properties: {msg: {type: string}}} ` - doc = compileStreamingSpec(t, declared, off) + doc = compileClean(t, declared, off) op, ok = opByName(doc, "getEvents") require.True(t, ok) assert.Equal(t, ir.StreamingServer, op.Streaming, "disabling a heuristic does not disable a declaration") @@ -101,7 +88,7 @@ func TestStreaming_PolicyMediaTypesReplaceTheDefaults(t *testing.T) { for _, tc := range tests { t.Run(tc.mediaType, func(t *testing.T) { t.Parallel() - doc := compileStreamingSpec(t, eventStreamSpec(tc.mediaType), own) + doc := compileClean(t, eventStreamSpec(tc.mediaType), own) op, ok := opByName(doc, "getEvents") require.True(t, ok) assert.Equal(t, tc.want, op.Streaming) @@ -119,7 +106,7 @@ func TestStreaming_DefaultMediaTypesAreTheOnesApplied(t *testing.T) { defaults := openapi.DefaultStreamingMediaTypes() require.NotEmpty(t, defaults, "an empty list would make this vacuous") for _, mediaType := range defaults { - doc := compileStreamingSpec(t, eventStreamSpec(mediaType), openapi.Options{}) + doc := compileClean(t, eventStreamSpec(mediaType), openapi.Options{}) op, ok := opByName(doc, "getEvents") require.True(t, ok) assert.Equal(t, ir.StreamingServer, op.Streaming, "default media type %q does not stream", mediaType) diff --git a/compilers/openapi/internal/annotation/annotation_internal_test.go b/compilers/openapi/internal/annotation/annotation_internal_test.go index 0c477a60..086066f7 100644 --- a/compilers/openapi/internal/annotation/annotation_internal_test.go +++ b/compilers/openapi/internal/annotation/annotation_internal_test.go @@ -261,17 +261,6 @@ func TestRead_NodeTypeOnlyADefinedValueIsPlaced(t *testing.T) { } } -// jsonSchemaFromYAML unmarshals body as a JSONSchema, keeping the reference and -// boolean forms a bare *oas3.Schema cannot represent. -func jsonSchemaFromYAML(t *testing.T, body string) *oas3.JSONSchema[oas3.Referenceable] { - t.Helper() - js := &oas3.JSONSchema[oas3.Referenceable]{} - valErrs, err := marshaller.Unmarshal(t.Context(), strings.NewReader(body), js) - require.NoError(t, err) - require.Empty(t, valErrs, "the fixture parses cleanly") - return js -} - // TestAt_ClassifiesEveryPositionShape pins the classification every annotation // read starts from. A declaration misread as a reference would fall back to a // referent that does not describe it, and a reference misread as a declaration @@ -304,7 +293,7 @@ func TestAt_ClassifiesEveryPositionShape(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := At(jsonSchemaFromYAML(t, tc.body)) + got := At(openapitest.SchemaFromYAML(t.Context(), t, tc.body)) assert.Equal(t, tc.wantKind, got.Kind) assert.Equal(t, tc.wantNode, got.Node != nil, "Node presence") assert.Nil(t, got.Referent, "At fills no Referent") @@ -326,7 +315,7 @@ func TestAt_ClassifiesEveryPositionShape(t *testing.T) { // At builds (see TestAt_ClassifiesEveryPositionShape). func TestAt_ReferenceSiteKeepsTheBodyWrittenBesideTheRef(t *testing.T) { t.Parallel() - got := At(jsonSchemaFromYAML(t, "{$ref: '#/$defs/Target', description: fromSite}\n")) + got := At(openapitest.SchemaFromYAML(t.Context(), t, "{$ref: '#/$defs/Target', description: fromSite}\n")) require.Equal(t, Reference, got.Kind) require.NotNil(t, got.Node, "the body written beside the $ref is the site's own") @@ -340,10 +329,10 @@ func TestAt_ReferenceSiteKeepsTheBodyWrittenBesideTheRef(t *testing.T) { func TestSchemaOf_ReturnsOnlyAWrittenBody(t *testing.T) { t.Parallel() assert.Nil(t, SchemaOf(nil), "a nil position writes no body") - assert.Nil(t, SchemaOf(jsonSchemaFromYAML(t, "true\n")), "a boolean schema writes no body") - assert.Nil(t, SchemaOf(jsonSchemaFromYAML(t, "false\n")), "including the false one") + assert.Nil(t, SchemaOf(openapitest.SchemaFromYAML(t.Context(), t, "true\n")), "a boolean schema writes no body") + assert.Nil(t, SchemaOf(openapitest.SchemaFromYAML(t.Context(), t, "false\n")), "including the false one") - s := SchemaOf(jsonSchemaFromYAML(t, "type: string\ntitle: T\n")) + s := SchemaOf(openapitest.SchemaFromYAML(t.Context(), t, "type: string\ntitle: T\n")) require.NotNil(t, s) assert.Equal(t, "T", s.GetTitle()) } @@ -353,9 +342,9 @@ func TestSchemaOf_ReturnsOnlyAWrittenBody(t *testing.T) { // one would turn a permissive position into a forbidding one. func TestIsFalseSchema_OnlyTheFalseBoolean(t *testing.T) { t.Parallel() - assert.True(t, IsFalseSchema(jsonSchemaFromYAML(t, "false\n"))) - assert.False(t, IsFalseSchema(jsonSchemaFromYAML(t, "true\n"))) - assert.False(t, IsFalseSchema(jsonSchemaFromYAML(t, "type: string\n"))) + assert.True(t, IsFalseSchema(openapitest.SchemaFromYAML(t.Context(), t, "false\n"))) + assert.False(t, IsFalseSchema(openapitest.SchemaFromYAML(t.Context(), t, "true\n"))) + assert.False(t, IsFalseSchema(openapitest.SchemaFromYAML(t.Context(), t, "type: string\n"))) assert.False(t, IsFalseSchema(nil)) } diff --git a/compilers/openapi/internal/openapitest/spec.go b/compilers/openapi/internal/openapitest/spec.go index ac69f677..07d35510 100644 --- a/compilers/openapi/internal/openapitest/spec.go +++ b/compilers/openapi/internal/openapitest/spec.go @@ -1,10 +1,12 @@ package openapitest import ( + "context" "fmt" "strings" oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" soa "github.com/speakeasy-api/openapi/openapi" "github.com/speakeasy-api/openapi/sequencedmap" "github.com/stretchr/testify/require" @@ -121,6 +123,17 @@ func YAMLNode(t TB, src string) *yaml.Node { return doc.Content[0] } +// SchemaFromYAML unmarshals body as a JSONSchema, keeping the reference and +// boolean forms a bare *oas3.Schema cannot represent. +func SchemaFromYAML(ctx context.Context, t TB, body string) *oas3.JSONSchema[oas3.Referenceable] { + t.Helper() + js := &oas3.JSONSchema[oas3.Referenceable]{} + valErrs, err := marshaller.Unmarshal(ctx, strings.NewReader(body), js) + require.NoError(t, err) + require.Empty(t, valErrs, "the fixture parses cleanly") + return js +} + // StrNode builds a bare string-scalar yaml.Node. func StrNode(val string) *yaml.Node { return ScalarNode("!!str", val) diff --git a/compilers/openapi/internal/openapitest/spec_test.go b/compilers/openapi/internal/openapitest/spec_test.go index d88ff908..f7875a59 100644 --- a/compilers/openapi/internal/openapitest/spec_test.go +++ b/compilers/openapi/internal/openapitest/spec_test.go @@ -92,6 +92,24 @@ func TestEmptyEitherSchema_IsASchemaWithNoSchema(t *testing.T) { assert.Nil(t, js.GetSchema()) } +// TestSchemaFromYAML_KeepsTheReferenceForm checks a schema written as a $ref +// comes back as a reference, which a bare *oas3.Schema could not hold. +func TestSchemaFromYAML_KeepsTheReferenceForm(t *testing.T) { + t.Parallel() + js := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: '#/$defs/T'}") + require.NotNil(t, js) + assert.True(t, js.IsReference()) +} + +// TestSchemaFromYAML_FailsOnAFixtureThatDoesNotParse checks a fixture the +// library cannot read fails the test rather than handing back an empty schema. +func TestSchemaFromYAML_FailsOnAFixtureThatDoesNotParse(t *testing.T) { + t.Parallel() + rec := &recorder{} + openapitest.SchemaFromYAML(t.Context(), rec, "{") + assert.True(t, rec.failed(), "an unreadable fixture is reported") +} + // TestYAMLNode_ReturnsTheRootValueNode checks the document node is unwrapped, // which is what makes the result comparable to what a parsed schema field // exposes. diff --git a/compilers/openapi/internal/resolve/locate_internal_test.go b/compilers/openapi/internal/resolve/locate_internal_test.go index e64231a2..d2a01be9 100644 --- a/compilers/openapi/internal/resolve/locate_internal_test.go +++ b/compilers/openapi/internal/resolve/locate_internal_test.go @@ -14,6 +14,7 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/navigation" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) // elsewhere is an Ends that reads every node it is asked about as a reference @@ -137,7 +138,7 @@ func TestScopeReached_StepsPastAnIndexTheLibraryRetries(t *testing.T) { t.Parallel() var built yaml.Node require.NoError(t, yaml.Unmarshal([]byte("{'0': {j: 5}}"), &built)) - js := schemaFromYAML(t, "type: string\n") + js := openapitest.SchemaFromYAML(t.Context(), t, "type: string\n") doc := &indexedDoc{built: built.Content[0], schema: js} for _, ends := range []func(any) (End, bool){nil, elsewhere} { sc := Scope{Doc: doc, Ends: ends} diff --git a/compilers/openapi/internal/resolve/refsite_internal_test.go b/compilers/openapi/internal/resolve/refsite_internal_test.go index 3f6a3f90..81907f95 100644 --- a/compilers/openapi/internal/resolve/refsite_internal_test.go +++ b/compilers/openapi/internal/resolve/refsite_internal_test.go @@ -1,28 +1,14 @@ package resolve import ( - "strings" "testing" - oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" - "github.com/speakeasy-api/openapi/marshaller" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers/openapi/internal/ids" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) -// schemaFromYAML unmarshals body as a JSONSchema, keeping the reference form a -// bare *oas3.Schema cannot represent. -func schemaFromYAML(t *testing.T, body string) *oas3.JSONSchema[oas3.Referenceable] { - t.Helper() - js := &oas3.JSONSchema[oas3.Referenceable]{} - valErrs, err := marshaller.Unmarshal(t.Context(), strings.NewReader(body), js) - require.NoError(t, err) - require.Empty(t, valErrs, "the fixture parses cleanly") - return js -} - // TestIsRefSite_IncludesTheDegenerateRef pins the deliberately broad test. A // position carrying a $ref field is $ref-shaped even when the ref is empty: // callers here ask "is there a $ref-carrying body", not "is there a followable @@ -30,14 +16,14 @@ func schemaFromYAML(t *testing.T, body string) *oas3.JSONSchema[oas3.Referenceab // ordinary schema body. func TestIsRefSite_IncludesTheDegenerateRef(t *testing.T) { t.Parallel() - ref := schemaFromYAML(t, "{$ref: '#/$defs/T'}\n") + ref := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: '#/$defs/T'}\n") assert.True(t, IsRefSite(ref, ref.GetSchema())) - empty := schemaFromYAML(t, "{$ref: ''}\n") + empty := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: ''}\n") assert.True(t, IsRefSite(empty, empty.GetSchema()), "a present but empty $ref still carries a Ref field") - plain := schemaFromYAML(t, "type: string\n") + plain := openapitest.SchemaFromYAML(t.Context(), t, "type: string\n") assert.False(t, IsRefSite(plain, plain.GetSchema())) assert.False(t, IsRefSite(plain, nil), "a boolean schema carries no body to inspect") } diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go index f5293b5d..987b9103 100644 --- a/internal/archtest/arch_test.go +++ b/internal/archtest/arch_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" ) const module = "github.com/dexpace/morphic" @@ -327,6 +329,7 @@ var rules = map[string][]string{ module + "/compilers/openapi/internal/fragment", "github.com/speakeasy-api/openapi/jsonpointer", "github.com/speakeasy-api/openapi/jsonschema/oas3", + "github.com/speakeasy-api/openapi/marshaller", "github.com/speakeasy-api/openapi/openapi", "github.com/speakeasy-api/openapi/references", "github.com/speakeasy-api/openapi/sequencedmap", @@ -440,7 +443,7 @@ var exempt = map[string]string{ // components where these entries name packages. func TestImportGraph_LayeringHolds(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) for dir, allowed := range rules { t.Run(dir, func(t *testing.T) { t.Parallel() @@ -577,7 +580,7 @@ func writeImporter(t *testing.T, file, imp string) { // the next package added an explicit decision rather than a silent omission. func TestImportGraph_EveryPackageIsRuledOrExempt(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) pkgs := productionPackages(t, root) require.NotEmpty(t, pkgs, "found no production packages to audit") @@ -772,21 +775,3 @@ func entryAdmits(entry, ip string) bool { root, subtree := strings.CutSuffix(entry, subtreeSuffix) return ip == root || (subtree && strings.HasPrefix(ip, root+"/")) } - -// repoRoot walks up from the working directory, which go test sets to this -// package's own, until it finds the module's go.mod, returning that directory. -// It does not start from this file's path: under -trimpath, runtime.Caller -// reports the file under its import path, not on disk. -func repoRoot(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - require.NoError(t, err) - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - require.NotEqual(t, parent, dir, "reached filesystem root without finding go.mod") - dir = parent - } -} diff --git a/internal/archtest/doccomment_test.go b/internal/archtest/doccomment_test.go index 26eaffa0..49270d5e 100644 --- a/internal/archtest/doccomment_test.go +++ b/internal/archtest/doccomment_test.go @@ -18,6 +18,8 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" ) // maxDocCommentWords caps the words in one doc comment, as CLAUDE.md's Go code @@ -42,7 +44,7 @@ type longDoc struct { // longDocComments does not excuse. func TestDocComments_StayUnderTheCap(t *testing.T) { t.Parallel() - docs, err := measureDocComments(repoRoot(t)) + docs, err := measureDocComments(irtest.RepoRoot(t)) require.NoError(t, err) require.NotEmpty(t, docs, "the sweep measured no doc comments, so an empty result proves nothing") for _, v := range docCommentViolations(docs, longDocComments) { diff --git a/internal/archtest/goroutineleak_test.go b/internal/archtest/goroutineleak_test.go index b6653c7d..fda8157a 100644 --- a/internal/archtest/goroutineleak_test.go +++ b/internal/archtest/goroutineleak_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" ) // leakcheckImport is the import path a test file must call Main through for @@ -28,7 +30,7 @@ const leakcheckImport = module + "/internal/leakcheck" // startsGoroutine's to say. func TestGoroutineLeak_EveryGoStatementHasALeakCheck(t *testing.T) { t.Parallel() - violations, err := goroutineLeakCheckViolations(repoRoot(t)) + violations, err := goroutineLeakCheckViolations(irtest.RepoRoot(t)) require.NoError(t, err) for _, v := range violations { t.Error(v) diff --git a/internal/archtest/methodcap_test.go b/internal/archtest/methodcap_test.go index f1b98da6..b665a2a4 100644 --- a/internal/archtest/methodcap_test.go +++ b/internal/archtest/methodcap_test.go @@ -12,6 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" ) // maxMethodsPerType caps how many methods one type may carry inside compilers/. @@ -34,7 +36,7 @@ const maxMethodsPerType = 20 // short method at a time, and every rule the repo enforced stayed green. func TestMethodsPerType_StayUnderTheCap(t *testing.T) { t.Parallel() - counts := methodCounts(t, filepath.Join(repoRoot(t), "compilers")) + counts := methodCounts(t, filepath.Join(irtest.RepoRoot(t), "compilers")) require.NotEmpty(t, counts, "the sweep found no methods at all, so an empty result proves nothing") var over []string diff --git a/internal/archtest/recursion_test.go b/internal/archtest/recursion_test.go index ec793f3f..4dbcaa88 100644 --- a/internal/archtest/recursion_test.go +++ b/internal/archtest/recursion_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/tools/go/packages" + + "github.com/dexpace/morphic/ir/irtest" ) // loweringRecursions are every recursion among the lowerings in @@ -155,7 +157,7 @@ func loweringCallGraph(t *testing.T) map[string]map[string]bool { cfg := &packages.Config{ Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports, - Dir: repoRoot(t), + Dir: irtest.RepoRoot(t), } loaded, err := packages.Load(cfg, patterns...) require.NoError(t, err) @@ -509,7 +511,7 @@ func TestLoweringCallGraph_ResolvesAMethodOnAValue(t *testing.T) { // changed for — one level up, in the input rather than the graph. func TestLoweringCallGraph_ReadsEveryPackageThatLowers(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) found := map[string]bool{} err := filepath.WalkDir(filepath.Join(root, "compilers"), func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !isProductionGoFile(d.Name()) { diff --git a/internal/archtest/resolution_test.go b/internal/archtest/resolution_test.go index 4041009f..86d75599 100644 --- a/internal/archtest/resolution_test.go +++ b/internal/archtest/resolution_test.go @@ -20,6 +20,7 @@ import ( "golang.org/x/tools/go/packages" "github.com/dexpace/morphic/internal/decls" + "github.com/dexpace/morphic/ir/irtest" ) // libraryModule is the parser library whose reference resolution the compiler @@ -598,7 +599,7 @@ func TestResolutionCalls_HeldToTheBaseline(t *testing.T) { } } require.True(t, swept, "the sweep missed the package that drives the resolver, so a clean result proves nothing") - for _, finding := range baselineFindings(resolutionBaseline, uses, repoRoot(t)) { + for _, finding := range baselineFindings(resolutionBaseline, uses, irtest.RepoRoot(t)) { t.Error(finding) } } @@ -848,7 +849,7 @@ func TestBaselineFindings_EveryDifferenceFails(t *testing.T) { // with src added to it as the file named file, and returns it. func loadOverlaid(t *testing.T, dir, file, src string) *decls.Package { t.Helper() - root := repoRoot(t) + root := irtest.RepoRoot(t) planted := filepath.Join(root, filepath.FromSlash(dir), file) cfg := &packages.Config{ Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, diff --git a/internal/archtest/suppression_test.go b/internal/archtest/suppression_test.go index ebd352f7..272d5948 100644 --- a/internal/archtest/suppression_test.go +++ b/internal/archtest/suppression_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" ) // suppressionDirectives are the comment forms that silence a linter from inside @@ -57,7 +59,7 @@ func suppressionOf(text string) (string, bool) { // with it, is not one. func TestSuppressions_NoDirectiveStandsInTheSource(t *testing.T) { t.Parallel() - violations, files, err := suppressionViolations(repoRoot(t)) + violations, files, err := suppressionViolations(irtest.RepoRoot(t)) require.NoError(t, err) require.Positive(t, files, "the walk read no Go file, so it checked nothing") for _, v := range violations { diff --git a/internal/archtest/testnames_test.go b/internal/archtest/testnames_test.go index 7642d56d..2f04dd74 100644 --- a/internal/archtest/testnames_test.go +++ b/internal/archtest/testnames_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/internal/keydoc" + "github.com/dexpace/morphic/ir/irtest" ) // testNameSpan is a span of code that is a test's name alone, optionally @@ -38,7 +39,7 @@ var testNamePlaceholders = map[string]string{ // without them sends the reader nowhere. func TestDocuments_NameOnlyTestsThatExist(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) declared, err := declaredTests(root) require.NoError(t, err) require.NotEmpty(t, declared, "found no test, so this proves nothing") diff --git a/ir/irtest/golden.go b/ir/irtest/golden.go index a9d11e0f..0aea04e9 100644 --- a/ir/irtest/golden.go +++ b/ir/irtest/golden.go @@ -1,5 +1,6 @@ // Package irtest provides test helpers for IR documents: golden snapshots, the -// namespaces a document's IDs live in, and values with every field filled. +// namespaces a document's IDs live in, values with every field filled, a +// document nested to a chosen depth, and the repository root fixtures sit in. // // A golden stores a document without its irVersion. The stamp names the shape // of the JSON, which a test in package ir pins against that shape, so storing diff --git a/ir/irtest/lineendings_test.go b/ir/irtest/lineendings_test.go index 9226c9d4..30f46c17 100644 --- a/ir/irtest/lineendings_test.go +++ b/ir/irtest/lineendings_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" ) // eolProbes are paths whose line endings the golden comparison depends on: an IR @@ -33,7 +35,7 @@ var eolProbes = []string{ // (GitHub #48). func TestGitAttributes_PinsLineEndings(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) raw, err := os.ReadFile(filepath.Join(root, ".gitattributes")) require.NoError(t, err, ".gitattributes must exist at the repository root") @@ -55,7 +57,7 @@ func TestGitAttributes_PinsLineEndings(t *testing.T) { // it is written. func TestGitAttributes_ResolveToLF(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) for _, rel := range eolProbes { require.FileExists(t, filepath.Join(root, rel)) } @@ -86,7 +88,7 @@ func TestGitAttributes_ResolveToLF(t *testing.T) { // runner — the same shape as the pin it accompanies. func TestTrackedFiles_HoldNoCarriageReturn(t *testing.T) { t.Parallel() - root := repoRoot(t) + root := irtest.RepoRoot(t) cmd := exec.Command("git", "ls-files", "-z") cmd.Dir = root out, err := cmd.Output() @@ -110,21 +112,3 @@ func TestTrackedFiles_HoldNoCarriageReturn(t *testing.T) { "these tracked files hold CR bytes; run `git add --renormalize .` to bring the checkout "+ "back to LF, or mark a file that needs them `-text` in .gitattributes") } - -// repoRoot walks up from the working directory, which go test sets to this -// package's own, to the directory holding go.mod. It does not start from this -// file's path: under -trimpath, runtime.Caller reports the file under its -// import path, not on disk. -func repoRoot(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - require.NoError(t, err) - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - require.NotEqual(t, parent, dir, "reached filesystem root without finding go.mod") - dir = parent - } -} diff --git a/ir/irtest/nesting.go b/ir/irtest/nesting.go new file mode 100644 index 00000000..7aa1229e --- /dev/null +++ b/ir/irtest/nesting.go @@ -0,0 +1,22 @@ +package irtest + +import "github.com/dexpace/morphic/ir" + +// NestedOverloadDoc returns a document whose first group holds an operation +// overloading one declared inside depth levels of nested groups, plus the ID +// they agree on. At a depth past ir.MaxWalkDepth a walk reaches the reference +// and not the declaration; below it, one walk reaches both. +func NestedOverloadDoc(depth int) (*ir.Document, ir.OpID) { + target := ir.OpID("op/x/Nested") + nested := ir.OperationGroup{Operations: []ir.Operation{{ID: target}}} + for range depth { + nested = ir.OperationGroup{Groups: []ir.OperationGroup{nested}} + } + return &ir.Document{Services: []ir.Service{{ + ID: "s/x", + Groups: []ir.OperationGroup{ + {Operations: []ir.Operation{{ID: "op/x/Overload", OverloadOf: &target}}}, + nested, + }, + }}}, target +} diff --git a/ir/irtest/nesting_test.go b/ir/irtest/nesting_test.go new file mode 100644 index 00000000..af7d954f --- /dev/null +++ b/ir/irtest/nesting_test.go @@ -0,0 +1,31 @@ +package irtest_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" +) + +// TestNestedOverloadDoc_NestsTheDeclarationAsDeepAsAsked pins the fixture's +// shape: the overload sits in the first group, and its target is depth groups +// down the second. +func TestNestedOverloadDoc_NestsTheDeclarationAsDeepAsAsked(t *testing.T) { + t.Parallel() + doc, target := irtest.NestedOverloadDoc(3) + require.Len(t, doc.Services, 1) + groups := doc.Services[0].Groups + require.Len(t, groups, 2) + require.Len(t, groups[0].Operations, 1) + assert.Equal(t, &target, groups[0].Operations[0].OverloadOf) + + g := groups[1] + for range 3 { + require.Len(t, g.Groups, 1) + g = g.Groups[0] + } + require.Len(t, g.Operations, 1) + assert.Equal(t, target, g.Operations[0].ID) +} diff --git a/ir/irtest/repo.go b/ir/irtest/repo.go new file mode 100644 index 00000000..c5868d6a --- /dev/null +++ b/ir/irtest/repo.go @@ -0,0 +1,40 @@ +package irtest + +import ( + "os" + "path/filepath" + "testing" +) + +// RepoRoot returns the directory holding the module's go.mod, walking up from +// the working directory, which go test sets to the package under test's own. +// It does not start from a source file's path: under -trimpath, +// runtime.Caller reports a file under its import path, not on disk. +func RepoRoot(t *testing.T) string { + t.Helper() + return rootFrom(t, os.Getwd) +} + +// rootFrom is RepoRoot with the working directory's lookup injected, so a test +// can drive both of its failures. +func rootFrom(t testingT, getwd func() (string, error)) string { + t.Helper() + dir, err := getwd() + if err != nil { + t.Fatalf("irtest: reading the working directory: %v", err) + } + for !holdsGoMod(dir) { + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("irtest: no go.mod in or above the working directory") + } + dir = parent + } + return dir +} + +// holdsGoMod reports whether dir has a go.mod of its own. +func holdsGoMod(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "go.mod")) + return err == nil +} diff --git a/ir/irtest/repo_internal_test.go b/ir/irtest/repo_internal_test.go new file mode 100644 index 00000000..30a7e756 --- /dev/null +++ b/ir/irtest/repo_internal_test.go @@ -0,0 +1,43 @@ +package irtest + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +// runRootFrom drives rootFrom in a goroutine so a Goexit from Fatalf unwinds +// only that goroutine, then returns the recorder and what rootFrom returned. +func runRootFrom(getwd func() (string, error)) (*recordingT, string) { + rec := &recordingT{} + var got string + done := make(chan struct{}) + go func() { + defer close(done) + got = rootFrom(rec, getwd) + }() + <-done + return rec, got +} + +// TestRootFrom_RefusesAWorkingDirectoryItCannotRead pins the first failure: +// the walk has nowhere to start, so the test stops rather than guessing one. +func TestRootFrom_RefusesAWorkingDirectoryItCannotRead(t *testing.T) { + t.Parallel() + rec, got := runRootFrom(func() (string, error) { return "", errors.New("gone") }) + assert.True(t, rec.aborted) + assert.Contains(t, rec.fatalf, "reading the working directory: gone") + assert.Empty(t, got) +} + +// TestRootFrom_RefusesADirectoryOutsideAnyModule pins the second: a walk that +// reaches the filesystem root without a go.mod stops there. +func TestRootFrom_RefusesADirectoryOutsideAnyModule(t *testing.T) { + t.Parallel() + outside := t.TempDir() + rec, got := runRootFrom(func() (string, error) { return outside, nil }) + assert.True(t, rec.aborted) + assert.Contains(t, rec.fatalf, "no go.mod in or above the working directory") + assert.Empty(t, got) +} diff --git a/ir/irtest/repo_test.go b/ir/irtest/repo_test.go new file mode 100644 index 00000000..42711eaa --- /dev/null +++ b/ir/irtest/repo_test.go @@ -0,0 +1,25 @@ +package irtest_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir/irtest" +) + +// TestRepoRoot_FindsTheModuleAboveTheWorkingDirectory pins the walk on the +// real tree: the root holds go.mod, and the working directory lies under it. +func TestRepoRoot_FindsTheModuleAboveTheWorkingDirectory(t *testing.T) { + t.Parallel() + root := irtest.RepoRoot(t) + _, err := os.Stat(filepath.Join(root, "go.mod")) + require.NoError(t, err) + wd, err := os.Getwd() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(wd, root), "%s lies under %s", wd, root) +} diff --git a/ir/irverify/refs_test.go b/ir/irverify/refs_test.go index f0e2c9d3..182faf9d 100644 --- a/ir/irverify/refs_test.go +++ b/ir/irverify/refs_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // docWithRef builds a model whose property points at target via a TypeRef. @@ -115,25 +116,6 @@ func TestCheckReferentialIntegrity_DanglingChannelRef(t *testing.T) { assert.Contains(t, got[0].Message, "does not resolve in channels") } -// opDocNested returns a document whose first group holds an operation overloading -// one declared inside depth levels of nested groups, plus the ID they agree on. -// At a depth past ir.MaxWalkDepth the walk reaches the reference and not the -// declaration; below it, one walk reaches both. -func opDocNested(depth int) (*ir.Document, ir.OpID) { - target := ir.OpID("op/x/Nested") - nested := ir.OperationGroup{Operations: []ir.Operation{{ID: target}}} - for range depth { - nested = ir.OperationGroup{Groups: []ir.OperationGroup{nested}} - } - return &ir.Document{Services: []ir.Service{{ - ID: "s/x", - Groups: []ir.OperationGroup{ - {Operations: []ir.Operation{{ID: "op/x/Overload", OverloadOf: &target}}}, - nested, - }, - }}}, target -} - // docWithOverload builds a service whose one operation overloads target. func docWithOverload(target ir.OpID) *ir.Document { return &ir.Document{Services: []ir.Service{{ @@ -210,14 +192,14 @@ func TestCheckReferentialIntegrity_DanglingOpRefWithNoOperationDeclared(t *testi // registries Document keys maps by are unaffected: they are read off the field, // not off the walk. func TestCheckReferentialIntegrity_TruncatedWalkClaimsNoDeclarations(t *testing.T) { - deep, target := opDocNested(ir.MaxWalkDepth) + deep, target := irtest.NestedOverloadDoc(ir.MaxWalkDepth) vs, truncated := checkReferentialIntegrity(deep, readDeclarations(deep)) assert.True(t, truncated, "the walk must report that it was cut short") assert.Empty(t, vs, "%s is declared past the cap, not undeclared", target) // The other half: the same shape within the cap does resolve, so the case // above is not passing on a walk that collects no reference at all. - shallow, _ := opDocNested(0) + shallow, _ := irtest.NestedOverloadDoc(0) assert.Empty(t, refViolations(shallow)) } diff --git a/pass/validate_refs_internal_test.go b/pass/validate_refs_internal_test.go index d5cf585c..4e0632c9 100644 --- a/pass/validate_refs_internal_test.go +++ b/pass/validate_refs_internal_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // TestCollectTypeIDs_DeepValueTreeIsTruncated drives what this pass does with a @@ -54,7 +55,7 @@ func TestCollectTypeIDs_UnmodeledBytesYieldNoReference(t *testing.T) { // keys maps by are read off the field rather than off the walk and are unaffected. func TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations(t *testing.T) { t.Parallel() - deep, target := opDocNested(ir.MaxWalkDepth) + deep, target := irtest.NestedOverloadDoc(ir.MaxWalkDepth) diags := danglingRefs(deep) require.NotEmpty(t, diags) assert.Equal(t, "ir/walk-truncated", diags[0].Code) @@ -65,29 +66,10 @@ func TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations(t *testing.T) { // The other half: the same shape within the cap resolves, so the case above // is not passing on a walk that collects no reference at all. - shallow, _ := opDocNested(0) + shallow, _ := irtest.NestedOverloadDoc(0) assert.Empty(t, danglingRefs(shallow)) } -// opDocNested returns a document whose first group holds an operation overloading -// one declared inside depth levels of nested groups, plus the ID they agree on. -// At a depth past ir.MaxWalkDepth the walk reaches the reference and not the -// declaration; below it, one walk reaches both. -func opDocNested(depth int) (*ir.Document, ir.OpID) { - target := ir.OpID("op/x/Nested") - nested := ir.OperationGroup{Operations: []ir.Operation{{ID: target}}} - for range depth { - nested = ir.OperationGroup{Groups: []ir.OperationGroup{nested}} - } - return &ir.Document{Services: []ir.Service{{ - ID: "s/x", - Groups: []ir.OperationGroup{ - {Operations: []ir.Operation{{ID: "op/x/Overload", OverloadOf: &target}}}, - nested, - }, - }}}, target -} - // TestCheckDanglingRefs_NilTypeDefIsNotFollowed pins report-only behaviour on // a malformed registry: a nil entry is skipped rather than dereferenced, so the // pass reports what it can instead of panicking. From 1f7f26841158d4941327f4da60d4e869aef4df52 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 02:43:20 +0300 Subject: [PATCH 02/18] test: reach package internals through export_test.go Fifty-one source files had two test files, x_test.go outside the package and x_internal_test.go inside it, because one file can hold only one package clause. Where the inside half reached a few unexported functions, constants or types, it needed only to see them. In thirteen packages those files are now external tests, merged into the x_test.go beside them or renamed to it, and reach what they need through the package's export_test.go, which names each alias once: twenty files in all. The tests are the same code, qualified by their package; where an alias's exported name is taken, it says what it adds (irtest's CompareGoldenOn and FillOn take the stub testingT, atomicfile's ReplaceWithChmod the injected chmod). auth's nil-requirement test is the one rewritten rather than moved: it reached lowerSecurityRequirement to hand it a nil entry, and LowerSecurityRequirements, which callers use, takes the same entry through its list. Both catch keeping the entry as an empty option and reporting it. A file stays inside its package where it needs an unexported field or method, a generic or a value variable, which no alias carries, or a file that does, and in the six packages whose external tests would need a second API's worth of aliases: schema, operation, bind, irverify, harness and overlay. --- compilers/openapi/detect_internal_test.go | 34 --- compilers/openapi/detect_test.go | 25 ++ compilers/openapi/export_test.go | 16 ++ .../openapi/internal/auth/export_test.go | 4 + .../auth/requirements_internal_test.go | 24 -- .../internal/auth/requirements_test.go | 14 + .../internal/auth/schemes_internal_test.go | 54 ---- .../openapi/internal/auth/schemes_test.go | 44 +++ .../openapi/internal/document/export_test.go | 10 + .../document/servers_internal_test.go | 97 ------- .../openapi/internal/document/servers_test.go | 88 ++++++ .../internal/navigation/export_test.go | 3 + .../navigation/navigation_internal_test.go | 29 -- .../internal/navigation/navigation_test.go | 20 ++ ...ters_internal_test.go => pointers_test.go} | 21 +- .../internal/resolve/refsite_internal_test.go | 63 ----- .../openapi/internal/resolve/refsite_test.go | 54 ++++ compilers/openapi/openapi_internal_test.go | 142 ---------- compilers/openapi/openapi_test.go | 126 +++++++++ compilers/openapi/options_test.go | 43 +-- .../{chmod_internal_test.go => chmod_test.go} | 8 +- engine/internal/atomicfile/export_test.go | 4 + ...ternal_test.go => disk_readopened_test.go} | 19 +- engine/reader/export_test.go | 6 + ...ernal_test.go => location_fileuri_test.go} | 6 +- internal/decls/decls_internal_test.go | 25 -- internal/decls/decls_test.go | 17 ++ internal/decls/export_test.go | 4 + internal/jsonshape/export_test.go | 7 + ...ernal_test.go => jsonshape_bounds_test.go} | 21 +- ...aml_internal_test.go => cite_yaml_test.go} | 6 +- internal/keydoc/export_test.go | 4 + internal/leakcheck/export_test.go | 19 ++ ...eck_internal_test.go => leakcheck_test.go} | 26 +- ir/irtest/export_test.go | 13 + ir/irtest/golden_internal_test.go | 250 ------------------ ir/irtest/golden_test.go | 238 +++++++++++++++++ ir/irtest/main_test.go | 2 +- ir/irtest/repo_internal_test.go | 43 --- ir/irtest/repo_test.go | 36 +++ pass/export_test.go | 7 + pass/validate_refs_internal_test.go | 87 ------ pass/validate_refs_test.go | 77 ++++++ 43 files changed, 918 insertions(+), 918 deletions(-) delete mode 100644 compilers/openapi/detect_internal_test.go create mode 100644 compilers/openapi/export_test.go create mode 100644 compilers/openapi/internal/auth/export_test.go delete mode 100644 compilers/openapi/internal/auth/requirements_internal_test.go delete mode 100644 compilers/openapi/internal/auth/schemes_internal_test.go create mode 100644 compilers/openapi/internal/document/export_test.go delete mode 100644 compilers/openapi/internal/document/servers_internal_test.go delete mode 100644 compilers/openapi/internal/navigation/navigation_internal_test.go rename compilers/openapi/internal/resolve/{pointers_internal_test.go => pointers_test.go} (93%) delete mode 100644 compilers/openapi/internal/resolve/refsite_internal_test.go delete mode 100644 compilers/openapi/openapi_internal_test.go rename engine/internal/atomicfile/{chmod_internal_test.go => chmod_test.go} (85%) create mode 100644 engine/internal/atomicfile/export_test.go rename engine/reader/{disk_readopened_internal_test.go => disk_readopened_test.go} (91%) rename engine/reader/{location_fileuri_internal_test.go => location_fileuri_test.go} (97%) delete mode 100644 internal/decls/decls_internal_test.go create mode 100644 internal/decls/export_test.go create mode 100644 internal/jsonshape/export_test.go rename internal/jsonshape/{jsonshape_bounds_internal_test.go => jsonshape_bounds_test.go} (77%) rename internal/keydoc/{cite_yaml_internal_test.go => cite_yaml_test.go} (90%) create mode 100644 internal/keydoc/export_test.go create mode 100644 internal/leakcheck/export_test.go rename internal/leakcheck/{leakcheck_internal_test.go => leakcheck_test.go} (79%) create mode 100644 ir/irtest/export_test.go delete mode 100644 ir/irtest/golden_internal_test.go delete mode 100644 ir/irtest/repo_internal_test.go create mode 100644 pass/export_test.go delete mode 100644 pass/validate_refs_internal_test.go diff --git a/compilers/openapi/detect_internal_test.go b/compilers/openapi/detect_internal_test.go deleted file mode 100644 index fba0ebe2..00000000 --- a/compilers/openapi/detect_internal_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package openapi - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/dexpace/morphic/compilers" -) - -// TestDetectionBudget_ReadsTheCallersOptions pins the budget detection takes -// from each kind of options, another compiler's included, which mean this -// one's default as compilers.Compiler.Detect requires rather than no budget. -// Detect's own tests exceed only a budget the caller sets: a source past the -// default costs more than the gate's race run should pay, so another -// compiler's options read as no budget would pass them all. -func TestDetectionBudget_ReadsTheCallersOptions(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - opts compilers.Options - want int - }{ - {"no options", compilers.Options{}, DefaultMaxSourceBytes}, - {"another compiler's options", compilers.Options{FormatOptions: "another compiler's options"}, DefaultMaxSourceBytes}, - {"a budget of its own", compilers.Options{FormatOptions: Options{Limits: Limits{MaxSourceBytes: 7}}}, 7}, - {"the budget turned off", compilers.Options{FormatOptions: Options{Limits: Limits{MaxSourceBytes: -1}}}, 0}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, detectionBudget(tc.opts)) - }) - } -} diff --git a/compilers/openapi/detect_test.go b/compilers/openapi/detect_test.go index d81079fe..529e90d8 100644 --- a/compilers/openapi/detect_test.go +++ b/compilers/openapi/detect_test.go @@ -100,3 +100,28 @@ func TestDetect_ReadsTheVersionTheCompileReads(t *testing.T) { }) } } + +// TestDetectionBudget_ReadsTheCallersOptions pins the budget detection takes +// from each kind of options, another compiler's included, which mean this +// one's default as compilers.Compiler.Detect requires rather than no budget. +// Detect's own tests exceed only a budget the caller sets: a source past the +// default costs more than the gate's race run should pay, so another +// compiler's options read as no budget would pass them all. +func TestDetectionBudget_ReadsTheCallersOptions(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + opts compilers.Options + want int + }{ + {"no options", compilers.Options{}, openapi.DefaultMaxSourceBytes}, + {"another compiler's options", compilers.Options{FormatOptions: "another compiler's options"}, openapi.DefaultMaxSourceBytes}, + {"a budget of its own", compilers.Options{FormatOptions: openapi.Options{Limits: openapi.Limits{MaxSourceBytes: 7}}}, 7}, + {"the budget turned off", compilers.Options{FormatOptions: openapi.Options{Limits: openapi.Limits{MaxSourceBytes: -1}}}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, openapi.DetectionBudget(tc.opts)) + }) + } +} diff --git a/compilers/openapi/export_test.go b/compilers/openapi/export_test.go new file mode 100644 index 00000000..b06f4cc4 --- /dev/null +++ b/compilers/openapi/export_test.go @@ -0,0 +1,16 @@ +package openapi + +// DetectionBudget is detectionBudget, for the external tests. +var DetectionBudget = detectionBudget + +// LimitOptions is limitOptions, for the external tests. +var LimitOptions = limitOptions + +// RootSrcIndex is rootSrcIndex, for the external tests. +const RootSrcIndex = rootSrcIndex + +// Run is run, for the external tests. +var Run = run + +// WithoutRereported is withoutRereported, for the external tests. +var WithoutRereported = withoutRereported diff --git a/compilers/openapi/internal/auth/export_test.go b/compilers/openapi/internal/auth/export_test.go new file mode 100644 index 00000000..00e65e87 --- /dev/null +++ b/compilers/openapi/internal/auth/export_test.go @@ -0,0 +1,4 @@ +package auth + +// MechanismFieldNames is mechanismFieldNames, for the external tests. +var MechanismFieldNames = mechanismFieldNames diff --git a/compilers/openapi/internal/auth/requirements_internal_test.go b/compilers/openapi/internal/auth/requirements_internal_test.go deleted file mode 100644 index d177d47b..00000000 --- a/compilers/openapi/internal/auth/requirements_internal_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package auth - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/dexpace/morphic/compilers/openapi/internal/lowering" -) - -// TestLowerSecurityRequirement_Nil covers the half of the object guard a parsed -// document cannot reach: a hand-built slice holding a nil entry. It is refused -// like any other entry that is no object, rather than surviving as the empty -// option that reads as "no auth is one acceptable choice" — and silently: an -// entry that is no object is the loader's to report, and a nil one is in no -// document at all. -func TestLowerSecurityRequirement_Nil(t *testing.T) { - t.Parallel() - got, ok, diags := lowerSecurityRequirement(lowering.Ctx{}, nil, "/security/0") - - assert.Empty(t, got.Schemes) - assert.False(t, ok, "a nil entry is no requirement, so the caller drops it") - assert.Empty(t, diags) -} diff --git a/compilers/openapi/internal/auth/requirements_test.go b/compilers/openapi/internal/auth/requirements_test.go index 7d6da010..a98b1110 100644 --- a/compilers/openapi/internal/auth/requirements_test.go +++ b/compilers/openapi/internal/auth/requirements_test.go @@ -454,3 +454,17 @@ func loaderErrors(diags []ir.Diagnostic) []string { } return out } + +// TestLowerSecurityRequirements_DropsANilEntrySilently covers the half of the +// object guard a parsed document cannot reach: a hand-built list holding a nil +// entry. It is dropped like any other entry that is no object, rather than +// surviving as the empty option that reads as "no auth is one acceptable +// choice", and silently: an entry that is no object is the loader's to report, +// and a nil one is in no document at all. +func TestLowerSecurityRequirements_DropsANilEntrySilently(t *testing.T) { + t.Parallel() + got, diags := auth.LowerSecurityRequirements(lowering.Ctx{}, []*soa.SecurityRequirement{nil}, "/security") + + assert.Empty(t, got, "a nil entry is no requirement, so it is dropped") + assert.Empty(t, diags) +} diff --git a/compilers/openapi/internal/auth/schemes_internal_test.go b/compilers/openapi/internal/auth/schemes_internal_test.go deleted file mode 100644 index 5b3cfe7c..00000000 --- a/compilers/openapi/internal/auth/schemes_internal_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package auth - -import ( - "reflect" - "slices" - "testing" - - soa "github.com/speakeasy-api/openapi/openapi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestMechanismFieldNames_AccountForEverySourceField holds mechanismFieldNames -// to the source model it lists, so a field the upstream model gains fails here -// rather than being dropped from the IR without trace, which is what #294 was. -// -// Reading the source struct is what makes the comparison possible: a -// hand-written list checked against another would agree with itself forever. -// The wire names are spelled out rather than derived, since they differ by more -// than leading case: OAuth2MetadataUrl is oauth2MetadataUrl. -func TestMechanismFieldNames_AccountForEverySourceField(t *testing.T) { - t.Parallel() - // alwaysRead are the fields lowerSecurityScheme reads whatever the type is, - // so no type can leave one unread and none needs preserving. - alwaysRead := map[string]bool{ - "Type": true, "Description": true, "Deprecated": true, "Extensions": true, - } - // perType are the fields only some types define, keyed by Go field name and - // valued by the wire name mechanismFieldNames must list. - perType := map[string]string{ - "Name": "name", "In": "in", "Scheme": "scheme", "BearerFormat": "bearerFormat", - "Flows": "flows", "OpenIdConnectUrl": "openIdConnectUrl", - "OAuth2MetadataUrl": "oauth2MetadataUrl", - } - - var want []string - st := reflect.TypeFor[soa.SecurityScheme]() - for f := range st.Fields() { - if f.Anonymous || !f.IsExported() { - continue // the embedded marshaller model is not a document field - } - wire, perTypeField := perType[f.Name] - require.True(t, alwaysRead[f.Name] || perTypeField, - "securityScheme field %q is neither read for every type nor listed as a mechanism "+ - "field, so a document declaring it loses it: add it to one or the other", f.Name) - if perTypeField { - want = append(want, wire) - } - } - - slices.Sort(want) - assert.Equal(t, want, mechanismFieldNames(), - "mechanismFieldNames must list every per-type field, sorted") -} diff --git a/compilers/openapi/internal/auth/schemes_test.go b/compilers/openapi/internal/auth/schemes_test.go index 7cca7688..18854acd 100644 --- a/compilers/openapi/internal/auth/schemes_test.go +++ b/compilers/openapi/internal/auth/schemes_test.go @@ -2,6 +2,7 @@ package auth_test import ( "encoding/json/jsontext" + "reflect" "slices" "strings" "testing" @@ -483,3 +484,46 @@ components: require.True(t, ok) assert.Empty(t, scheme.Unmodeled, "the unserializable extension is dropped, not stored") } + +// TestMechanismFieldNames_AccountForEverySourceField holds mechanismFieldNames +// to the source model it lists, so a field the upstream model gains fails here +// rather than being dropped from the IR without trace, which is what #294 was. +// +// Reading the source struct is what makes the comparison possible: a +// hand-written list checked against another would agree with itself forever. +// The wire names are spelled out rather than derived, since they differ by more +// than leading case: OAuth2MetadataUrl is oauth2MetadataUrl. +func TestMechanismFieldNames_AccountForEverySourceField(t *testing.T) { + t.Parallel() + // alwaysRead are the fields lowerSecurityScheme reads whatever the type is, + // so no type can leave one unread and none needs preserving. + alwaysRead := map[string]bool{ + "Type": true, "Description": true, "Deprecated": true, "Extensions": true, + } + // perType are the fields only some types define, keyed by Go field name and + // valued by the wire name mechanismFieldNames must list. + perType := map[string]string{ + "Name": "name", "In": "in", "Scheme": "scheme", "BearerFormat": "bearerFormat", + "Flows": "flows", "OpenIdConnectUrl": "openIdConnectUrl", + "OAuth2MetadataUrl": "oauth2MetadataUrl", + } + + var want []string + st := reflect.TypeFor[soa.SecurityScheme]() + for f := range st.Fields() { + if f.Anonymous || !f.IsExported() { + continue // the embedded marshaller model is not a document field + } + wire, perTypeField := perType[f.Name] + require.True(t, alwaysRead[f.Name] || perTypeField, + "securityScheme field %q is neither read for every type nor listed as a mechanism "+ + "field, so a document declaring it loses it: add it to one or the other", f.Name) + if perTypeField { + want = append(want, wire) + } + } + + slices.Sort(want) + assert.Equal(t, want, auth.MechanismFieldNames(), + "mechanismFieldNames must list every per-type field, sorted") +} diff --git a/compilers/openapi/internal/document/export_test.go b/compilers/openapi/internal/document/export_test.go new file mode 100644 index 00000000..46184bba --- /dev/null +++ b/compilers/openapi/internal/document/export_test.go @@ -0,0 +1,10 @@ +package document + +// LowerServer is lowerServer, for the external tests. +var LowerServer = lowerServer + +// LowerServers is lowerServers, for the external tests. +var LowerServers = lowerServers + +// ServerName is serverName, for the external tests. +var ServerName = serverName diff --git a/compilers/openapi/internal/document/servers_internal_test.go b/compilers/openapi/internal/document/servers_internal_test.go deleted file mode 100644 index 7149d198..00000000 --- a/compilers/openapi/internal/document/servers_internal_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package document - -import ( - "testing" - - soa "github.com/speakeasy-api/openapi/openapi" - "github.com/speakeasy-api/openapi/sequencedmap" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/compilers/openapi/internal/lowering" - "github.com/dexpace/morphic/ir" -) - -// TestServerName_DerivedFromURLWhenUnnamed pins how a server is named when the -// source declares no name for it, which before OpenAPI 3.2 it has no way to do. -// Every such server used to reach the IR with all three name channels empty, -// leaving an emitter that renders one client per server nothing to name them by -// (GitHub #258). -func TestServerName_DerivedFromURLWhenUnnamed(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - url string - want ir.Naming - }{ - {"absolute url with a template variable", "https://{env}.example.com/v1", - ir.Naming{Hint: "https_env_example_com_v_1"}}, - {"relative url", "/v2", ir.Naming{Hint: "v_2"}}, - {"root url has no word in it", "/", ir.Naming{Hint: "server"}}, - {"no url at all", "", ir.Naming{Hint: "server"}}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, serverName(&soa.Server{URL: tc.url})) - }) - } -} - -// TestServerName_DistinguishesServersDifferingOnlyInPath is why the hint is the -// whole URL template and not the host inside it: one host serving two API -// versions is an ordinary OpenAPI document, and a host-derived hint would name -// both servers the same. -func TestServerName_DistinguishesServersDifferingOnlyInPath(t *testing.T) { - t.Parallel() - v1 := serverName(&soa.Server{URL: "https://api.example.com/v1"}) - v2 := serverName(&soa.Server{URL: "https://api.example.com/v2"}) - assert.NotEqual(t, v1.Hint, v2.Hint, "two distinct servers get two distinct hints") -} - -// TestServerName_CollidesOnPunctuationAlone bounds that claim, which must not be -// read as "distinct servers get distinct hints": canonicalizing drops every -// non-word character, so two URLs differing only in punctuation reduce to one -// word sequence. Uniquifying it is an emitter's job, and pinning it keeps that a -// known bound rather than a later discovery. -func TestServerName_CollidesOnPunctuationAlone(t *testing.T) { - t.Parallel() - dotted := serverName(&soa.Server{URL: "https://api.example.com/v1"}) - dashed := serverName(&soa.Server{URL: "https://api.example.com/v-1"}) - assert.Equal(t, dotted.Hint, dashed.Hint, - "neutral words carry no punctuation, so these two collide") -} - -func TestLowerServers_NilEntrySkipped(t *testing.T) { - t.Parallel() - doc := &soa.OpenAPI{Servers: []*soa.Server{nil, {URL: "https://x.example.com"}}} - got, diags := lowerServers(lowering.Ctx{Doc: doc}) - - assert.Empty(t, diags) - require.Len(t, got, 1, "nil server entry skipped, valid one lowered") - assert.Equal(t, "https://x.example.com", got[0].URLTemplate) -} - -func TestServerVariables_NilEntrySkipped(t *testing.T) { - t.Parallel() - vars := sequencedmap.New( - sequencedmap.NewElem("skip", (*soa.ServerVariable)(nil)), - sequencedmap.NewElem("keep", &soa.ServerVariable{}), - ) - srv, diags := lowerServer(lowering.Ctx{}, &soa.Server{URL: "https://x", Variables: vars}, "/servers/0") - assert.Empty(t, diags) - require.Len(t, srv.Variables, 1, "nil variable entry skipped") - assert.Equal(t, "keep", srv.Variables[0].Name) -} - -// TestLowerServers_EveryEntrySkippedIsNil pins, for the servers, the guard -// auth's TestLowerSecuritySchemes_NothingLoweredIsNilNotEmpty pins for the -// schemes: when no entry survives, the field stays unset rather than becoming -// an empty list the source never declared. -func TestLowerServers_EveryEntrySkippedIsNil(t *testing.T) { - t.Parallel() - doc := &soa.OpenAPI{Servers: []*soa.Server{nil, nil}} - - got, diags := lowerServers(lowering.Ctx{Doc: doc}) - assert.Nil(t, got) - assert.Empty(t, diags) -} diff --git a/compilers/openapi/internal/document/servers_test.go b/compilers/openapi/internal/document/servers_test.go index a8b4fa8d..6b414e7b 100644 --- a/compilers/openapi/internal/document/servers_test.go +++ b/compilers/openapi/internal/document/servers_test.go @@ -3,10 +3,14 @@ package document_test import ( "testing" + soa "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/sequencedmap" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/document" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" ) @@ -118,3 +122,87 @@ paths: {} }) } } + +// TestServerName_DerivedFromURLWhenUnnamed pins how a server is named when the +// source declares no name for it, which before OpenAPI 3.2 it has no way to do. +// Every such server used to reach the IR with all three name channels empty, +// leaving an emitter that renders one client per server nothing to name them by +// (GitHub #258). +func TestServerName_DerivedFromURLWhenUnnamed(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + url string + want ir.Naming + }{ + {"absolute url with a template variable", "https://{env}.example.com/v1", + ir.Naming{Hint: "https_env_example_com_v_1"}}, + {"relative url", "/v2", ir.Naming{Hint: "v_2"}}, + {"root url has no word in it", "/", ir.Naming{Hint: "server"}}, + {"no url at all", "", ir.Naming{Hint: "server"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, document.ServerName(&soa.Server{URL: tc.url})) + }) + } +} + +// TestServerName_DistinguishesServersDifferingOnlyInPath is why the hint is the +// whole URL template and not the host inside it: one host serving two API +// versions is an ordinary OpenAPI document, and a host-derived hint would name +// both servers the same. +func TestServerName_DistinguishesServersDifferingOnlyInPath(t *testing.T) { + t.Parallel() + v1 := document.ServerName(&soa.Server{URL: "https://api.example.com/v1"}) + v2 := document.ServerName(&soa.Server{URL: "https://api.example.com/v2"}) + assert.NotEqual(t, v1.Hint, v2.Hint, "two distinct servers get two distinct hints") +} + +// TestServerName_CollidesOnPunctuationAlone bounds that claim, which must not be +// read as "distinct servers get distinct hints": canonicalizing drops every +// non-word character, so two URLs differing only in punctuation reduce to one +// word sequence. Uniquifying it is an emitter's job, and pinning it keeps that a +// known bound rather than a later discovery. +func TestServerName_CollidesOnPunctuationAlone(t *testing.T) { + t.Parallel() + dotted := document.ServerName(&soa.Server{URL: "https://api.example.com/v1"}) + dashed := document.ServerName(&soa.Server{URL: "https://api.example.com/v-1"}) + assert.Equal(t, dotted.Hint, dashed.Hint, + "neutral words carry no punctuation, so these two collide") +} + +func TestLowerServers_NilEntrySkipped(t *testing.T) { + t.Parallel() + doc := &soa.OpenAPI{Servers: []*soa.Server{nil, {URL: "https://x.example.com"}}} + got, diags := document.LowerServers(lowering.Ctx{Doc: doc}) + + assert.Empty(t, diags) + require.Len(t, got, 1, "nil server entry skipped, valid one lowered") + assert.Equal(t, "https://x.example.com", got[0].URLTemplate) +} + +func TestServerVariables_NilEntrySkipped(t *testing.T) { + t.Parallel() + vars := sequencedmap.New( + sequencedmap.NewElem("skip", (*soa.ServerVariable)(nil)), + sequencedmap.NewElem("keep", &soa.ServerVariable{}), + ) + srv, diags := document.LowerServer(lowering.Ctx{}, &soa.Server{URL: "https://x", Variables: vars}, "/servers/0") + assert.Empty(t, diags) + require.Len(t, srv.Variables, 1, "nil variable entry skipped") + assert.Equal(t, "keep", srv.Variables[0].Name) +} + +// TestLowerServers_EveryEntrySkippedIsNil pins, for the servers, the guard +// auth's TestLowerSecuritySchemes_NothingLoweredIsNilNotEmpty pins for the +// schemes: when no entry survives, the field stays unset rather than becoming +// an empty list the source never declared. +func TestLowerServers_EveryEntrySkippedIsNil(t *testing.T) { + t.Parallel() + doc := &soa.OpenAPI{Servers: []*soa.Server{nil, nil}} + + got, diags := document.LowerServers(lowering.Ctx{Doc: doc}) + assert.Nil(t, got) + assert.Empty(t, diags) +} diff --git a/compilers/openapi/internal/navigation/export_test.go b/compilers/openapi/internal/navigation/export_test.go index 46b8bb76..5526e89c 100644 --- a/compilers/openapi/internal/navigation/export_test.go +++ b/compilers/openapi/internal/navigation/export_test.go @@ -42,3 +42,6 @@ func Leaves(node any, token string) (*yaml.Node, bool) { r, raw := ReadingOf(node, token) return raw, r == Leaving } + +// Keyed is keyed, for the external tests. +var Keyed = keyed diff --git a/compilers/openapi/internal/navigation/navigation_internal_test.go b/compilers/openapi/internal/navigation/navigation_internal_test.go deleted file mode 100644 index 38305914..00000000 --- a/compilers/openapi/internal/navigation/navigation_internal_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package navigation - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestKeyed_CostsNoAllocation holds keyed, which runs as pointer reads step -// through models, at no allocation. Ranging over reflect.Type.Fields costs it -// four a call, and go fix and modernize both propose exactly that rewrite. -// .golangci.yml declines it for this file; go fix would apply it, and this test -// is what catches that. It is not run in parallel: AllocsPerRun measures the -// process. -func TestKeyed_CostsNoAllocation(t *testing.T) { - type model struct { - Tagged int `key:"tagged"` - Untagged int - } - rt := reflect.TypeFor[model]() - require.True(t, keyed(rt, "tagged", false), "a tagged field answers its tag") - require.True(t, keyed(rt, "Untagged", true), "by name, an untagged field answers its name") - require.False(t, keyed(rt, "Untagged", false), "by tag alone, it answers nothing") - - assert.Zero(t, testing.AllocsPerRun(10, func() { _ = keyed(rt, "absent", true) }), - "reading every field and finding no answer allocates nothing") -} diff --git a/compilers/openapi/internal/navigation/navigation_test.go b/compilers/openapi/internal/navigation/navigation_test.go index 6f064f18..beff6f8d 100644 --- a/compilers/openapi/internal/navigation/navigation_test.go +++ b/compilers/openapi/internal/navigation/navigation_test.go @@ -724,3 +724,23 @@ func (keyedIndexed) NavigateWithIndex(i int) (any, error) { } return map[string]any{"k": 2}, nil } + +// TestKeyed_CostsNoAllocation holds keyed, which runs as pointer reads step +// through models, at no allocation. Ranging over reflect.Type.Fields costs it +// four a call, and go fix and modernize both propose exactly that rewrite. +// .golangci.yml declines it for this file; go fix would apply it, and this test +// is what catches that. It is not run in parallel: AllocsPerRun measures the +// process. +func TestKeyed_CostsNoAllocation(t *testing.T) { + type model struct { + Tagged int `key:"tagged"` + Untagged int + } + rt := reflect.TypeFor[model]() + require.True(t, navigation.Keyed(rt, "tagged", false), "a tagged field answers its tag") + require.True(t, navigation.Keyed(rt, "Untagged", true), "by name, an untagged field answers its name") + require.False(t, navigation.Keyed(rt, "Untagged", false), "by tag alone, it answers nothing") + + assert.Zero(t, testing.AllocsPerRun(10, func() { _ = navigation.Keyed(rt, "absent", true) }), + "reading every field and finding no answer allocates nothing") +} diff --git a/compilers/openapi/internal/resolve/pointers_internal_test.go b/compilers/openapi/internal/resolve/pointers_test.go similarity index 93% rename from compilers/openapi/internal/resolve/pointers_internal_test.go rename to compilers/openapi/internal/resolve/pointers_test.go index 4bb7e57d..3794832f 100644 --- a/compilers/openapi/internal/resolve/pointers_internal_test.go +++ b/compilers/openapi/internal/resolve/pointers_test.go @@ -1,4 +1,4 @@ -package resolve +package resolve_test import ( "encoding/json/jsontext" @@ -8,11 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/compilers/openapi/internal/fragment" + "github.com/dexpace/morphic/compilers/openapi/internal/resolve" ) func TestInternalPointer(t *testing.T) { t.Parallel() - sc := Scope{SelfPath: "m.yaml", Declares: func(string) bool { return false }} + sc := resolve.Scope{SelfPath: "m.yaml", Declares: func(string) bool { return false }} cases := []struct { ref string want jsontext.Pointer @@ -47,7 +48,7 @@ func TestInternalPointer(t *testing.T) { // declared names failed every spec-correct escape (GitHub #40). func TestInternalPointer_AgreesWithTheResolverOnWhatItIsHanded(t *testing.T) { t.Parallel() - sc := Scope{SelfPath: "m.yaml", Declares: func(string) bool { return false }} + sc := resolve.Scope{SelfPath: "m.yaml", Declares: func(string) bool { return false }} tests := []struct { name, ref string want jsontext.Pointer @@ -123,7 +124,7 @@ func TestFragmentPointer_ReadsTheFragmentOfAnyDocument(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, ok := FragmentPointer(tc.ref) + got, ok := resolve.FragmentPointer(tc.ref) assert.Equal(t, tc.wantOK, ok, tc.ref) assert.Equal(t, tc.want, got, tc.ref) }) @@ -168,12 +169,12 @@ func TestInternalPointer_InTheSourcesOwnContent(t *testing.T) { {"dir%/m.yaml", "m.yaml", false}, {"100%.yaml", "100%.yaml", false}, } { - _, ok := Scope{SelfPath: c.self}.InternalPointer(c.doc + "#/components/schemas/A") + _, ok := resolve.Scope{SelfPath: c.self}.InternalPointer(c.doc + "#/components/schemas/A") assert.Equal(t, c.internal, ok, "%q written in %q", c.doc, c.self) } - _, ok := Scope{SelfPath: "dir/m.yaml"}.InternalPointer("#/components/schemas/A") + _, ok := resolve.Scope{SelfPath: "dir/m.yaml"}.InternalPointer("#/components/schemas/A") assert.True(t, ok, "a pointer alone names a position in the source's own content") - _, ok = Scope{}.InternalPointer("#/components/schemas/A") + _, ok = resolve.Scope{}.InternalPointer("#/components/schemas/A") assert.True(t, ok, "whatever the source is called") } @@ -192,8 +193,8 @@ func TestInternalPointer_ReadsOwnContentAsAForeignScopeHeldByTheSource(t *testin for _, self := range selves { for _, doc := range docs { ref := doc + "#/components/schemas/A" - _, own := Scope{SelfPath: self}.InternalPointer(ref) - _, foreign := Scope{SelfPath: self, Foreign: true, Holder: self}.InternalPointer(ref) + _, own := resolve.Scope{SelfPath: self}.InternalPointer(ref) + _, foreign := resolve.Scope{SelfPath: self, Foreign: true, Holder: self}.InternalPointer(ref) assert.Equal(t, foreign, own, "%q written in %q", doc, self) } } @@ -221,7 +222,7 @@ func TestInternalPointer_InAForeignScope(t *testing.T) { {"https://example.com/ext.yaml", "spec.yaml#/components/schemas/A", false}, {"", "spec.yaml#/components/schemas/A", false}, } { - scope := Scope{SelfPath: self, Foreign: true, Holder: c.holder} + scope := resolve.Scope{SelfPath: self, Foreign: true, Holder: c.holder} pointer, ok := scope.InternalPointer(c.ref) assert.Equal(t, c.internal, ok, "%s from %s", c.ref, c.holder) if c.internal { diff --git a/compilers/openapi/internal/resolve/refsite_internal_test.go b/compilers/openapi/internal/resolve/refsite_internal_test.go deleted file mode 100644 index 81907f95..00000000 --- a/compilers/openapi/internal/resolve/refsite_internal_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package resolve - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/dexpace/morphic/compilers/openapi/internal/ids" - "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" -) - -// TestIsRefSite_IncludesTheDegenerateRef pins the deliberately broad test. A -// position carrying a $ref field is $ref-shaped even when the ref is empty: -// callers here ask "is there a $ref-carrying body", not "is there a followable -// reference", and reading the narrower question would treat {$ref: ""} as an -// ordinary schema body. -func TestIsRefSite_IncludesTheDegenerateRef(t *testing.T) { - t.Parallel() - ref := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: '#/$defs/T'}\n") - assert.True(t, IsRefSite(ref, ref.GetSchema())) - - empty := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: ''}\n") - assert.True(t, IsRefSite(empty, empty.GetSchema()), - "a present but empty $ref still carries a Ref field") - - plain := openapitest.SchemaFromYAML(t.Context(), t, "type: string\n") - assert.False(t, IsRefSite(plain, plain.GetSchema())) - assert.False(t, IsRefSite(plain, nil), "a boolean schema carries no body to inspect") -} - -func TestResolveComponentRef(t *testing.T) { - t.Parallel() - sc := Scope{Declares: func(n string) bool { return n == "User" }} - - id, ok, handled := sc.ComponentRef("/components/schemas/User") - assert.True(t, handled) - assert.True(t, ok) - assert.Equal(t, ids.Document{}.NamedType("/components/schemas/User"), id) - - _, ok, handled = sc.ComponentRef("/components/schemas/Missing") - assert.True(t, handled, "an undeclared component pointer is still classified as a component") - assert.False(t, ok, "an undeclared component does not resolve") - - _, _, handled = sc.ComponentRef("/components/schemas/Foo/properties/bar") - assert.False(t, handled, "a sub-schema pointer is not a top-level component pointer") -} - -// TestResolveComponentRef_NonCanonicalEscape pins that the resolved ID is built -// from the component's canonical name, so a $ref that escapes non-canonically -// (a raw '~' for a component named "A~B", interned under "A~0B") still resolves to -// the interned node rather than an unbacked ID (issue #14). -func TestResolveComponentRef_NonCanonicalEscape(t *testing.T) { - t.Parallel() - sc := Scope{Declares: func(n string) bool { return n == "A~B" }} - - id, ok, handled := sc.ComponentRef("/components/schemas/A~B") - assert.True(t, handled) - assert.True(t, ok) - assert.Equal(t, ids.Document{}.NamedType("/components/schemas/A~0B"), id, - "the ID is canonically re-escaped to match the interned node") - assert.Equal(t, ids.Document{}.NamedType(ids.Ptr("components", "schemas", "A~B")), id, - "and equals the ID the component was interned under") -} diff --git a/compilers/openapi/internal/resolve/refsite_test.go b/compilers/openapi/internal/resolve/refsite_test.go index a34847de..fe785fd4 100644 --- a/compilers/openapi/internal/resolve/refsite_test.go +++ b/compilers/openapi/internal/resolve/refsite_test.go @@ -13,6 +13,7 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi/internal/bind" + "github.com/dexpace/morphic/compilers/openapi/internal/ids" "github.com/dexpace/morphic/compilers/openapi/internal/load" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/compilers/openapi/internal/resolve" @@ -172,3 +173,56 @@ func TestScope_NamesReferent_ADeclaredTargetOrAResolvedOne(t *testing.T) { "a component the lowering does not declare is no target, though the $ref landed on it") assert.False(t, resolve.Scope{}.NamesReferent(b.component(t, "ToUser")), "no bindings, no target") } + +// TestIsRefSite_IncludesTheDegenerateRef pins the deliberately broad test. A +// position carrying a $ref field is $ref-shaped even when the ref is empty: +// callers here ask "is there a $ref-carrying body", not "is there a followable +// reference", and reading the narrower question would treat {$ref: ""} as an +// ordinary schema body. +func TestIsRefSite_IncludesTheDegenerateRef(t *testing.T) { + t.Parallel() + ref := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: '#/$defs/T'}\n") + assert.True(t, resolve.IsRefSite(ref, ref.GetSchema())) + + empty := openapitest.SchemaFromYAML(t.Context(), t, "{$ref: ''}\n") + assert.True(t, resolve.IsRefSite(empty, empty.GetSchema()), + "a present but empty $ref still carries a Ref field") + + plain := openapitest.SchemaFromYAML(t.Context(), t, "type: string\n") + assert.False(t, resolve.IsRefSite(plain, plain.GetSchema())) + assert.False(t, resolve.IsRefSite(plain, nil), "a boolean schema carries no body to inspect") +} + +func TestResolveComponentRef(t *testing.T) { + t.Parallel() + sc := resolve.Scope{Declares: func(n string) bool { return n == "User" }} + + id, ok, handled := sc.ComponentRef("/components/schemas/User") + assert.True(t, handled) + assert.True(t, ok) + assert.Equal(t, ids.Document{}.NamedType("/components/schemas/User"), id) + + _, ok, handled = sc.ComponentRef("/components/schemas/Missing") + assert.True(t, handled, "an undeclared component pointer is still classified as a component") + assert.False(t, ok, "an undeclared component does not resolve") + + _, _, handled = sc.ComponentRef("/components/schemas/Foo/properties/bar") + assert.False(t, handled, "a sub-schema pointer is not a top-level component pointer") +} + +// TestResolveComponentRef_NonCanonicalEscape pins that the resolved ID is built +// from the component's canonical name, so a $ref that escapes non-canonically +// (a raw '~' for a component named "A~B", interned under "A~0B") still resolves to +// the interned node rather than an unbacked ID (issue #14). +func TestResolveComponentRef_NonCanonicalEscape(t *testing.T) { + t.Parallel() + sc := resolve.Scope{Declares: func(n string) bool { return n == "A~B" }} + + id, ok, handled := sc.ComponentRef("/components/schemas/A~B") + assert.True(t, handled) + assert.True(t, ok) + assert.Equal(t, ids.Document{}.NamedType("/components/schemas/A~0B"), id, + "the ID is canonically re-escaped to match the interned node") + assert.Equal(t, ids.Document{}.NamedType(ids.Ptr("components", "schemas", "A~B")), id, + "and equals the ID the component was interned under") +} diff --git a/compilers/openapi/openapi_internal_test.go b/compilers/openapi/openapi_internal_test.go deleted file mode 100644 index 1605762a..00000000 --- a/compilers/openapi/openapi_internal_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package openapi - -import ( - "context" - "slices" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - soa "github.com/speakeasy-api/openapi/openapi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/compile" - "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/compilers/openapi/internal/lowering" - "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/ir" -) - -func TestCompile_UnsupportedVersion(t *testing.T) { - t.Parallel() - spec := "openapi: 2.0.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n" - doc, diags, err := New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, compilers.Options{}) - require.NoError(t, err) - assert.Nil(t, doc, "unsupported version refuses to lower") - assert.True(t, openapitest.HasDiag(diags, diag.UnsupportedVersion)) -} - -// TestCompile_UnmarshalError pins where a document that will not parse is -// reported. It is a finding about the source, not a failure of the compiler: -// engine.Run turns a compiler's Go error into one of its own, and the CLI reads -// that as having been invoked wrong rather than as a spec it could not read. -func TestCompile_UnmarshalError(t *testing.T) { - t.Parallel() - doc, diags, err := New().Compile(context.Background(), - []compilers.Source{openapitest.SourceOf("\t\t: : : not valid : yaml\n\x00")}, compilers.Options{}) - require.NoError(t, err) - assert.Nil(t, doc) - require.Len(t, diags, 1) - assert.Equal(t, diag.UndecodableSource, diags[0].Code) - assert.Equal(t, rootSrcIndex, diags[0].Provenance.Source, - "no document comes back, but SourceTable names the source a refusal indexes") -} - -// TestCompile_KeyThatIsNotUTF8IsRefused pins what ids.Ptr relies on: a source with -// a mapping key that is not UTF-8 is refused before anything lowers, so no such -// key becomes a pointer token. -func TestCompile_KeyThatIsNotUTF8IsRefused(t *testing.T) { - t.Parallel() - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n\xff: x\n" - doc, diags, err := New().Compile(context.Background(), - []compilers.Source{openapitest.SourceOf(spec)}, compilers.Options{}) - require.NoError(t, err) - assert.Nil(t, doc, "an undecodable source refuses to lower; no types are interned") - assert.True(t, openapitest.HasDiag(diags, diag.UndecodableSource)) -} - -// TestRun_RegistryRefusalsAreSurfaced covers the reporting of an entry -// compile.Types declined to hold. -// -// No spec can provoke one — an empty ID or a nil type definition is a compiler -// bug — so the refusal is forced directly. Without this the framework would -// decline garbage and say nothing, which hides the bug instead of the symptom: -// the node is simply absent and every reference to it dangles. -func TestRun_RegistryRefusalsAreSurfaced(t *testing.T) { - t.Parallel() - types := compile.NewTypes() - types.Register("", nil) - - _, diags, err := run(t.Context(), lowering.Ctx{Doc: &soa.OpenAPI{}}, types) - require.NoError(t, err) - - assert.True(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { - return d.Code == compile.RegistryRefused && strings.Contains(d.Message, "register rejected: empty type id") - }), "the compile reports the refusal Finish carries: %v", diags) -} - -// TestWithoutRereported is a unit table over the ways a lowering diagnostic -// and the load phase's diagnostics can relate. Only one drops anything: the key -// withoutRereported dedupes on is the code and the whole Provenance, so another -// code at the same provenance survives, and so does the same code at another -// pointer, or at the same pointer in another source. -func TestWithoutRereported(t *testing.T) { - t.Parallel() - at := ir.Provenance{Source: 0, Pointer: "/components/schemas/S"} - elsewhere := ir.Provenance{Source: 0, Pointer: "/components/schemas/T"} - inOverlay := ir.Provenance{Source: 1, Pointer: "/components/schemas/S"} - - tests := map[string]struct { - lowered []ir.Diagnostic - loaded []ir.Diagnostic - want []ir.Diagnostic - }{ - "a different code at the same provenance is kept": { - lowered: []ir.Diagnostic{{Code: diag.DegradedConstruct, Provenance: at}}, - loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, - want: []ir.Diagnostic{{Code: diag.DegradedConstruct, Provenance: at}}, - }, - "unresolved-ref at a different provenance is kept": { - lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: elsewhere}}, - loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, - want: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: elsewhere}}, - }, - "unresolved-ref at the same pointer in another source is kept": { - lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: inOverlay}}, - loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, - want: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: inOverlay}}, - }, - "unresolved-ref at the same provenance is dropped": { - lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, - loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, - want: []ir.Diagnostic{}, - }, - "unresolved-ref where the load phase refused a cycle is dropped": { - lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, - loaded: []ir.Diagnostic{{Code: diag.CyclicRef, Provenance: at}}, - want: []ir.Diagnostic{}, - }, - "an empty loaded list keeps everything": { - lowered: []ir.Diagnostic{ - {Code: diag.UnresolvedRef, Provenance: at}, - {Code: diag.DegradedConstruct, Provenance: elsewhere}, - }, - loaded: nil, - want: []ir.Diagnostic{ - {Code: diag.UnresolvedRef, Provenance: at}, - {Code: diag.DegradedConstruct, Provenance: elsewhere}, - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - got := withoutRereported(tc.lowered, tc.loaded) - if d := cmp.Diff(tc.want, got); d != "" { - t.Errorf("withoutRereported(-want +got):\n%s", d) - } - }) - } -} diff --git a/compilers/openapi/openapi_test.go b/compilers/openapi/openapi_test.go index 051d098d..7c25845d 100644 --- a/compilers/openapi/openapi_test.go +++ b/compilers/openapi/openapi_test.go @@ -9,12 +9,16 @@ import ( "testing" "github.com/google/go-cmp/cmp" + soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" ) @@ -192,3 +196,125 @@ func TestCompile_AnUnreadableSourceIsADiagnostic(t *testing.T) { require.Len(t, diags, 1) assert.Equal(t, diag.UndecodableSource, diags[0].Code) } + +func TestCompile_UnsupportedVersion(t *testing.T) { + t.Parallel() + spec := "openapi: 2.0.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n" + doc, diags, err := openapi.New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, compilers.Options{}) + require.NoError(t, err) + assert.Nil(t, doc, "unsupported version refuses to lower") + assert.True(t, openapitest.HasDiag(diags, diag.UnsupportedVersion)) +} + +// TestCompile_UnmarshalError pins where a document that will not parse is +// reported. It is a finding about the source, not a failure of the compiler: +// engine.Run turns a compiler's Go error into one of its own, and the CLI reads +// that as having been invoked wrong rather than as a spec it could not read. +func TestCompile_UnmarshalError(t *testing.T) { + t.Parallel() + doc, diags, err := openapi.New().Compile(context.Background(), + []compilers.Source{openapitest.SourceOf("\t\t: : : not valid : yaml\n\x00")}, compilers.Options{}) + require.NoError(t, err) + assert.Nil(t, doc) + require.Len(t, diags, 1) + assert.Equal(t, diag.UndecodableSource, diags[0].Code) + assert.Equal(t, openapi.RootSrcIndex, diags[0].Provenance.Source, + "no document comes back, but SourceTable names the source a refusal indexes") +} + +// TestCompile_KeyThatIsNotUTF8IsRefused pins what ids.Ptr relies on: a source with +// a mapping key that is not UTF-8 is refused before anything lowers, so no such +// key becomes a pointer token. +func TestCompile_KeyThatIsNotUTF8IsRefused(t *testing.T) { + t.Parallel() + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n\xff: x\n" + doc, diags, err := openapi.New().Compile(context.Background(), + []compilers.Source{openapitest.SourceOf(spec)}, compilers.Options{}) + require.NoError(t, err) + assert.Nil(t, doc, "an undecodable source refuses to lower; no types are interned") + assert.True(t, openapitest.HasDiag(diags, diag.UndecodableSource)) +} + +// TestRun_RegistryRefusalsAreSurfaced covers the reporting of an entry +// compile.Types declined to hold. +// +// No spec can provoke one — an empty ID or a nil type definition is a compiler +// bug — so the refusal is forced directly. Without this the framework would +// decline garbage and say nothing, which hides the bug instead of the symptom: +// the node is simply absent and every reference to it dangles. +func TestRun_RegistryRefusalsAreSurfaced(t *testing.T) { + t.Parallel() + types := compile.NewTypes() + types.Register("", nil) + + _, diags, err := openapi.Run(t.Context(), lowering.Ctx{Doc: &soa.OpenAPI{}}, types) + require.NoError(t, err) + + assert.True(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { + return d.Code == compile.RegistryRefused && strings.Contains(d.Message, "register rejected: empty type id") + }), "the compile reports the refusal Finish carries: %v", diags) +} + +// TestWithoutRereported is a unit table over the ways a lowering diagnostic +// and the load phase's diagnostics can relate. Only one drops anything: the key +// withoutRereported dedupes on is the code and the whole Provenance, so another +// code at the same provenance survives, and so does the same code at another +// pointer, or at the same pointer in another source. +func TestWithoutRereported(t *testing.T) { + t.Parallel() + at := ir.Provenance{Source: 0, Pointer: "/components/schemas/S"} + elsewhere := ir.Provenance{Source: 0, Pointer: "/components/schemas/T"} + inOverlay := ir.Provenance{Source: 1, Pointer: "/components/schemas/S"} + + tests := map[string]struct { + lowered []ir.Diagnostic + loaded []ir.Diagnostic + want []ir.Diagnostic + }{ + "a different code at the same provenance is kept": { + lowered: []ir.Diagnostic{{Code: diag.DegradedConstruct, Provenance: at}}, + loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, + want: []ir.Diagnostic{{Code: diag.DegradedConstruct, Provenance: at}}, + }, + "unresolved-ref at a different provenance is kept": { + lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: elsewhere}}, + loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, + want: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: elsewhere}}, + }, + "unresolved-ref at the same pointer in another source is kept": { + lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: inOverlay}}, + loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, + want: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: inOverlay}}, + }, + "unresolved-ref at the same provenance is dropped": { + lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, + loaded: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, + want: []ir.Diagnostic{}, + }, + "unresolved-ref where the load phase refused a cycle is dropped": { + lowered: []ir.Diagnostic{{Code: diag.UnresolvedRef, Provenance: at}}, + loaded: []ir.Diagnostic{{Code: diag.CyclicRef, Provenance: at}}, + want: []ir.Diagnostic{}, + }, + "an empty loaded list keeps everything": { + lowered: []ir.Diagnostic{ + {Code: diag.UnresolvedRef, Provenance: at}, + {Code: diag.DegradedConstruct, Provenance: elsewhere}, + }, + loaded: nil, + want: []ir.Diagnostic{ + {Code: diag.UnresolvedRef, Provenance: at}, + {Code: diag.DegradedConstruct, Provenance: elsewhere}, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + got := openapi.WithoutRereported(tc.lowered, tc.loaded) + if d := cmp.Diff(tc.want, got); d != "" { + t.Errorf("withoutRereported(-want +got):\n%s", d) + } + }) + } +} diff --git a/compilers/openapi/options_test.go b/compilers/openapi/options_test.go index 803e9ecb..a621b801 100644 --- a/compilers/openapi/options_test.go +++ b/compilers/openapi/options_test.go @@ -1,4 +1,4 @@ -package openapi +package openapi_test import ( "context" @@ -11,13 +11,14 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) func TestCompile_WrongFormatOptions(t *testing.T) { t.Parallel() - _, _, err := New().Compile(context.Background(), + _, _, err := openapi.New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n")}, compilers.Options{FormatOptions: "not-openapi-options"}) require.Error(t, err, "wrong FormatOptions type is a programmer error") @@ -26,8 +27,8 @@ func TestCompile_WrongFormatOptions(t *testing.T) { func TestCompile_ExplicitOptions(t *testing.T) { t.Parallel() spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n /a/b:\n get: {operationId: ab, responses: {\"200\": {description: ok}}}\n" - doc, _, err := New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, - compilers.Options{FormatOptions: Options{Grouping: GroupByPathPrefix}}) + doc, _, err := openapi.New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, + compilers.Options{FormatOptions: openapi.Options{Grouping: openapi.GroupByPathPrefix}}) require.NoError(t, err) require.NotNil(t, doc) assert.Equal(t, "a", doc.Services[0].Groups[0].Name.Source) @@ -47,7 +48,7 @@ func TestDecodeOptions_TextReachesEveryField(t *testing.T) { return []byte(overlayDoc), nil } - got, err := New().DecodeOptions(compilers.OptionSet{ + got, err := openapi.New().DecodeOptions(compilers.OptionSet{ Settings: map[string]string{ "grouping": "path-prefix", "allow-external-refs": "true", @@ -64,16 +65,16 @@ func TestDecodeOptions_TextReachesEveryField(t *testing.T) { }) require.NoError(t, err) - opts, ok := got.(Options) + opts, ok := got.(openapi.Options) require.True(t, ok, "the decoded value must be this compiler's own options type") - assert.Equal(t, GroupByPathPrefix, opts.Grouping) + assert.Equal(t, openapi.GroupByPathPrefix, opts.Grouping) assert.True(t, opts.AllowExternalRefs) require.NotNil(t, opts.Overlay) assert.Equal(t, "patch.yaml", opts.Overlay.Path) assert.Equal(t, overlayDoc, string(opts.Overlay.Data)) assert.True(t, opts.Overlay.Lax) assert.True(t, opts.NullableWidening.Disabled, "nullable-widening=false turns the policy off") - assert.Equal(t, Limits{MaxSourceBytes: 11, MaxSourceNodes: -1, MaxAliasSurplus: 13, MaxExternalDocuments: 14}, + assert.Equal(t, openapi.Limits{MaxSourceBytes: 11, MaxSourceNodes: -1, MaxAliasSurplus: 13, MaxExternalDocuments: 14}, opts.Limits, "each budget as the Go field reads it, 0 left for the default") } @@ -83,9 +84,9 @@ func TestDecodeOptions_TextReachesEveryField(t *testing.T) { // reach. func TestLimitOptions_NameEveryBudget(t *testing.T) { t.Parallel() - var l Limits + var l openapi.Limits fields := map[*int]string{} - for name, field := range limitOptions(&l) { + for name, field := range openapi.LimitOptions(&l) { _, dup := fields[field] assert.False(t, dup, "%s sets a field another option sets", name) fields[field] = name @@ -101,9 +102,9 @@ func TestLimitOptions_NameEveryBudget(t *testing.T) { func TestDecodeOptions_EmptySetIsDefaults(t *testing.T) { t.Parallel() - got, err := New().DecodeOptions(compilers.OptionSet{}) + got, err := openapi.New().DecodeOptions(compilers.OptionSet{}) require.NoError(t, err) - assert.Equal(t, Options{}, got) + assert.Equal(t, openapi.Options{}, got) } func TestDecodeOptions_Refusals(t *testing.T) { @@ -151,7 +152,7 @@ func TestDecodeOptions_Refusals(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := New().DecodeOptions(compilers.OptionSet{ + got, err := openapi.New().DecodeOptions(compilers.OptionSet{ Settings: tc.settings, ReadFile: tc.readFile, }) @@ -169,19 +170,19 @@ func TestDecodeOptions_OverlayLaxnessIsOrderIndependent(t *testing.T) { t.Parallel() read := func(string) ([]byte, error) { return []byte("overlay: 1.0.0\n"), nil } - strict, err := New().DecodeOptions(compilers.OptionSet{ + strict, err := openapi.New().DecodeOptions(compilers.OptionSet{ Settings: map[string]string{"overlay": "p.yaml", "overlay-lax": "false"}, ReadFile: read, }) require.NoError(t, err) - lax, err := New().DecodeOptions(compilers.OptionSet{ + lax, err := openapi.New().DecodeOptions(compilers.OptionSet{ Settings: map[string]string{"overlay-lax": "true", "overlay": "p.yaml"}, ReadFile: read, }) require.NoError(t, err) - assert.False(t, strict.(Options).Overlay.Lax) - assert.True(t, lax.(Options).Overlay.Lax) + assert.False(t, strict.(openapi.Options).Overlay.Lax) + assert.True(t, lax.(openapi.Options).Overlay.Lax) } // TestDecodeOptions_FeedsCompile closes the loop the CLI walks: what @@ -190,12 +191,12 @@ func TestDecodeOptions_OverlayLaxnessIsOrderIndependent(t *testing.T) { func TestDecodeOptions_FeedsCompile(t *testing.T) { t.Parallel() spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n /a/b:\n get: {operationId: ab, tags: [zoo], responses: {\"200\": {description: ok}}}\n" - formatOpts, err := New().DecodeOptions(compilers.OptionSet{ + formatOpts, err := openapi.New().DecodeOptions(compilers.OptionSet{ Settings: map[string]string{"grouping": "path-prefix"}, }) require.NoError(t, err) - doc, _, err := New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, + doc, _, err := openapi.New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, compilers.Options{FormatOptions: formatOpts}) require.NoError(t, err) require.NotNil(t, doc) @@ -208,12 +209,12 @@ func TestDecodeOptions_FeedsCompile(t *testing.T) { // extending it starts from what a compile really uses. func TestDefaultStreamingMediaTypes_IsWhatTheLoweringApplies(t *testing.T) { t.Parallel() - assert.Equal(t, lowering.DefaultStreamingMediaTypes(), DefaultStreamingMediaTypes()) + assert.Equal(t, lowering.DefaultStreamingMediaTypes(), openapi.DefaultStreamingMediaTypes()) } // TestDefaultExtensionPromotions_IsWhatTheLoweringApplies pins the exported // mapping to the one a compile promotes by when Options names none. func TestDefaultExtensionPromotions_IsWhatTheLoweringApplies(t *testing.T) { t.Parallel() - assert.Equal(t, lowering.DefaultExtensionPromotions(), DefaultExtensionPromotions()) + assert.Equal(t, lowering.DefaultExtensionPromotions(), openapi.DefaultExtensionPromotions()) } diff --git a/engine/internal/atomicfile/chmod_internal_test.go b/engine/internal/atomicfile/chmod_test.go similarity index 85% rename from engine/internal/atomicfile/chmod_internal_test.go rename to engine/internal/atomicfile/chmod_test.go index 4a2ea45c..62dc7edf 100644 --- a/engine/internal/atomicfile/chmod_internal_test.go +++ b/engine/internal/atomicfile/chmod_test.go @@ -1,4 +1,4 @@ -package atomicfile +package atomicfile_test import ( "errors" @@ -12,6 +12,8 @@ import ( "github.com/google/renameio/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/engine/internal/atomicfile" ) // TestReplace_AChmodFailureLeavesNothingBehind pins a filesystem refusing the @@ -33,7 +35,7 @@ func TestReplace_AChmodFailureLeavesNothingBehind(t *testing.T) { dest := filepath.Join(dir, "ir.json") require.NoError(t, os.WriteFile(dest, []byte("old\n"), 0o600)) - err := replace(dest, fill, failing) + err := atomicfile.ReplaceWithChmod(dest, fill, failing) require.ErrorIs(t, err, refused) assert.ErrorContains(t, err, fmt.Sprintf("chmod output %q", dest)) @@ -46,5 +48,5 @@ func TestReplace_AChmodFailureLeavesNothingBehind(t *testing.T) { assert.Equal(t, "old\n", string(raw)) fresh := filepath.Join(t.TempDir(), "ir.json") - require.NoError(t, replace(fresh, fill, failing), "a new destination is not chmod-ed") + require.NoError(t, atomicfile.ReplaceWithChmod(fresh, fill, failing), "a new destination is not chmod-ed") } diff --git a/engine/internal/atomicfile/export_test.go b/engine/internal/atomicfile/export_test.go new file mode 100644 index 00000000..2e9a709e --- /dev/null +++ b/engine/internal/atomicfile/export_test.go @@ -0,0 +1,4 @@ +package atomicfile + +// ReplaceWithChmod is replace, for the external tests. +var ReplaceWithChmod = replace diff --git a/engine/reader/disk_readopened_internal_test.go b/engine/reader/disk_readopened_test.go similarity index 91% rename from engine/reader/disk_readopened_internal_test.go rename to engine/reader/disk_readopened_test.go index 2667dd8f..206b97e5 100644 --- a/engine/reader/disk_readopened_internal_test.go +++ b/engine/reader/disk_readopened_test.go @@ -1,4 +1,4 @@ -package reader +package reader_test import ( "errors" @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/engine/reader" ) // scriptedFile is an fs.File whose Stat, Read and Close answer as a test @@ -63,13 +64,13 @@ func TestReadOpened_JudgesTheFileOpened(t *testing.T) { }{ {name: "a directory", file: func(t *testing.T) *scriptedFile { return &scriptedFile{info: infoOf(t, fs.ModeDir)} - }, wantIs: []error{errNotRegular}}, + }, wantIs: []error{reader.ErrNotRegular}}, {name: "a FIFO", file: func(t *testing.T) *scriptedFile { return &scriptedFile{info: infoOf(t, fs.ModeNamedPipe)} - }, wantIs: []error{errNotRegular}}, + }, wantIs: []error{reader.ErrNotRegular}}, {name: "a device whose close fails", file: func(t *testing.T) *scriptedFile { return &scriptedFile{info: infoOf(t, fs.ModeDevice|fs.ModeCharDevice), closeErr: closeFailed} - }, wantIs: []error{errNotRegular, closeFailed}}, + }, wantIs: []error{reader.ErrNotRegular, closeFailed}}, {name: "a file Stat fails on", file: func(*testing.T) *scriptedFile { return &scriptedFile{statErr: statFailed} }, wantIs: []error{statFailed}}, @@ -83,13 +84,13 @@ func TestReadOpened_JudgesTheFileOpened(t *testing.T) { f := tc.file(t) f.body = strings.NewReader("never read") - got, err := readOpened("p.yaml", f, 0) + got, err := reader.ReadOpened("p.yaml", f, 0) require.Error(t, err) for _, want := range tc.wantIs { assert.ErrorIs(t, err, want) } - if slices.Contains(tc.wantIs, errNotRegular) { + if slices.Contains(tc.wantIs, reader.ErrNotRegular) { assert.Contains(t, err.Error(), "p.yaml", "the file refused") } assert.Nil(t, got) @@ -131,7 +132,7 @@ func TestReadOpened_ReadsAndClosesARegularFile(t *testing.T) { t.Parallel() f := &scriptedFile{info: infoOf(t, 0), body: tc.body, closeErr: tc.closeErr} - got, err := readOpened("p.yaml", f, tc.limit) + got, err := reader.ReadOpened("p.yaml", f, tc.limit) assert.Equal(t, 1, f.closes, "closed once, whatever the read did") if len(tc.wantIs) > 0 { @@ -157,9 +158,9 @@ func TestReadOpened_RefusesAnOpenedDirectory(t *testing.T) { f, err := os.Open(dir) require.NoError(t, err) - got, err := readOpened(dir, f, 0) + got, err := reader.ReadOpened(dir, f, 0) - require.ErrorIs(t, err, errNotRegular) + require.ErrorIs(t, err, reader.ErrNotRegular) assert.Contains(t, err.Error(), dir) assert.Nil(t, got) assert.ErrorIs(t, f.Close(), os.ErrClosed, "the refused file is closed") diff --git a/engine/reader/export_test.go b/engine/reader/export_test.go index 2401ebdd..a6b6fcd2 100644 --- a/engine/reader/export_test.go +++ b/engine/reader/export_test.go @@ -4,3 +4,9 @@ package reader // socket fails with, so the external tests assert on it rather than on its // wording. var ErrNotRegular = errNotRegular + +// FilePathOf is filePathOf, for the external tests. +var FilePathOf = filePathOf + +// ReadOpened is readOpened, for the external tests. +var ReadOpened = readOpened diff --git a/engine/reader/location_fileuri_internal_test.go b/engine/reader/location_fileuri_test.go similarity index 97% rename from engine/reader/location_fileuri_internal_test.go rename to engine/reader/location_fileuri_test.go index 159c870b..aff33ece 100644 --- a/engine/reader/location_fileuri_internal_test.go +++ b/engine/reader/location_fileuri_test.go @@ -1,4 +1,4 @@ -package reader +package reader_test import ( "net/url" @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/engine/reader" ) // TestFilePathOf_NamesTheLocalFileAFileURINames pins how a file URI names a @@ -62,7 +64,7 @@ func TestFilePathOf_NamesTheLocalFileAFileURINames(t *testing.T) { u, err := url.Parse(tc.uri) require.NoError(t, err) - got, err := filePathOf(u, tc.windows) + got, err := reader.FilePathOf(u, tc.windows) if tc.wantErr != "" { require.Error(t, err) diff --git a/internal/decls/decls_internal_test.go b/internal/decls/decls_internal_test.go deleted file mode 100644 index 848aba47..00000000 --- a/internal/decls/decls_internal_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package decls - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestLoadAlone_Refusals covers what listing a pattern first keeps LoadEach -// from reaching: the go command refusing to run for the load itself, and a -// path naming other than one package. It sets the environment, so it cannot -// run in parallel. -func TestLoadAlone_Refusals(t *testing.T) { - p, err := loadAlone("github.com/dexpace/morphic/internal/decls/testdata/nowhere/...") - require.Error(t, err) - assert.Nil(t, p) - assert.Contains(t, err.Error(), "loaded as 0 packages, want 1") - - t.Setenv("GOFLAGS", "-mod=bogus") - p, err = loadAlone("github.com/dexpace/morphic/internal/decls/testdata/good/fixture") - require.Error(t, err) - assert.Nil(t, p) - assert.Contains(t, err.Error(), "bogus", "the go command's own complaint is kept") -} diff --git a/internal/decls/decls_test.go b/internal/decls/decls_test.go index 179f792c..3fbc8b86 100644 --- a/internal/decls/decls_test.go +++ b/internal/decls/decls_test.go @@ -323,3 +323,20 @@ func TestTypesDeclaring_CountsOnlyOwnMethods(t *testing.T) { assert.Equal(t, []string{"Color", "Shade"}, p.TypesDeclaring("Method")) assert.Empty(t, p.TypesDeclaring("Missing")) } + +// TestLoadAlone_Refusals covers what listing a pattern first keeps LoadEach +// from reaching: the go command refusing to run for the load itself, and a +// path naming other than one package. It sets the environment, so it cannot +// run in parallel. +func TestLoadAlone_Refusals(t *testing.T) { + p, err := decls.LoadAlone("github.com/dexpace/morphic/internal/decls/testdata/nowhere/...") + require.Error(t, err) + assert.Nil(t, p) + assert.Contains(t, err.Error(), "loaded as 0 packages, want 1") + + t.Setenv("GOFLAGS", "-mod=bogus") + p, err = decls.LoadAlone("github.com/dexpace/morphic/internal/decls/testdata/good/fixture") + require.Error(t, err) + assert.Nil(t, p) + assert.Contains(t, err.Error(), "bogus", "the go command's own complaint is kept") +} diff --git a/internal/decls/export_test.go b/internal/decls/export_test.go new file mode 100644 index 00000000..12a44248 --- /dev/null +++ b/internal/decls/export_test.go @@ -0,0 +1,4 @@ +package decls + +// LoadAlone is loadAlone, for the external tests. +var LoadAlone = loadAlone diff --git a/internal/jsonshape/export_test.go b/internal/jsonshape/export_test.go new file mode 100644 index 00000000..88f17e39 --- /dev/null +++ b/internal/jsonshape/export_test.go @@ -0,0 +1,7 @@ +package jsonshape + +// MaxDepth is maxDepth, for the external tests. +const MaxDepth = maxDepth + +// MaxTypes is maxTypes, for the external tests. +const MaxTypes = maxTypes diff --git a/internal/jsonshape/jsonshape_bounds_internal_test.go b/internal/jsonshape/jsonshape_bounds_test.go similarity index 77% rename from internal/jsonshape/jsonshape_bounds_internal_test.go rename to internal/jsonshape/jsonshape_bounds_test.go index e3575478..477e30b0 100644 --- a/internal/jsonshape/jsonshape_bounds_internal_test.go +++ b/internal/jsonshape/jsonshape_bounds_test.go @@ -1,4 +1,4 @@ -package jsonshape +package jsonshape_test import ( "fmt" @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/internal/decls" + "github.com/dexpace/morphic/internal/jsonshape" ) // TestDescribe_HoldsItsBounds generates a module whose roots reach exactly as @@ -29,10 +30,10 @@ func TestDescribe_HoldsItsBounds(t *testing.T) { } fmt.Fprintf(&src, "type %s%d struct{}\n", prefix, n-1) } - chain("Within", maxTypes) - chain("Beyond", maxTypes+1) - fmt.Fprintf(&src, "type Shallow struct { D %sstring `json:\"d\"` }\n", strings.Repeat("[]", maxDepth)) - fmt.Fprintf(&src, "type Deep struct { D %sstring `json:\"d\"` }\n", strings.Repeat("[]", maxDepth+1)) + chain("Within", jsonshape.MaxTypes) + chain("Beyond", jsonshape.MaxTypes+1) + fmt.Fprintf(&src, "type Shallow struct { D %sstring `json:\"d\"` }\n", strings.Repeat("[]", jsonshape.MaxDepth)) + fmt.Fprintf(&src, "type Deep struct { D %sstring `json:\"d\"` }\n", strings.Repeat("[]", jsonshape.MaxDepth+1)) require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module bounds\n\ngo 1.27\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "bounds.go"), []byte(src.String()), 0o644)) @@ -42,11 +43,11 @@ func TestDescribe_HoldsItsBounds(t *testing.T) { require.NoError(t, err) for _, root := range []string{"Within0", "Shallow"} { - _, err := Describe(p, root) + _, err := jsonshape.Describe(p, root) assert.NoError(t, err, root) } - _, err = Describe(p, "Beyond0") - assert.ErrorContains(t, err, fmt.Sprintf("Beyond0 reaches more than %d types", maxTypes)) - _, err = Describe(p, "Deep") - assert.ErrorContains(t, err, fmt.Sprintf("Deep: field D: a type nests more than %d deep", maxDepth)) + _, err = jsonshape.Describe(p, "Beyond0") + assert.ErrorContains(t, err, fmt.Sprintf("Beyond0 reaches more than %d types", jsonshape.MaxTypes)) + _, err = jsonshape.Describe(p, "Deep") + assert.ErrorContains(t, err, fmt.Sprintf("Deep: field D: a type nests more than %d deep", jsonshape.MaxDepth)) } diff --git a/internal/keydoc/cite_yaml_internal_test.go b/internal/keydoc/cite_yaml_test.go similarity index 90% rename from internal/keydoc/cite_yaml_internal_test.go rename to internal/keydoc/cite_yaml_test.go index 7a39687d..e3a2f4c4 100644 --- a/internal/keydoc/cite_yaml_internal_test.go +++ b/internal/keydoc/cite_yaml_test.go @@ -1,9 +1,11 @@ -package keydoc +package keydoc_test import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/internal/keydoc" ) // TestYAMLComment_StartsAtAHashOutsideQuotes reads where a YAML line's comment @@ -28,7 +30,7 @@ func TestYAMLComment_StartsAtAHashOutsideQuotes(t *testing.T) { {"[a, 'b # c'] # after a flow sequence", " after a flow sequence", true}, } for _, tc := range tests { - got, has := yamlComment(tc.line) + got, has := keydoc.YamlComment(tc.line) assert.Equal(t, tc.has, has, tc.line) assert.Equal(t, tc.want, got, tc.line) } diff --git a/internal/keydoc/export_test.go b/internal/keydoc/export_test.go new file mode 100644 index 00000000..9964fdae --- /dev/null +++ b/internal/keydoc/export_test.go @@ -0,0 +1,4 @@ +package keydoc + +// YamlComment is yamlComment, for the external tests. +var YamlComment = yamlComment diff --git a/internal/leakcheck/export_test.go b/internal/leakcheck/export_test.go new file mode 100644 index 00000000..38756515 --- /dev/null +++ b/internal/leakcheck/export_test.go @@ -0,0 +1,19 @@ +package leakcheck + +// ForcedCode is forcedCode, for the external tests. +var ForcedCode = forcedCode + +// ForcedExitCode is forcedExitCode, for the external tests. +const ForcedExitCode = forcedExitCode + +// GoroutineLeakProfile is goroutineLeakProfile, for the external tests. +const GoroutineLeakProfile = goroutineLeakProfile + +// PprofLookup is pprofLookup, for the external tests. +var PprofLookup = pprofLookup + +// Profile is profile, for the external tests. +type Profile = profile + +// Verdict is verdict, for the external tests. +var Verdict = verdict diff --git a/internal/leakcheck/leakcheck_internal_test.go b/internal/leakcheck/leakcheck_test.go similarity index 79% rename from internal/leakcheck/leakcheck_internal_test.go rename to internal/leakcheck/leakcheck_test.go index d97e370d..05a7a478 100644 --- a/internal/leakcheck/leakcheck_internal_test.go +++ b/internal/leakcheck/leakcheck_test.go @@ -1,4 +1,4 @@ -package leakcheck +package leakcheck_test import ( "bytes" @@ -7,6 +7,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/internal/leakcheck" ) // fakeProfile is a profile whose Count and WriteTo behavior are both fixed by @@ -30,8 +32,8 @@ func (f *fakeProfile) WriteTo(w io.Writer, _ int) error { // lookupFake returns a lookup function that hands back p regardless of the // name asked for. -func lookupFake(p profile) func(string) profile { - return func(string) profile { return p } +func lookupFake(p leakcheck.Profile) func(string) leakcheck.Profile { + return func(string) leakcheck.Profile { return p } } func TestVerdict_NilProfileFailsLoudlyRegardlessOfCode(t *testing.T) { @@ -41,7 +43,7 @@ func TestVerdict_NilProfileFailsLoudlyRegardlessOfCode(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() var out bytes.Buffer - got := verdict(code, lookupFake(nil), &out) + got := leakcheck.Verdict(code, lookupFake(nil), &out) assert.NotZero(t, got, "an absent profile must fail loudly, never report green") assert.Contains(t, out.String(), "not registered") }) @@ -52,7 +54,7 @@ func TestVerdict_WriteToErrorFailsLoudly(t *testing.T) { t.Parallel() var out bytes.Buffer wantErr := errors.New("boom") - got := verdict(0, lookupFake(&fakeProfile{writeErr: wantErr}), &out) + got := leakcheck.Verdict(0, lookupFake(&fakeProfile{writeErr: wantErr}), &out) assert.NotZero(t, got, "a profile that cannot be written must fail loudly") assert.Contains(t, out.String(), "boom") } @@ -64,7 +66,7 @@ func TestVerdict_ZeroLeaksReturnsCodeUnchangedAndPrintsNothing(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() var out bytes.Buffer - got := verdict(code, lookupFake(&fakeProfile{count: 0}), &out) + got := leakcheck.Verdict(code, lookupFake(&fakeProfile{count: 0}), &out) assert.Equal(t, code, got) assert.Empty(t, out.String(), "a clean run must print nothing") }) @@ -78,7 +80,7 @@ func TestVerdict_LeaksArePrintedAndForceNonZero(t *testing.T) { code int want int }{ - {name: "a passing run", code: 0, want: forcedExitCode}, + {name: "a passing run", code: 0, want: leakcheck.ForcedExitCode}, {name: "an already-failing run", code: 9, want: 9}, } for _, tc := range tests { @@ -86,7 +88,7 @@ func TestVerdict_LeaksArePrintedAndForceNonZero(t *testing.T) { t.Parallel() var out bytes.Buffer p := &fakeProfile{count: 3, written: "STACKMARKER\n"} - got := verdict(tc.code, lookupFake(p), &out) + got := leakcheck.Verdict(tc.code, lookupFake(p), &out) assert.Equal(t, tc.want, got) assert.Contains(t, out.String(), "3", "the summary must name the count") assert.Contains(t, out.String(), "STACKMARKER", "the stacks WriteTo wrote must be printed") @@ -96,20 +98,20 @@ func TestVerdict_LeaksArePrintedAndForceNonZero(t *testing.T) { func TestForcedCode_ReturnsCodeUnlessZero(t *testing.T) { t.Parallel() - assert.Equal(t, forcedExitCode, forcedCode(0)) - assert.Equal(t, 7, forcedCode(7)) + assert.Equal(t, leakcheck.ForcedExitCode, leakcheck.ForcedCode(0)) + assert.Equal(t, 7, leakcheck.ForcedCode(7)) } func TestPprofLookup_RegisteredProfileIsNonNil(t *testing.T) { t.Parallel() - assert.NotNil(t, pprofLookup(goroutineLeakProfile)) + assert.NotNil(t, leakcheck.PprofLookup(leakcheck.GoroutineLeakProfile)) } // TestPprofLookup_UnregisteredNameIsGenuinelyNilInterface guards the typed-nil // trap pprofLookup's doc comment describes. func TestPprofLookup_UnregisteredNameIsGenuinelyNilInterface(t *testing.T) { t.Parallel() - got := pprofLookup("leakcheck-test-name-nothing-registers") + got := leakcheck.PprofLookup("leakcheck-test-name-nothing-registers") // Equal against nil compares the interface itself, not assert.Nil: testify's // Nil reflects through the interface and would report this as nil even if // pprofLookup regressed to "return pprof.Lookup(name)" — a *pprof.Profile(nil) diff --git a/ir/irtest/export_test.go b/ir/irtest/export_test.go new file mode 100644 index 00000000..7dc1c4be --- /dev/null +++ b/ir/irtest/export_test.go @@ -0,0 +1,13 @@ +package irtest + +// CompareGoldenOn is compareGolden, for the external tests. +var CompareGoldenOn = compareGolden + +// FillOn is fill, for the external tests. +var FillOn = fill + +// RootFrom is rootFrom, for the external tests. +var RootFrom = rootFrom + +// UpdateFlag is update, for the external tests. +var UpdateFlag = update diff --git a/ir/irtest/golden_internal_test.go b/ir/irtest/golden_internal_test.go deleted file mode 100644 index d2728af8..00000000 --- a/ir/irtest/golden_internal_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package irtest - -import ( - "fmt" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/ir" -) - -// recordingT is a stub testingT that records Fatalf/Errorf instead of aborting -// the real test. Fatalf mimics *testing.T by aborting the calling goroutine via -// runtime.Goexit, so code after the call does not run. -type recordingT struct { - helperCalls int - fatalf string - errorf string - aborted bool -} - -func (r *recordingT) Helper() { r.helperCalls++ } - -func (r *recordingT) Fatalf(format string, args ...any) { - r.fatalf = fmt.Sprintf(format, args...) - r.aborted = true - runtime.Goexit() -} - -func (r *recordingT) Errorf(format string, args ...any) { - r.errorf = fmt.Sprintf(format, args...) -} - -// runCompare drives compareGolden in a goroutine so a Goexit from Fatalf unwinds -// only that goroutine, then returns the populated recorder. -func runCompare(goldenPath string, doc *ir.Document) *recordingT { - rec := &recordingT{} - done := make(chan struct{}) - go func() { - defer close(done) - compareGolden(rec, goldenPath, doc) - }() - <-done - return rec -} - -// badExtDoc marshals with an error: an invalid jsontext.Value in Unmodeled -// makes encodeGolden's json.Marshal fail. -func badExtDoc() *ir.Document { - return &ir.Document{IRVersion: ir.IRVersion, Unmodeled: ir.Unmodeled{"x": {Value: ir.RawValue("{invalid")}}} -} - -// withUpdate sets the -update flag for the duration of fn and restores it. -func withUpdate(t *testing.T, v bool, fn func()) { - t.Helper() - prev := *update - *update = v - defer func() { *update = prev }() - fn() -} - -func TestCompareGolden_UpdateWritesAndReturns(t *testing.T) { - // Not parallel: toggles the shared -update flag. - dir := t.TempDir() - path := filepath.Join(dir, "nested", "doc.golden.json") - doc := &ir.Document{IRVersion: ir.IRVersion, Name: "u"} - - withUpdate(t, true, func() { - rec := runCompare(path, doc) - require.False(t, rec.aborted, "successful update must not abort") - assert.Empty(t, rec.fatalf) - assert.Empty(t, rec.errorf) - }) - - written, err := os.ReadFile(path) - require.NoError(t, err) - assert.NotEmpty(t, written) -} - -func TestCompareGolden_UpdateWriteErrorFatals(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "doc.golden.json") - - withUpdate(t, true, func() { - rec := runCompare(path, badExtDoc()) - require.True(t, rec.aborted, "write failure must abort via Fatalf") - assert.Contains(t, rec.fatalf, "update golden:") - }) -} - -func TestCompareGolden_MissingGoldenFatals(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "absent.golden.json") - doc := &ir.Document{IRVersion: ir.IRVersion, Name: "x"} - - withUpdate(t, false, func() { - rec := runCompare(path, doc) - require.True(t, rec.aborted, "missing golden must abort via Fatalf") - assert.Contains(t, rec.fatalf, "run with -update to create") - }) -} - -func TestCompareGolden_MarshalErrorFatals(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "doc.golden.json") - require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - - withUpdate(t, false, func() { - rec := runCompare(path, badExtDoc()) - require.True(t, rec.aborted, "marshal failure must abort via Fatalf") - assert.Contains(t, rec.fatalf, "marshal document:") - }) -} - -func TestCompareGolden_MismatchErrorf(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "doc.golden.json") - require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - doc := &ir.Document{IRVersion: ir.IRVersion, Name: "different"} - - withUpdate(t, false, func() { - rec := runCompare(path, doc) - require.False(t, rec.aborted, "a diff mismatch reports via Errorf, not Fatalf") - assert.Contains(t, rec.errorf, "golden mismatch") - }) -} - -func TestWriteGolden_MarshalError(t *testing.T) { - t.Parallel() - err := WriteGolden(filepath.Join(t.TempDir(), "doc.json"), badExtDoc()) - require.Error(t, err) - assert.Contains(t, err.Error(), "marshal golden") -} - -func TestWriteGolden_MkdirError(t *testing.T) { - t.Parallel() - dir := t.TempDir() - // A regular file where a parent directory is expected makes MkdirAll fail. - blocker := filepath.Join(dir, "file") - require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) - path := filepath.Join(blocker, "sub", "doc.json") - - err := WriteGolden(path, &ir.Document{IRVersion: ir.IRVersion, Name: "x"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "mkdir for golden") -} - -func TestWriteGolden_WriteError(t *testing.T) { - t.Parallel() - dir := t.TempDir() - // The target path is itself a directory, so WriteFile cannot write to it. - path := filepath.Join(dir, "asdir") - require.NoError(t, os.Mkdir(path, 0o755)) - - err := WriteGolden(path, &ir.Document{IRVersion: ir.IRVersion, Name: "x"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "write golden") -} - -// TestCompareGolden_RejectsBadInput drives the preconditions. They sit ahead of -// the -update branch, so neither case needs the flag toggled to reach them. -// -// The nil-document case is a regression test with a written-out failure mode: the -// golden on disk is the "null" an unguarded WriteGolden produces, and an -// unguarded compareGolden encodes nil to the same four bytes and reports a clean -// match. The abort is the only thing separating that from a real comparison. -// -// The stamp cases would compare clean against the golden on disk, which -// leaves the stamp out, but for the refusal. -func TestCompareGolden_RejectsBadInput(t *testing.T) { - dir := t.TempDir() - nullGolden := filepath.Join(dir, "null.golden.json") - require.NoError(t, os.WriteFile(nullGolden, []byte("null\n"), 0o644)) - golden := filepath.Join(dir, "doc.golden.json") - require.NoError(t, WriteGolden(golden, &ir.Document{IRVersion: ir.IRVersion, Name: "x"})) - - cases := map[string]struct { - path string - doc *ir.Document - wantFatal string - }{ - "empty path": {path: "", doc: &ir.Document{Name: "x"}, wantFatal: "empty golden path"}, - "nil document": {path: nullGolden, doc: nil, wantFatal: "nil document"}, - "unstamped document": {path: golden, doc: &ir.Document{Name: "x"}, - wantFatal: `document stamped irVersion "", not ir.IRVersion ` + ir.IRVersion}, - "document stamped with another version": {path: golden, doc: &ir.Document{IRVersion: "0.0.1", Name: "x"}, - wantFatal: `document stamped irVersion "0.0.1", not ir.IRVersion ` + ir.IRVersion}, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - rec := runCompare(tc.path, tc.doc) - require.True(t, rec.aborted, "a precondition failure must abort via Fatalf") - assert.Contains(t, rec.fatalf, tc.wantFatal) - assert.Empty(t, rec.errorf, "the abort happens before any comparison") - }) - } -} - -// TestCompareGolden_UpdateRefusesAnUnstampedDocument holds the stamp check ahead -// of -update, so rewriting goldens cannot store a snapshot of a document that -// was never stamped. -func TestCompareGolden_UpdateRefusesAnUnstampedDocument(t *testing.T) { - // Not parallel: toggles the shared -update flag. - path := filepath.Join(t.TempDir(), "doc.golden.json") - - withUpdate(t, true, func() { - rec := runCompare(path, &ir.Document{Name: "x"}) - require.True(t, rec.aborted, "an unstamped document must abort via Fatalf") - assert.Contains(t, rec.fatalf, "not ir.IRVersion") - }) - _, err := os.Stat(path) - assert.ErrorIs(t, err, os.ErrNotExist, "a refused update leaves no golden behind") -} - -func TestCompareGolden_HelperIsCalled(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "doc.golden.json") - require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - - withUpdate(t, false, func() { - rec := runCompare(path, &ir.Document{IRVersion: ir.IRVersion}) - assert.GreaterOrEqual(t, rec.helperCalls, 1, "compareGolden marks itself a helper") - }) -} - -func TestFill_RefusesAnythingButANonNilPointer(t *testing.T) { - t.Parallel() - for name, v := range map[string]any{ - "a value": ir.Document{}, - "a nil pointer": (*ir.Document)(nil), - "nothing": nil, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - rec := &recordingT{} - done := make(chan struct{}) - go func() { - defer close(done) - fill(rec, v) - }() - <-done - assert.True(t, rec.aborted, "fill must stop rather than fill what it cannot reach") - assert.Contains(t, rec.fatalf, "want a non-nil pointer") - }) - } -} diff --git a/ir/irtest/golden_test.go b/ir/irtest/golden_test.go index c5459c74..e3b0ceed 100644 --- a/ir/irtest/golden_test.go +++ b/ir/irtest/golden_test.go @@ -1,8 +1,10 @@ package irtest_test import ( + "fmt" "os" "path/filepath" + "runtime" "testing" "github.com/google/go-cmp/cmp" @@ -140,3 +142,239 @@ func TestReadGolden_Refusals(t *testing.T) { }) } } + +// recordingT is a stub testingT that records Fatalf/Errorf instead of aborting +// the real test. Fatalf mimics *testing.T by aborting the calling goroutine via +// runtime.Goexit, so code after the call does not run. +type recordingT struct { + helperCalls int + fatalf string + errorf string + aborted bool +} + +func (r *recordingT) Helper() { r.helperCalls++ } + +func (r *recordingT) Fatalf(format string, args ...any) { + r.fatalf = fmt.Sprintf(format, args...) + r.aborted = true + runtime.Goexit() +} + +func (r *recordingT) Errorf(format string, args ...any) { + r.errorf = fmt.Sprintf(format, args...) +} + +// runCompare drives compareGolden in a goroutine so a Goexit from Fatalf unwinds +// only that goroutine, then returns the populated recorder. +func runCompare(goldenPath string, doc *ir.Document) *recordingT { + rec := &recordingT{} + done := make(chan struct{}) + go func() { + defer close(done) + irtest.CompareGoldenOn(rec, goldenPath, doc) + }() + <-done + return rec +} + +// badExtDoc marshals with an error: an invalid jsontext.Value in Unmodeled +// makes encodeGolden's json.Marshal fail. +func badExtDoc() *ir.Document { + return &ir.Document{IRVersion: ir.IRVersion, Unmodeled: ir.Unmodeled{"x": {Value: ir.RawValue("{invalid")}}} +} + +// withUpdate sets the -update flag for the duration of fn and restores it. +func withUpdate(t *testing.T, v bool, fn func()) { + t.Helper() + prev := *irtest.UpdateFlag + *irtest.UpdateFlag = v + defer func() { *irtest.UpdateFlag = prev }() + fn() +} + +func TestCompareGolden_UpdateWritesAndReturns(t *testing.T) { + // Not parallel: toggles the shared -update flag. + dir := t.TempDir() + path := filepath.Join(dir, "nested", "doc.golden.json") + doc := &ir.Document{IRVersion: ir.IRVersion, Name: "u"} + + withUpdate(t, true, func() { + rec := runCompare(path, doc) + require.False(t, rec.aborted, "successful update must not abort") + assert.Empty(t, rec.fatalf) + assert.Empty(t, rec.errorf) + }) + + written, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotEmpty(t, written) +} + +func TestCompareGolden_UpdateWriteErrorFatals(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "doc.golden.json") + + withUpdate(t, true, func() { + rec := runCompare(path, badExtDoc()) + require.True(t, rec.aborted, "write failure must abort via Fatalf") + assert.Contains(t, rec.fatalf, "update golden:") + }) +} + +func TestCompareGolden_MissingGoldenFatals(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "absent.golden.json") + doc := &ir.Document{IRVersion: ir.IRVersion, Name: "x"} + + withUpdate(t, false, func() { + rec := runCompare(path, doc) + require.True(t, rec.aborted, "missing golden must abort via Fatalf") + assert.Contains(t, rec.fatalf, "run with -update to create") + }) +} + +func TestCompareGolden_MarshalErrorFatals(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "doc.golden.json") + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + + withUpdate(t, false, func() { + rec := runCompare(path, badExtDoc()) + require.True(t, rec.aborted, "marshal failure must abort via Fatalf") + assert.Contains(t, rec.fatalf, "marshal document:") + }) +} + +func TestCompareGolden_MismatchErrorf(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "doc.golden.json") + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + doc := &ir.Document{IRVersion: ir.IRVersion, Name: "different"} + + withUpdate(t, false, func() { + rec := runCompare(path, doc) + require.False(t, rec.aborted, "a diff mismatch reports via Errorf, not Fatalf") + assert.Contains(t, rec.errorf, "golden mismatch") + }) +} + +func TestWriteGolden_MarshalError(t *testing.T) { + t.Parallel() + err := irtest.WriteGolden(filepath.Join(t.TempDir(), "doc.json"), badExtDoc()) + require.Error(t, err) + assert.Contains(t, err.Error(), "marshal golden") +} + +func TestWriteGolden_MkdirError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // A regular file where a parent directory is expected makes MkdirAll fail. + blocker := filepath.Join(dir, "file") + require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) + path := filepath.Join(blocker, "sub", "doc.json") + + err := irtest.WriteGolden(path, &ir.Document{IRVersion: ir.IRVersion, Name: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "mkdir for golden") +} + +func TestWriteGolden_WriteError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // The target path is itself a directory, so WriteFile cannot write to it. + path := filepath.Join(dir, "asdir") + require.NoError(t, os.Mkdir(path, 0o755)) + + err := irtest.WriteGolden(path, &ir.Document{IRVersion: ir.IRVersion, Name: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "write golden") +} + +// TestCompareGolden_RejectsBadInput drives the preconditions. They sit ahead of +// the -update branch, so neither case needs the flag toggled to reach them. +// +// The nil-document case is a regression test with a written-out failure mode: the +// golden on disk is the "null" an unguarded WriteGolden produces, and an +// unguarded compareGolden encodes nil to the same four bytes and reports a clean +// match. The abort is the only thing separating that from a real comparison. +// +// The stamp cases would compare clean against the golden on disk, which +// leaves the stamp out, but for the refusal. +func TestCompareGolden_RejectsBadInput(t *testing.T) { + dir := t.TempDir() + nullGolden := filepath.Join(dir, "null.golden.json") + require.NoError(t, os.WriteFile(nullGolden, []byte("null\n"), 0o644)) + golden := filepath.Join(dir, "doc.golden.json") + require.NoError(t, irtest.WriteGolden(golden, &ir.Document{IRVersion: ir.IRVersion, Name: "x"})) + + cases := map[string]struct { + path string + doc *ir.Document + wantFatal string + }{ + "empty path": {path: "", doc: &ir.Document{Name: "x"}, wantFatal: "empty golden path"}, + "nil document": {path: nullGolden, doc: nil, wantFatal: "nil document"}, + "unstamped document": {path: golden, doc: &ir.Document{Name: "x"}, + wantFatal: `document stamped irVersion "", not ir.IRVersion ` + ir.IRVersion}, + "document stamped with another version": {path: golden, doc: &ir.Document{IRVersion: "0.0.1", Name: "x"}, + wantFatal: `document stamped irVersion "0.0.1", not ir.IRVersion ` + ir.IRVersion}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + rec := runCompare(tc.path, tc.doc) + require.True(t, rec.aborted, "a precondition failure must abort via Fatalf") + assert.Contains(t, rec.fatalf, tc.wantFatal) + assert.Empty(t, rec.errorf, "the abort happens before any comparison") + }) + } +} + +// TestCompareGolden_UpdateRefusesAnUnstampedDocument holds the stamp check ahead +// of -update, so rewriting goldens cannot store a snapshot of a document that +// was never stamped. +func TestCompareGolden_UpdateRefusesAnUnstampedDocument(t *testing.T) { + // Not parallel: toggles the shared -update flag. + path := filepath.Join(t.TempDir(), "doc.golden.json") + + withUpdate(t, true, func() { + rec := runCompare(path, &ir.Document{Name: "x"}) + require.True(t, rec.aborted, "an unstamped document must abort via Fatalf") + assert.Contains(t, rec.fatalf, "not ir.IRVersion") + }) + _, err := os.Stat(path) + assert.ErrorIs(t, err, os.ErrNotExist, "a refused update leaves no golden behind") +} + +func TestCompareGolden_HelperIsCalled(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "doc.golden.json") + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + + withUpdate(t, false, func() { + rec := runCompare(path, &ir.Document{IRVersion: ir.IRVersion}) + assert.GreaterOrEqual(t, rec.helperCalls, 1, "compareGolden marks itself a helper") + }) +} + +func TestFill_RefusesAnythingButANonNilPointer(t *testing.T) { + t.Parallel() + for name, v := range map[string]any{ + "a value": ir.Document{}, + "a nil pointer": (*ir.Document)(nil), + "nothing": nil, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + rec := &recordingT{} + done := make(chan struct{}) + go func() { + defer close(done) + irtest.FillOn(rec, v) + }() + <-done + assert.True(t, rec.aborted, "fill must stop rather than fill what it cannot reach") + assert.Contains(t, rec.fatalf, "want a non-nil pointer") + }) + } +} diff --git a/ir/irtest/main_test.go b/ir/irtest/main_test.go index afa855da..4adb06f0 100644 --- a/ir/irtest/main_test.go +++ b/ir/irtest/main_test.go @@ -1,4 +1,4 @@ -package irtest +package irtest_test import ( "testing" diff --git a/ir/irtest/repo_internal_test.go b/ir/irtest/repo_internal_test.go deleted file mode 100644 index 30a7e756..00000000 --- a/ir/irtest/repo_internal_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package irtest - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" -) - -// runRootFrom drives rootFrom in a goroutine so a Goexit from Fatalf unwinds -// only that goroutine, then returns the recorder and what rootFrom returned. -func runRootFrom(getwd func() (string, error)) (*recordingT, string) { - rec := &recordingT{} - var got string - done := make(chan struct{}) - go func() { - defer close(done) - got = rootFrom(rec, getwd) - }() - <-done - return rec, got -} - -// TestRootFrom_RefusesAWorkingDirectoryItCannotRead pins the first failure: -// the walk has nowhere to start, so the test stops rather than guessing one. -func TestRootFrom_RefusesAWorkingDirectoryItCannotRead(t *testing.T) { - t.Parallel() - rec, got := runRootFrom(func() (string, error) { return "", errors.New("gone") }) - assert.True(t, rec.aborted) - assert.Contains(t, rec.fatalf, "reading the working directory: gone") - assert.Empty(t, got) -} - -// TestRootFrom_RefusesADirectoryOutsideAnyModule pins the second: a walk that -// reaches the filesystem root without a go.mod stops there. -func TestRootFrom_RefusesADirectoryOutsideAnyModule(t *testing.T) { - t.Parallel() - outside := t.TempDir() - rec, got := runRootFrom(func() (string, error) { return outside, nil }) - assert.True(t, rec.aborted) - assert.Contains(t, rec.fatalf, "no go.mod in or above the working directory") - assert.Empty(t, got) -} diff --git a/ir/irtest/repo_test.go b/ir/irtest/repo_test.go index 42711eaa..ac0ce962 100644 --- a/ir/irtest/repo_test.go +++ b/ir/irtest/repo_test.go @@ -1,6 +1,7 @@ package irtest_test import ( + "errors" "os" "path/filepath" "strings" @@ -23,3 +24,38 @@ func TestRepoRoot_FindsTheModuleAboveTheWorkingDirectory(t *testing.T) { require.NoError(t, err) assert.True(t, strings.HasPrefix(wd, root), "%s lies under %s", wd, root) } + +// runRootFrom drives rootFrom in a goroutine so a Goexit from Fatalf unwinds +// only that goroutine, then returns the recorder and what rootFrom returned. +func runRootFrom(getwd func() (string, error)) (*recordingT, string) { + rec := &recordingT{} + var got string + done := make(chan struct{}) + go func() { + defer close(done) + got = irtest.RootFrom(rec, getwd) + }() + <-done + return rec, got +} + +// TestRootFrom_RefusesAWorkingDirectoryItCannotRead pins the first failure: +// the walk has nowhere to start, so the test stops rather than guessing one. +func TestRootFrom_RefusesAWorkingDirectoryItCannotRead(t *testing.T) { + t.Parallel() + rec, got := runRootFrom(func() (string, error) { return "", errors.New("gone") }) + assert.True(t, rec.aborted) + assert.Contains(t, rec.fatalf, "reading the working directory: gone") + assert.Empty(t, got) +} + +// TestRootFrom_RefusesADirectoryOutsideAnyModule pins the second: a walk that +// reaches the filesystem root without a go.mod stops there. +func TestRootFrom_RefusesADirectoryOutsideAnyModule(t *testing.T) { + t.Parallel() + outside := t.TempDir() + rec, got := runRootFrom(func() (string, error) { return outside, nil }) + assert.True(t, rec.aborted) + assert.Contains(t, rec.fatalf, "no go.mod in or above the working directory") + assert.Empty(t, got) +} diff --git a/pass/export_test.go b/pass/export_test.go new file mode 100644 index 00000000..8df396f6 --- /dev/null +++ b/pass/export_test.go @@ -0,0 +1,7 @@ +package pass + +// CheckDanglingRefs is checkDanglingRefs, for the external tests. +var CheckDanglingRefs = checkDanglingRefs + +// CollectTypeIDs is collectTypeIDs, for the external tests. +var CollectTypeIDs = collectTypeIDs diff --git a/pass/validate_refs_internal_test.go b/pass/validate_refs_internal_test.go deleted file mode 100644 index 4e0632c9..00000000 --- a/pass/validate_refs_internal_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package pass // internal test package — exercises the collectors and checkDanglingRefs directly - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/ir" - "github.com/dexpace/morphic/ir/irtest" -) - -// TestCollectTypeIDs_DeepValueTreeIsTruncated drives what this pass does with a -// truncated walk: a value tree nested past the shared cap must not be silently -// under-checked, so the flag comes back out of the collector and Validate turns -// it into a diagnostic rather than claiming the document is referentially closed. -// The bound itself is ir.MaxWalkDepth's to hold; this is the reporting half. -func TestCollectTypeIDs_DeepValueTreeIsTruncated(t *testing.T) { - t.Parallel() - v := ir.Value{Kind: ir.ValueNull} - for range ir.MaxWalkDepth { - v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} - } - m := &ir.Model{ID: "t/m", Examples: []ir.Example{{Value: &v}}} - doc := &ir.Document{Types: ir.TypeRegistry{m.ID: m}} - - _, truncated := collectTypeIDs(doc, "doc") - assert.True(t, truncated, "a tree nested past the cap must report truncation") - - diags := danglingRefs(doc) - require.NotEmpty(t, diags) - assert.Equal(t, "ir/walk-truncated", diags[0].Code) - assert.Equal(t, ir.SeverityError, diags[0].Severity) -} - -// TestCollectTypeIDs_UnmodeledBytesYieldNoReference states the result the byte -// payloads must produce: a preserved JSON blob is opaque data, so bytes that -// happen to spell a type ID are not a reference. -func TestCollectTypeIDs_UnmodeledBytesYieldNoReference(t *testing.T) { - t.Parallel() - doc := &ir.Document{Unmodeled: ir.Unmodeled{"openapi:x-thing": { - Reason: ir.ReasonVendorExtension, - Value: ir.RawValue(`"t/ghost/in-bytes"`), - }}} - sites, truncated := collectTypeIDs(doc, "doc") - assert.False(t, truncated) - assert.Empty(t, sites) -} - -// TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations holds the one direction -// a declaration-derived registry can be wrong in. The operation the shallow -// reference names is declared, but past the walk cap, so a registry built from -// what the walk reached answers "not declared" for a node that is — and a false -// dangling error is worse than the silence it replaced. The registries Document -// keys maps by are read off the field rather than off the walk and are unaffected. -func TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations(t *testing.T) { - t.Parallel() - deep, target := irtest.NestedOverloadDoc(ir.MaxWalkDepth) - diags := danglingRefs(deep) - require.NotEmpty(t, diags) - assert.Equal(t, "ir/walk-truncated", diags[0].Code) - for _, d := range diags { - assert.NotEqual(t, "ir/dangling-op-ref", d.Code, - "%s is declared past the cap, not undeclared", target) - } - - // The other half: the same shape within the cap resolves, so the case above - // is not passing on a walk that collects no reference at all. - shallow, _ := irtest.NestedOverloadDoc(0) - assert.Empty(t, danglingRefs(shallow)) -} - -// TestCheckDanglingRefs_NilTypeDefIsNotFollowed pins report-only behaviour on -// a malformed registry: a nil entry is skipped rather than dereferenced, so the -// pass reports what it can instead of panicking. -func TestCheckDanglingRefs_NilTypeDefIsNotFollowed(t *testing.T) { - t.Parallel() - doc := &ir.Document{Types: ir.TypeRegistry{"t/nil": nil}} - assert.Empty(t, danglingRefs(doc)) -} - -// danglingRefs runs checkDanglingRefs over the declarations of doc, as Validate -// does. -func danglingRefs(doc *ir.Document) []ir.Diagnostic { - decls, truncated := ir.DeclaredIDs(doc) - return checkDanglingRefs(doc, decls, truncated) -} diff --git a/pass/validate_refs_test.go b/pass/validate_refs_test.go index 45e66ab3..6e88d393 100644 --- a/pass/validate_refs_test.go +++ b/pass/validate_refs_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/ir/irverify" "github.com/dexpace/morphic/pass" ) @@ -828,3 +829,79 @@ func TestValidate_PerOperationAuthOverride(t *testing.T) { require.NotEmpty(t, diags) assert.Contains(t, codes(diags), "ir/dangling-auth-ref") } + +// TestCollectTypeIDs_DeepValueTreeIsTruncated drives what this pass does with a +// truncated walk: a value tree nested past the shared cap must not be silently +// under-checked, so the flag comes back out of the collector and Validate turns +// it into a diagnostic rather than claiming the document is referentially closed. +// The bound itself is ir.MaxWalkDepth's to hold; this is the reporting half. +func TestCollectTypeIDs_DeepValueTreeIsTruncated(t *testing.T) { + t.Parallel() + v := ir.Value{Kind: ir.ValueNull} + for range ir.MaxWalkDepth { + v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} + } + m := &ir.Model{ID: "t/m", Examples: []ir.Example{{Value: &v}}} + doc := &ir.Document{Types: ir.TypeRegistry{m.ID: m}} + + _, truncated := pass.CollectTypeIDs(doc, "doc") + assert.True(t, truncated, "a tree nested past the cap must report truncation") + + diags := danglingRefs(doc) + require.NotEmpty(t, diags) + assert.Equal(t, "ir/walk-truncated", diags[0].Code) + assert.Equal(t, ir.SeverityError, diags[0].Severity) +} + +// TestCollectTypeIDs_UnmodeledBytesYieldNoReference states the result the byte +// payloads must produce: a preserved JSON blob is opaque data, so bytes that +// happen to spell a type ID are not a reference. +func TestCollectTypeIDs_UnmodeledBytesYieldNoReference(t *testing.T) { + t.Parallel() + doc := &ir.Document{Unmodeled: ir.Unmodeled{"openapi:x-thing": { + Reason: ir.ReasonVendorExtension, + Value: ir.RawValue(`"t/ghost/in-bytes"`), + }}} + sites, truncated := pass.CollectTypeIDs(doc, "doc") + assert.False(t, truncated) + assert.Empty(t, sites) +} + +// TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations holds the one direction +// a declaration-derived registry can be wrong in. The operation the shallow +// reference names is declared, but past the walk cap, so a registry built from +// what the walk reached answers "not declared" for a node that is — and a false +// dangling error is worse than the silence it replaced. The registries Document +// keys maps by are read off the field rather than off the walk and are unaffected. +func TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations(t *testing.T) { + t.Parallel() + deep, target := irtest.NestedOverloadDoc(ir.MaxWalkDepth) + diags := danglingRefs(deep) + require.NotEmpty(t, diags) + assert.Equal(t, "ir/walk-truncated", diags[0].Code) + for _, d := range diags { + assert.NotEqual(t, "ir/dangling-op-ref", d.Code, + "%s is declared past the cap, not undeclared", target) + } + + // The other half: the same shape within the cap resolves, so the case above + // is not passing on a walk that collects no reference at all. + shallow, _ := irtest.NestedOverloadDoc(0) + assert.Empty(t, danglingRefs(shallow)) +} + +// TestCheckDanglingRefs_NilTypeDefIsNotFollowed pins report-only behaviour on +// a malformed registry: a nil entry is skipped rather than dereferenced, so the +// pass reports what it can instead of panicking. +func TestCheckDanglingRefs_NilTypeDefIsNotFollowed(t *testing.T) { + t.Parallel() + doc := &ir.Document{Types: ir.TypeRegistry{"t/nil": nil}} + assert.Empty(t, danglingRefs(doc)) +} + +// danglingRefs runs checkDanglingRefs over the declarations of doc, as Validate +// does. +func danglingRefs(doc *ir.Document) []ir.Diagnostic { + decls, truncated := ir.DeclaredIDs(doc) + return pass.CheckDanglingRefs(doc, decls, truncated) +} From b7054e87cda21b4949a0b8b4163b38dd1ac7c4a0 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 02:43:31 +0300 Subject: [PATCH 03/18] test: name every in-package test file with _internal Nine test files inside their package sat beside external tests under a plain x_test.go name, so a reader could not tell from the name which side of the package boundary a test was on: the root's budgets and helpers files, harness's order tests, and six in irverify. They take the _internal_test.go spelling the rest of the repository uses for that. The lint exclusion naming budgets_test.go, and the comments that named three of the files, follow. One of those comments is in a conformance spec, whose golden records the spec's hash, so the golden's hash moves with it; the comment now names the function it means, reverseMappings, instead of a file. --- .golangci.yml | 2 +- .../openapi/{budgets_test.go => budgets_internal_test.go} | 0 .../openapi/{helpers_test.go => helpers_internal_test.go} | 0 internal/harness/{order_test.go => order_internal_test.go} | 0 ir/irverify/{declared_test.go => declared_internal_test.go} | 0 .../{duplicates_test.go => duplicates_internal_test.go} | 0 ir/irverify/emptyrefs_internal_test.go | 4 ++-- ir/irverify/{indices_test.go => indices_internal_test.go} | 0 ir/irverify/{irsource_test.go => irsource_internal_test.go} | 0 ...alkchecks_test.go => irverify_walkchecks_internal_test.go} | 0 ir/irverify/{refs_test.go => refs_internal_test.go} | 0 .../openapi/allof-narrowing-redeclaration.golden.json | 2 +- .../conformance/openapi/allof-narrowing-redeclaration.yaml | 4 ++-- 13 files changed, 6 insertions(+), 6 deletions(-) rename compilers/openapi/{budgets_test.go => budgets_internal_test.go} (100%) rename compilers/openapi/{helpers_test.go => helpers_internal_test.go} (100%) rename internal/harness/{order_test.go => order_internal_test.go} (100%) rename ir/irverify/{declared_test.go => declared_internal_test.go} (100%) rename ir/irverify/{duplicates_test.go => duplicates_internal_test.go} (100%) rename ir/irverify/{indices_test.go => indices_internal_test.go} (100%) rename ir/irverify/{irsource_test.go => irsource_internal_test.go} (100%) rename ir/irverify/{irverify_walkchecks_test.go => irverify_walkchecks_internal_test.go} (100%) rename ir/irverify/{refs_test.go => refs_internal_test.go} (100%) diff --git a/.golangci.yml b/.golangci.yml index 05329ae1..ea5917c9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -202,7 +202,7 @@ linters: # liveForCalls is a context.Context: it embeds the one it wraps to answer Err # on a schedule, which is how a context decorator is written, not a context # held for later. - - path: compilers/openapi/budgets_test\.go + - path: compilers/openapi/budgets_internal_test\.go linters: - containedctx source: ^\s*context\.Context$ diff --git a/compilers/openapi/budgets_test.go b/compilers/openapi/budgets_internal_test.go similarity index 100% rename from compilers/openapi/budgets_test.go rename to compilers/openapi/budgets_internal_test.go diff --git a/compilers/openapi/helpers_test.go b/compilers/openapi/helpers_internal_test.go similarity index 100% rename from compilers/openapi/helpers_test.go rename to compilers/openapi/helpers_internal_test.go diff --git a/internal/harness/order_test.go b/internal/harness/order_internal_test.go similarity index 100% rename from internal/harness/order_test.go rename to internal/harness/order_internal_test.go diff --git a/ir/irverify/declared_test.go b/ir/irverify/declared_internal_test.go similarity index 100% rename from ir/irverify/declared_test.go rename to ir/irverify/declared_internal_test.go diff --git a/ir/irverify/duplicates_test.go b/ir/irverify/duplicates_internal_test.go similarity index 100% rename from ir/irverify/duplicates_test.go rename to ir/irverify/duplicates_internal_test.go diff --git a/ir/irverify/emptyrefs_internal_test.go b/ir/irverify/emptyrefs_internal_test.go index 8941bb6a..09332197 100644 --- a/ir/irverify/emptyrefs_internal_test.go +++ b/ir/irverify/emptyrefs_internal_test.go @@ -72,7 +72,7 @@ func mentionsIDClass(classes map[*types.TypeName]bool, typ types.Type) bool { // declaredIDFields returns "Type.Field" for every struct field in the ir package // whose type is, or is a pointer/slice/map (key or value) of, one of idTypes' -// identity classes. Modeled on declaredIntegerFields in indices_test.go. +// identity classes. Modeled on declaredIntegerFields in indices_internal_test.go. func declaredIDFields(t *testing.T) []string { t.Helper() classes := idClassTypes(t) @@ -159,7 +159,7 @@ func identityOnBothSides(classes map[*types.TypeName]bool, typ types.Type) bool } // TestIDTypes_MatchTheIdentityClasses holds idTypes to naming exactly the -// types identityClasses (duplicates_test.go) already calls an identity, so +// types identityClasses (duplicates_internal_test.go) already calls an identity, so // this package's second table of reference classes cannot silently drift from // the first: idTypes is what checkEmptyRefs classifies by, identityClasses is // what the rest of the walk-based checks were audited against. diff --git a/ir/irverify/indices_test.go b/ir/irverify/indices_internal_test.go similarity index 100% rename from ir/irverify/indices_test.go rename to ir/irverify/indices_internal_test.go diff --git a/ir/irverify/irsource_test.go b/ir/irverify/irsource_internal_test.go similarity index 100% rename from ir/irverify/irsource_test.go rename to ir/irverify/irsource_internal_test.go diff --git a/ir/irverify/irverify_walkchecks_test.go b/ir/irverify/irverify_walkchecks_internal_test.go similarity index 100% rename from ir/irverify/irverify_walkchecks_test.go rename to ir/irverify/irverify_walkchecks_internal_test.go diff --git a/ir/irverify/refs_test.go b/ir/irverify/refs_internal_test.go similarity index 100% rename from ir/irverify/refs_test.go rename to ir/irverify/refs_internal_test.go diff --git a/testdata/conformance/openapi/allof-narrowing-redeclaration.golden.json b/testdata/conformance/openapi/allof-narrowing-redeclaration.golden.json index 95386fb9..71148a93 100644 --- a/testdata/conformance/openapi/allof-narrowing-redeclaration.golden.json +++ b/testdata/conformance/openapi/allof-narrowing-redeclaration.golden.json @@ -1778,7 +1778,7 @@ { "format": "openapi@3.1", "path": "allof-narrowing-redeclaration.yaml", - "hash": "1a6116cb210d73f2bfa6af66ead7ee5f275699e66b063efde8599113194ece83" + "hash": "ced25f71b401295798dbc44080f73289934ab0eb0d35441ea29b690808d90c7f" } ] } diff --git a/testdata/conformance/openapi/allof-narrowing-redeclaration.yaml b/testdata/conformance/openapi/allof-narrowing-redeclaration.yaml index f97b4cfb..2e5dbb3a 100644 --- a/testdata/conformance/openapi/allof-narrowing-redeclaration.yaml +++ b/testdata/conformance/openapi/allof-narrowing-redeclaration.yaml @@ -12,8 +12,8 @@ components: # and RefusedLateDefault models). Each pair is declared in both orders, and # each three-branch chain in all three positions of its narrowest member. # The orders are separate inputs to one rule: the harness's order oracle - # keeps sequence order (internal/harness/order_test.go), so this fixture is - # the corpus's only guard on branch order. + # keeps sequence order (reverseMappings reverses mappings only), so this + # fixture is the corpus's only guard on branch order. NumberIntegerBroadFirst: allOf: - type: object From 9f4229940cd3bbb13b5be6e9b8238900a89c97e1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 02:47:51 +0300 Subject: [PATCH 04/18] test: declare schema's lowering fixture once schema's tests drove one lowering through a fixture, the lowerer with loweredFor, lowerSpec, lowererOver, newRawLowerer and its document method, and declared it twice, byte for byte, once on each side of the package boundary: in-package tests cannot see helpers an external test file declares, and external tests cannot see unexported ones. It is now declared once, exported, in schema's export_test.go, which both sides compile with: schema.LowerSpec from outside the package, LowerSpec from inside it. Its fields are exported for the same reason. Every use was rewritten by its type information, not its spelling, and the package's tests and subtests are the same before and after. operation had two helpers named lowerServiceSpec doing different things: the in-package one drives LowerService through the fixture, the external one compiles the whole spec. The external one is now compileService. --- .../internal/operation/content_test.go | 18 +-- .../internal/operation/encoding_test.go | 12 +- .../internal/operation/examples_test.go | 12 +- .../openapi/internal/operation/groups_test.go | 32 ++-- .../internal/operation/headers_test.go | 20 +-- .../internal/operation/helpers_test.go | 4 +- .../internal/operation/multipart_test.go | 32 ++-- .../internal/operation/operations_test.go | 6 +- .../openapi/internal/operation/params_test.go | 54 +++---- .../internal/operation/pathkeys_test.go | 12 +- .../internal/operation/requestbody_test.go | 14 +- .../internal/operation/responses_test.go | 32 ++-- .../internal/operation/typespelling_test.go | 8 +- .../internal/schema/allof_internal_test.go | 14 +- .../openapi/internal/schema/allof_test.go | 141 +++++++++--------- .../openapi/internal/schema/array_test.go | 20 +-- .../internal/schema/branch_internal_test.go | 6 +- .../internal/schema/census_internal_test.go | 8 +- .../openapi/internal/schema/census_test.go | 15 +- .../schema/constraints_internal_test.go | 12 +- .../internal/schema/constraints_test.go | 21 +-- .../schema/declaration_internal_test.go | 22 +-- .../internal/schema/declaration_test.go | 12 +- .../schema/discriminator_internal_test.go | 88 +++++------ .../internal/schema/discriminator_test.go | 29 ++-- .../schema/dynamicref_internal_test.go | 18 +-- .../openapi/internal/schema/enum_test.go | 51 +++---- .../openapi/internal/schema/export_test.go | 81 ++++++++++ .../internal/schema/helpers_internal_test.go | 70 --------- .../openapi/internal/schema/helpers_test.go | 95 ++---------- .../internal/schema/hoist_internal_test.go | 2 +- .../openapi/internal/schema/hoist_test.go | 7 +- .../openapi/internal/schema/model_test.go | 19 +-- .../internal/schema/nulls_internal_test.go | 2 +- .../openapi/internal/schema/nulls_test.go | 17 ++- .../openapi/internal/schema/property_test.go | 32 ++-- .../internal/schema/refsite_internal_test.go | 46 +++--- .../openapi/internal/schema/refsite_test.go | 28 ++-- .../openapi/internal/schema/scalar_test.go | 5 +- .../internal/schema/schema_budgets_test.go | 16 +- .../internal/schema/schema_internal_test.go | 6 +- .../openapi/internal/schema/schema_test.go | 38 ++--- .../internal/schema/union_internal_test.go | 14 +- .../openapi/internal/schema/union_test.go | 63 ++++---- 44 files changed, 602 insertions(+), 652 deletions(-) create mode 100644 compilers/openapi/internal/schema/export_test.go diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index e20d9856..8236bea5 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -31,7 +31,7 @@ func TestContent_AllMediaTypesKeptInOrder(t *testing.T) { application/xml: {schema: {type: object, properties: {n: {type: string}}}} responses: {"201": {description: created}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request) @@ -73,7 +73,7 @@ func TestContent_MixedCaseFormContentKeepsEncoding(t *testing.T) { contentType: application/json responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] assert.Equal(t, tc.mt, content.MediaType, "the declared spelling is what the IR records") @@ -114,7 +114,7 @@ func TestContent_MixedCaseURLEncodedKeepsPartEncoding(t *testing.T) { tags: {type: array, items: {type: string}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] assert.Equal(t, tc.mt, content.MediaType, "the declared spelling is what the IR records") @@ -138,7 +138,7 @@ func TestContent_BinaryOctetStreamBody(t *testing.T) { schema: {type: string, format: binary} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request) @@ -174,7 +174,7 @@ func TestContent_MixedCaseOctetStreamBodyIsFile(t *testing.T) { "` + tc.mt + `": {} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] require.NotNil(t, content.File, "an octet-stream body with no schema is a file body") @@ -202,7 +202,7 @@ components: schemas: Blob: {type: string, format: binary} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] require.NotNil(t, content.File, "binary body behind a $ref lowers to a FileInfo") @@ -479,7 +479,7 @@ func TestContent_InlineSchemaKeepsItsUseSiteHint(t *testing.T) { // lost, and nothing claims it was kept. func TestContent_UnkeepableMediaTypeRefIsReported(t *testing.T) { t.Parallel() - _, _, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.2.0", ` /a: + _, _, diags := compileService(t, openapitest.PathsSpecVer("3.2.0", ` /a: get: operationId: a responses: @@ -505,7 +505,7 @@ func TestContent_MediaTypeRefIsKeptNotFollowed(t *testing.T) { for _, version := range []string{"3.2.0", "3.1.0"} { t.Run(version, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer(version, ` /a: + _, svc, diags := compileService(t, openapitest.PathsSpecVer(version, ` /a: get: operationId: a parameters: @@ -548,7 +548,7 @@ func TestContent_MediaTypeDescriptionIsKept(t *testing.T) { for _, version := range []string{"3.2.0", "3.1.0"} { t.Run(version, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer(version, ` /a: + _, svc, diags := compileService(t, openapitest.PathsSpecVer(version, ` /a: get: operationId: a parameters: diff --git a/compilers/openapi/internal/operation/encoding_test.go b/compilers/openapi/internal/operation/encoding_test.go index aae52a2f..54b914b8 100644 --- a/compilers/openapi/internal/operation/encoding_test.go +++ b/compilers/openapi/internal/operation/encoding_test.go @@ -21,7 +21,7 @@ import ( // on the owning Content keyed by the part they govern. func TestEncoding_AllowReservedAndExtensionsKeptOnTheContent(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.1.0", encodingSpec)) + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.1.0", encodingSpec)) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request) @@ -48,7 +48,7 @@ func TestEncoding_AllowReservedAndExtensionsKeptOnTheContent(t *testing.T) { // default the accessor would hand back and calling it a source fact. func TestEncoding_AbsentAllowReservedRecordsNothing(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.1.0", ` /form: + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.1.0", ` /form: post: operationId: postForm requestBody: @@ -79,7 +79,7 @@ func TestEncoding_AbsentAllowReservedRecordsNothing(t *testing.T) { // check entirely: moving the read below it left the whole suite green. func TestEncoding_OnlyUnhomedFieldsOutliveTheEmptyPartEncoding(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.1.0", ` /form: + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.1.0", ` /form: post: operationId: postForm requestBody: @@ -117,7 +117,7 @@ func TestEncoding_OnlyUnhomedFieldsOutliveTheEmptyPartEncoding(t *testing.T) { // them. func TestEncoding_PartNameWithSlashKeepsItsOwnKey(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.1.0", ` /form: + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.1.0", ` /form: post: operationId: postForm requestBody: @@ -152,7 +152,7 @@ func TestEncoding_PartNameWithSlashKeepsItsOwnKey(t *testing.T) { // tell them apart. func TestEncoding_ItemEncodingKeepsItsOwnUnderItsOwnKey(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.2.0", ` /stream: + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.2.0", ` /stream: get: operationId: streamEvents responses: @@ -203,7 +203,7 @@ func TestEncoding_NestedEncodingsAreKeptAtEveryVersion(t *testing.T) { for _, version := range []string{"3.2.0", "3.1.0"} { t.Run(version, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, "openapi: "+version+"\ninfo: {title: T, version: \"1\"}\npaths:\n"+body) + _, svc, diags := compileService(t, "openapi: "+version+"\ninfo: {title: T, version: \"1\"}\npaths:\n"+body) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] kept, ok := content.Unmodeled["openapi:encoding/note/prefixEncoding"] diff --git a/compilers/openapi/internal/operation/examples_test.go b/compilers/openapi/internal/operation/examples_test.go index 74716e29..ef711222 100644 --- a/compilers/openapi/internal/operation/examples_test.go +++ b/compilers/openapi/internal/operation/examples_test.go @@ -46,7 +46,7 @@ func TestContent_UnconvertibleExamplesDiagnosed(t *testing.T) { examples: one: {value: !foo baz} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) op := openapitest.FirstOp(t, svc) require.Len(t, op.Responses, 1) c := op.Responses[0].Payload.Contents[0] @@ -92,7 +92,7 @@ components: Good: {value: fine} Bad: {value: !foo baz} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) op := openapitest.FirstOp(t, svc) c := op.Responses[0].Payload.Contents[0] require.Len(t, c.Examples, 1, "the convertible $ref'd example still lowers") @@ -162,7 +162,7 @@ components: examples: Sample: {summary: declared summary, description: declared description, value: {n: x}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) payload := openapitest.FirstOp(t, svc).Responses[0].Payload require.NotNil(t, payload) @@ -200,7 +200,7 @@ func TestParamContent_OwnAndMediaExamplesAreBothKept(t *testing.T) { schema: {type: integer, examples: [9]} example: 4 `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) nums := func(examples []ir.Example) []ir.BigVal { @@ -258,7 +258,7 @@ func TestExamples_EverySpellingReachesTheIR(t *testing.T) { examples: e: `+tc.entry+` `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) examples := openapitest.FirstOp(t, svc).Responses[0].Payload.Contents[0].Examples require.Len(t, examples, 1, "the entry is kept, not dropped") if tc.value == "" { @@ -306,7 +306,7 @@ components: examples: Serial: {serializedValue: '"5"'} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) examples := openapitest.FirstOp(t, svc).Responses[0].Payload.Contents[0].Examples require.Len(t, examples, 1) diff --git a/compilers/openapi/internal/operation/groups_test.go b/compilers/openapi/internal/operation/groups_test.go index a93a7c88..5666d447 100644 --- a/compilers/openapi/internal/operation/groups_test.go +++ b/compilers/openapi/internal/operation/groups_test.go @@ -43,7 +43,7 @@ paths: /tagged: {get: {operationId: tagged, tags: [default], responses: {"200": {description: ok}}}} /untagged: {get: {operationId: untagged, responses: {"200": {description: ok}}}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, svc.Groups, 2) @@ -97,7 +97,7 @@ tags: paths: /pets: {get: {operationId: listPets, tags: [pets], responses: {"200": {description: ok}}}} ` - _, svc, _ := lowerServiceSpec(t, spec) + _, svc, _ := compileService(t, spec) require.Len(t, svc.Groups, 1) assert.Equal(t, ir.GroupID("g/tags/pets"), svc.Groups[0].ID) @@ -121,8 +121,8 @@ func TestGrouping_IDsDoNotDependOnDeclarationOrder(t *testing.T) { forward := head + tagsAscending + "paths:\n" + pathA + pathB + pathC reverse := head + tagsDescending + "paths:\n" + pathC + pathB + pathA - _, first, _ := lowerServiceSpec(t, forward) - _, second, _ := lowerServiceSpec(t, reverse) + _, first, _ := compileService(t, forward) + _, second, _ := compileService(t, reverse) require.Len(t, first.Groups, 3) assert.Empty(t, cmp.Diff(opsByGroup(t, first.Groups), opsByGroup(t, second.Groups)), @@ -146,7 +146,7 @@ paths: operationId: misc responses: {"200": {description: ok}} ` - doc, svc, diags := lowerServiceSpec(t, spec) + doc, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, svc.Groups, 2) byName := openapitest.IndexBy(svc.Groups, func(g ir.OperationGroup) string { return g.Name.Source }) @@ -236,7 +236,7 @@ paths: tags: [beta, books] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, svc.Groups, 1, "the badge tag contributes no group") books, ok := groupBySource(svc.Groups, "books") @@ -263,7 +263,7 @@ paths: tags: [beta] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, svc.Groups, 1) assert.Equal(t, "default", svc.Groups[0].Name.Hint) @@ -290,7 +290,7 @@ paths: tags: [books] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, svc.Groups, 1, "the child is nested, not a second top-level group") catalog := svc.Groups[0] @@ -333,7 +333,7 @@ paths: ` tree := func(spec string) []ir.OperationGroup { t.Helper() - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) return svc.Groups } @@ -358,7 +358,7 @@ paths: tags: [books] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) assert.Equal(t, []string{"openapi/validation/validation-invalid-reference at /tags/0/parent"}, errorFindings(diags)) require.Len(t, svc.Groups, 1) assert.Equal(t, "books", svc.Groups[0].Name.Source, "the group is top-level") @@ -381,7 +381,7 @@ paths: tags: [books] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, svc.Groups, 1) assert.Equal(t, "books", svc.Groups[0].Name.Source) @@ -416,7 +416,7 @@ paths: tags: [c] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) assert.Equal(t, []string{ "openapi/validation/validation-circular-reference at /tags/0/parent", "openapi/validation/validation-circular-reference at /tags/1/parent", @@ -436,7 +436,7 @@ paths: // 3.1 tag does and stays top-level. func TestGrouping_UndeclaredTagIsNavigational(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec(` /x: + _, svc, diags := compileService(t, openapitest.PathsSpec(` /x: get: operationId: x tags: [adHoc] @@ -467,7 +467,7 @@ paths: tags: [books] responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) assert.Equal(t, []string{"openapi/validation/validation-duplicate-key at /tags/1/name"}, errorFindings(diags)) require.Len(t, svc.Groups, 1, "the first declaration wins for both the kind and the parent") assert.Equal(t, "books", svc.Groups[0].Name.Source) @@ -486,7 +486,7 @@ tags: - {name: catalog} paths: {} ` - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, doc.TagDefs, 3) assert.Equal(t, ir.TagDef{Name: "books", Docs: ir.Docs{}, Parent: "catalog", Kind: "nav"}, doc.TagDefs[0]) @@ -532,7 +532,7 @@ tags: ` + paths tree := func(spec string) []ir.OperationGroup { t.Helper() - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) return svc.Groups } diff --git a/compilers/openapi/internal/operation/headers_test.go b/compilers/openapi/internal/operation/headers_test.go index 07efd28c..8b5b2bf8 100644 --- a/compilers/openapi/internal/operation/headers_test.go +++ b/compilers/openapi/internal/operation/headers_test.go @@ -86,7 +86,7 @@ func TestContent_EncodingHeaderRefInternsAtDeclaration(t *testing.T) { // field for each (GitHub #116). func TestHeaders_SchemaDetailReachesTheProperty(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n responses:\n"+ " \"200\":\n description: ok\n headers:\n"+ " X-H: {schema: "+openapitest.InlineProbeBody+"}\n")) @@ -116,7 +116,7 @@ func TestHeaders_SchemaDetailReachesTheProperty(t *testing.T) { // overlay order, so the assertion over it passes whichever side wins. func TestHeaders_OwnAnnotationsOverrideTheSchema(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n responses:\n"+ " \"200\":\n description: ok\n headers:\n"+ " X-H:\n description: HEADER\n example: HEADER\n"+ @@ -148,7 +148,7 @@ func TestHeaders_DeprecationUnionsWithTheSchema(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n responses:\n"+ " \"200\":\n description: ok\n headers:\n"+ " X-H:\n deprecated: "+tc.header+"\n"+ @@ -196,7 +196,7 @@ func TestHeaders_SerializationKeywordsKept(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, tc.spec) + _, svc, diags := compileService(t, tc.spec) openapitest.RequireNoErrorDiags(t, diags) assertHeaderSerializationKept(t, headerAt(t, openapitest.FirstOp(t, svc)), diags, tc.at) }) @@ -209,7 +209,7 @@ func TestHeaders_SerializationKeywordsKept(t *testing.T) { // defaults the accessors would hand back and calling them source facts. func TestHeaders_SerializationKeywordsAbsentRecordNothing(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n responses:\n"+ " \"200\":\n description: ok\n headers:\n"+ " X-H: {schema: {type: string}}\n")) @@ -246,7 +246,7 @@ func TestHeaders_ReservedContentTypeEntryIsReported(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n responses:\n"+ " \"200\":\n description: ok\n headers:\n"+ " "+tc.header+": {schema: {type: string}}\n")) @@ -266,7 +266,7 @@ func TestHeaders_ReservedContentTypeEntryIsReported(t *testing.T) { // describes Content-Type separately and SHALL ignore an entry for it. func TestHeaders_ReservedContentTypeInEncodingIsReported(t *testing.T) { t.Parallel() - _, _, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, _, diags := compileService(t, openapitest.PathsSpec( " /x:\n post:\n operationId: g\n requestBody:\n"+ " content:\n multipart/form-data:\n"+ " schema: {type: object, properties: {file: {type: string}}}\n"+ @@ -290,7 +290,7 @@ func TestHeaders_ReservedContentTypeInEncodingIsReported(t *testing.T) { // reporting at the shared declaration instead would collapse them into one. func TestHeaders_ReservedNameIsTheKeyNotTheDeclaration(t *testing.T) { t.Parallel() - _, _, diags := lowerServiceSpec(t, `openapi: 3.1.0 + _, _, diags := compileService(t, `openapi: 3.1.0 info: {title: T, version: "1"} components: headers: @@ -328,7 +328,7 @@ func TestHeaders_RefSiteKeywordsAreKeptOnTheHeader(t *testing.T) { " \"200\":\n description: ok\n headers:\n" + " X-H: {schema: {$ref: '#/components/schemas/Base', enum: [a, b]}}\n" + "components:\n schemas:\n Base: {type: string}\n" - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) h := headerAt(t, openapitest.FirstOp(t, svc)) @@ -362,7 +362,7 @@ components: headers: Rate: {description: declared, schema: {type: integer}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) require.Len(t, openapitest.FirstOp(t, svc).Responses, 1) header := openapitest.FirstOp(t, svc).Responses[0].Headers[0] diff --git a/compilers/openapi/internal/operation/helpers_test.go b/compilers/openapi/internal/operation/helpers_test.go index 23042f6e..e981aceb 100644 --- a/compilers/openapi/internal/operation/helpers_test.go +++ b/compilers/openapi/internal/operation/helpers_test.go @@ -35,9 +35,9 @@ func parseFull(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { return doc, diags } -// lowerServiceSpec compiles src and returns the document, its single service, +// compileService compiles src and returns the document, its single service, // and every diagnostic. -func lowerServiceSpec(t *testing.T, src string) (*ir.Document, ir.Service, []ir.Diagnostic) { +func compileService(t *testing.T, src string) (*ir.Document, ir.Service, []ir.Diagnostic) { t.Helper() doc, diags := parseFull(t, src) require.Len(t, doc.Services, 1) diff --git a/compilers/openapi/internal/operation/multipart_test.go b/compilers/openapi/internal/operation/multipart_test.go index f0389963..48426aef 100644 --- a/compilers/openapi/internal/operation/multipart_test.go +++ b/compilers/openapi/internal/operation/multipart_test.go @@ -37,7 +37,7 @@ func TestContent_MultipartPartEncoding(t *testing.T) { X-Part: {schema: {type: string}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] metaProp := ir.PropID("p/openapi" + ids.Ptr("paths", "/upload", "post", "requestBody", "content", "multipart/form-data", "schema", "properties", "meta")) @@ -76,7 +76,7 @@ components: meta: {type: object, properties: {k: {type: string}}} file: {type: string, format: binary} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] require.NotNil(t, content.Encoding, "referenced multipart body keeps per-part encoding") @@ -122,7 +122,7 @@ func TestContent_MultipartRedeclaredPartFlagsComeFromTheSettledType(t *testing.T if qFirst { components = q + body } - doc, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec(` /u: + doc, svc, diags := compileService(t, openapitest.PathsSpec(` /u: post: requestBody: content: @@ -171,7 +171,7 @@ func TestContent_MultipartAliasBodyKeyedByAliasedModel(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, svc, diags := lowerServiceSpec(t, aliasedMultipartSpec(tc.mediaSchema)) + doc, svc, diags := compileService(t, aliasedMultipartSpec(tc.mediaSchema)) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] form, isModel := typeByName(doc, "Form").(*ir.Model) @@ -234,7 +234,7 @@ components: schemas: NotAModel: {enum: [x], properties: {file: {type: string, format: binary}}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] require.Contains(t, content.Encoding, tc.want, @@ -257,7 +257,7 @@ func TestContent_ArrayMultipartPartMulti(t *testing.T) { tags: {type: array, items: {type: string}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) content := openapitest.FirstOp(t, svc).Request.Contents[0] tagsProp := ir.PropID("p/openapi" + ids.Ptr("paths", "/bulk", "post", "requestBody", "content", "multipart/form-data", "schema", "properties", "tags")) @@ -308,7 +308,7 @@ components: type: object properties: {note: {type: string}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) enc := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding @@ -337,7 +337,7 @@ func TestBodyParts_RedeclaredNameIsOnePart(t *testing.T) { note: {contentType: text/plain} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) enc := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding assert.Len(t, enc, 1, "one wire part is one encoding entry; got %v", enc) @@ -484,7 +484,7 @@ func TestPartEncoding_ADecidedTypeNeverFallsBack(t *testing.T) { `{type: object, properties: {file: {type: string, format: binary}}}]} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, openapitest.FirstOp(t, svc).Request.Contents[0].Encoding, "the kept integer decides: not a file") } @@ -566,7 +566,7 @@ func TestPartEncodings_ReadsTheFlattenedComposition(t *testing.T) { reversed := nestedPartSpec(tc.n, tc.residue, tc.shape, tc.media, []string{"meta", "file"}, []string{"meta", "file"}) var got [2]map[ir.PropID]ir.PartEncoding for i, src := range []string{forward, reversed} { - _, svc, diags := lowerServiceSpec(t, src) + _, svc, diags := compileService(t, src) openapitest.RequireNoErrorDiags(t, diags) got[i] = openapitest.FirstOp(t, svc).Request.Contents[0].Encoding if tc.capped { @@ -617,7 +617,7 @@ func TestPartEncodings_RefHopBoundIsReported(t *testing.T) { components: schemas: ` + comps.String()) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) msg := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/paths/~1u/post/requestBody/content/multipart~1form-data/schema") @@ -658,7 +658,7 @@ func TestPartEncodings_ShorterPathReentersTarget(t *testing.T) { components: schemas: ` + comps.String()) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) got := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding for _, part := range []string{"file", "meta"} { @@ -718,7 +718,7 @@ components: Deep: ` + deep + ` Form: ` + obj + ` `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) got := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding for _, part := range []string{"file", "meta"} { @@ -774,7 +774,7 @@ func TestPartEncodings_CutPastReachedPart(t *testing.T) { components: schemas: ` + tc.comps) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) got := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding for _, part := range []string{"file", "meta"} { @@ -817,7 +817,7 @@ components: type: object properties: {file: {type: string, format: binary}, meta: {type: string}} allOf: ` + tc.allOf + "\n") - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) got := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding for _, part := range []string{"file", "meta"} { @@ -872,7 +872,7 @@ components: schemas: Blob: {type: string, format: binary} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) enc := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding require.Len(t, enc, 1, "one part carries a flag: %v", enc) diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index be3d7e65..bd38f457 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -30,7 +30,7 @@ func TestOperation_ExplicitlyPublicSecurity(t *testing.T) { operationId: inherits responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) ops := map[string]ir.Operation{} for _, g := range svc.Groups { @@ -49,7 +49,7 @@ func TestOperation_NoOperationIdHint(t *testing.T) { get: responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) assert.Empty(t, op.Name.Source, "no operationId leaves an empty source name") @@ -733,7 +733,7 @@ func TestOperations_OwnServersFindingIsNotInferred(t *testing.T) { // encoding — here the response's own content schema. func TestLowerService_PercentEncodedEntryRefKeepsTheDeclaredCoordinate(t *testing.T) { t.Parallel() - doc, _, diags := lowerServiceSpec(t, `openapi: 3.1.0 + doc, _, diags := compileService(t, `openapi: 3.1.0 info: {title: T, version: "1"} paths: /a: diff --git a/compilers/openapi/internal/operation/params_test.go b/compilers/openapi/internal/operation/params_test.go index 4e4da7ca..926203ae 100644 --- a/compilers/openapi/internal/operation/params_test.go +++ b/compilers/openapi/internal/operation/params_test.go @@ -34,7 +34,7 @@ func TestParams_LocationsAndSerializationDefaults(t *testing.T) { - {name: session, in: cookie, schema: {type: string}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 5) @@ -77,7 +77,7 @@ func TestParams_ContentStyleParameter(t *testing.T) { schema: {type: object, properties: {kind: {type: string}}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Bindings.HTTP, 1) @@ -102,7 +102,7 @@ func TestParams_UnconvertibleExampleDiagnosed(t *testing.T) { example: !foo bar responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 1) assert.Empty(t, op.Params[0].Examples, "the unconvertible example is skipped, not appended") @@ -122,7 +122,7 @@ func TestParams_SchemaConstraints(t *testing.T) { - {name: age, in: query, schema: {type: integer, maximum: 120}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 1) @@ -519,7 +519,7 @@ func TestParams_ContentStyleComponentRefInternsOnce(t *testing.T) { // though Parameter has Docs, Examples and Unmodeled (GitHub #116). func TestParams_SchemaAnnotationsReachTheParameter(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n parameters:\n"+ " - name: q\n in: query\n schema: "+openapitest.InlineProbeBody+"\n"+ " responses: {\"204\": {description: ok}}\n")) @@ -544,7 +544,7 @@ func TestParams_SchemaAnnotationsReachTheParameter(t *testing.T) { // annotations, in the only order there is. func TestParams_SchemaAnnotationsSurviveARefNamingTheSchema(t *testing.T) { t.Parallel() - doc, svc, diags := lowerServiceSpec(t, + doc, svc, diags := compileService(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ " /x:\n get:\n operationId: g\n parameters:\n"+ " - name: q\n in: query\n schema: "+openapitest.InlineProbeBody+"\n"+ @@ -604,7 +604,7 @@ func paramsOf(t *testing.T, svc ir.Service) map[string]ir.Parameter { // default — because the position never resolved the target it was holding. func TestParams_RefSchemaInheritsFromItsReferent(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramRefInheritSpec) + _, svc, diags := compileService(t, paramRefInheritSpec) openapitest.RequireNoErrorDiags(t, diags) p := paramsOf(t, svc)["bare"] @@ -621,7 +621,7 @@ func TestParams_RefSchemaInheritsFromItsReferent(t *testing.T) { // written beside the $ref describes this use of the type and outranks the target. func TestParams_RefSchemaUseSiteWinsOverItsReferent(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramRefInheritSpec) + _, svc, diags := compileService(t, paramRefInheritSpec) openapitest.RequireNoErrorDiags(t, diags) p := paramsOf(t, svc)["override"] @@ -637,7 +637,7 @@ func TestParams_RefSchemaUseSiteWinsOverItsReferent(t *testing.T) { // chain to its end reaches Q. func TestParams_RefSchemaInheritsThroughARefChain(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramRefInheritSpec) + _, svc, diags := compileService(t, paramRefInheritSpec) openapitest.RequireNoErrorDiags(t, diags) p := paramsOf(t, svc)["chained"] @@ -670,7 +670,7 @@ paths: // binding records that content type. func TestParams_SchemaXMLHintsKeptUnderUnmodeled(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramXMLSpec) + _, svc, diags := compileService(t, paramXMLSpec) openapitest.RequireNoErrorDiags(t, diags) params := paramsOf(t, svc) @@ -702,7 +702,7 @@ func TestParams_SchemaXMLHintsKeptUnderUnmodeled(t *testing.T) { // one declaration two homes that can drift. func TestParams_SchemaXMLHintsStayOnAnOwnedNode(t *testing.T) { t.Parallel() - doc, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + doc, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n parameters:\n"+ " - {name: obj, in: query, schema: {type: object, xml: {name: XOBJ}}}\n"+ " responses: {\"204\": {description: ok}}\n")) @@ -749,7 +749,7 @@ components: // $ref and in a content-style parameter alike. func TestParams_SchemaRedactionKeptUnderUnmodeled(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramRedactionSpec) + _, svc, diags := compileService(t, paramRedactionSpec) openapitest.RequireNoErrorDiags(t, diags) params := paramsOf(t, svc) @@ -780,7 +780,7 @@ func TestParams_SchemaRedactionKeptUnderUnmodeled(t *testing.T) { // one writing a format that names a shape keeps nothing. func TestParams_SchemaRedactionStaysOnANode(t *testing.T) { t.Parallel() - doc, svc, diags := lowerServiceSpec(t, paramRedactionSpec) + doc, svc, diags := compileService(t, paramRedactionSpec) openapitest.RequireNoErrorDiags(t, diags) params := paramsOf(t, svc) @@ -822,7 +822,7 @@ paths: // compiler exists to make impossible. func TestParams_SchemaVisibilityKeptUnderUnmodeled(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramVisibilitySpec) + _, svc, diags := compileService(t, paramVisibilitySpec) openapitest.RequireNoErrorDiags(t, diags) params := paramsOf(t, svc) @@ -852,7 +852,7 @@ func TestParams_SchemaVisibilityKeptUnderUnmodeled(t *testing.T) { // it, so a verbatim copy beside it would give one declaration two homes. func TestParams_SchemaDefaultIsNotAlsoKeptVerbatim(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, paramVisibilitySpec) + _, svc, diags := compileService(t, paramVisibilitySpec) openapitest.RequireNoErrorDiags(t, diags) p := paramsOf(t, svc)["ro"] @@ -870,7 +870,7 @@ func TestParams_SchemaDefaultIsNotAlsoKeptVerbatim(t *testing.T) { // describes the type, and the more specific of the two wins. func TestParams_OwnAnnotationsWinOverTheSchema(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n parameters:\n"+ " - name: q\n in: query\n description: PARAM\n"+ " x-scope: param\n schema: {type: string, description: SCHEMA, x-scope: schema}\n"+ @@ -1014,7 +1014,7 @@ func TestParams_RefSiteKeywordsAreKeptOnTheParameter(t *testing.T) { " - {name: r, in: query, schema: {$ref: '#/components/schemas/Base', minLength: 3}}\n" + " responses: {\"200\": {description: ok}}\n" + "components:\n schemas:\n Base: {type: string}\n" - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) params := openapitest.IndexBy(openapitest.FirstOp(t, svc).Params, func(p ir.Parameter) string { return p.Name.Source }) @@ -1045,7 +1045,7 @@ func TestParams_RefSiteKeywordsAreKeptOnTheParameter(t *testing.T) { // #425). func TestParams_CoDeclaredBoundsReachTheParameter(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( + _, svc, diags := compileService(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n parameters:\n"+ " - {name: low, in: query, schema: {type: integer, minimum: 10, exclusiveMinimum: 0}}\n"+ " - {name: high, in: query, schema: {type: integer, minimum: 0, exclusiveMinimum: 10}}\n"+ @@ -1084,7 +1084,7 @@ func TestParams_CoDeclaredBoundsReachTheParameter(t *testing.T) { // declared keyword silently, so the parameter keeps it verbatim. func TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.0.3", + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.0.3", " /x:\n get:\n operationId: g\n parameters:\n"+ " - {name: bare, in: query, schema: {type: integer, exclusiveMinimum: true}}\n"+ " responses: {\"204\": {description: ok}}\n")) @@ -1138,7 +1138,7 @@ components: parameters: Limit: {name: limit, in: query, description: declared, schema: {type: integer}} ` - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) withSiblings, ok := paramBySource(openapitest.FindOp(t, doc, "a"), "limit") @@ -1486,7 +1486,7 @@ func TestParameters_PathItemSharedAcrossOperationsInternsOnce(t *testing.T) { - {name: force, in: query, schema: {type: boolean}} responses: {"200": {description: ok}} `) - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) getPet := openapitest.FindOp(t, doc, "getPet") deletePet := openapitest.FindOp(t, doc, "deletePet") @@ -1537,9 +1537,9 @@ func TestParameters_PathItemSchemaIDStableAcrossOperationParamChanges(t *testing - {name: verbose, in: query, schema: {type: boolean}} responses: {"200": {description: ok}} `) - _, svcBefore, diagsBefore := lowerServiceSpec(t, before) + _, svcBefore, diagsBefore := compileService(t, before) openapitest.RequireNoErrorDiags(t, diagsBefore) - _, svcAfter, diagsAfter := lowerServiceSpec(t, after) + _, svcAfter, diagsAfter := compileService(t, after) openapitest.RequireNoErrorDiags(t, diagsAfter) opBefore := openapitest.FirstOp(t, svcBefore) @@ -1570,7 +1570,7 @@ webhooks: operationId: onPetEvent responses: {"200": {description: ok}} ` - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FindOp(t, doc, "onPetEvent") require.Len(t, op.Params, 1) @@ -1603,7 +1603,7 @@ func TestParameters_CallbackPathItemParameterPointer(t *testing.T) { responses: {"200": {description: ok}} responses: {"200": {description: ok}} `) - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) cbOp := openapitest.FindOp(t, doc, "onEvent") require.Len(t, cbOp.Params, 1) @@ -1637,7 +1637,7 @@ func TestParameters_ShadowedPathItemParamUsesOperationPointer(t *testing.T) { schema: {type: string, enum: [c, d]} responses: {"200": {description: ok}} `) - doc, svc, diags := lowerServiceSpec(t, spec) + doc, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 1, "the operation parameter shadows the path-item one; no duplicate") @@ -1682,7 +1682,7 @@ paths: parameters: Limit: {name: limit, in: query, description: declared, schema: {type: integer}} ` - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) out := map[string]string{} for _, name := range []string{"a", "b"} { diff --git a/compilers/openapi/internal/operation/pathkeys_test.go b/compilers/openapi/internal/operation/pathkeys_test.go index 38cbb8a0..ea5a03e4 100644 --- a/compilers/openapi/internal/operation/pathkeys_test.go +++ b/compilers/openapi/internal/operation/pathkeys_test.go @@ -70,7 +70,7 @@ func TestPathKeys_MalformedKeyIsReportedOnce(t *testing.T) { for _, tc := range tests { t.Run(tc.key, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, pathKeySpec("3.1.0", tc.key, tc.params...)) + _, svc, diags := compileService(t, pathKeySpec("3.1.0", tc.key, tc.params...)) openapitest.RequireNoErrorDiags(t, diags) msg := openapitest.DiagMessageAt(t, diags, diag.InvalidPathKey, ir.SeverityWarning, tc.ptr) @@ -94,7 +94,7 @@ func TestPathKeys_ValidKeysAreNotReported(t *testing.T) { } { t.Run(key, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, pathKeySpec("3.1.0", key, params...)) + _, svc, diags := compileService(t, pathKeySpec("3.1.0", key, params...)) openapitest.RequireNoErrorDiags(t, diags) assert.False(t, openapitest.HasDiag(diags, diag.InvalidPathKey), "%q is a path template; got %+v", key, diags) assert.Equal(t, key, openapitest.FirstOp(t, svc).Bindings.HTTP[0].URITemplate) @@ -111,7 +111,7 @@ func TestPathKeys_RulesAreNotVersionGated(t *testing.T) { for _, version := range []string{"3.0.3", "3.1.0", "3.2.0"} { t.Run(version, func(t *testing.T) { t.Parallel() - _, _, diags := lowerServiceSpec(t, pathKeySpec(version, "/a//{id}/{id}", "id")) + _, _, diags := compileService(t, pathKeySpec(version, "/a//{id}/{id}", "id")) openapitest.RequireNoErrorDiags(t, diags) msg := openapitest.DiagMessageAt(t, diags, diag.InvalidPathKey, ir.SeverityWarning, "/paths/~1a~1~1{id}~1{id}") @@ -140,7 +140,7 @@ func TestPathKeys_FragmentIsLeftToGitHub602(t *testing.T) { for _, tc := range tests { t.Run(tc.key, func(t *testing.T) { t.Parallel() - _, svc, diags := lowerServiceSpec(t, pathKeySpec("3.1.0", tc.key)) + _, svc, diags := compileService(t, pathKeySpec("3.1.0", tc.key)) openapitest.RequireNoErrorDiags(t, diags) assert.Equal(t, tc.key, openapitest.FirstOp(t, svc).Bindings.HTTP[0].URITemplate) if tc.msg == "" { @@ -168,7 +168,7 @@ paths: parameters: [{name: x, in: query, schema: {type: string}}] components: {pathItems: {}} ` - _, _, diags := lowerServiceSpec(t, spec) + _, _, diags := compileService(t, spec) require.True(t, openapitest.HasDiagCodeAt(diags, diag.UnresolvedRef, "/paths/a b"), "the $ref must be the unresolved one this case is about; got %+v", diags) for _, ptr := range []string{"/paths/a b", "/paths/c d", "/paths/e f"} { @@ -198,7 +198,7 @@ webhooks: newPet: post: {operationId: newPet, responses: {"200": {description: ok}}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.False(t, openapitest.HasDiag(diags, diag.InvalidPathKey), "got %+v", diags) diff --git a/compilers/openapi/internal/operation/requestbody_test.go b/compilers/openapi/internal/operation/requestbody_test.go index b998c2e9..5d31fdf9 100644 --- a/compilers/openapi/internal/operation/requestbody_test.go +++ b/compilers/openapi/internal/operation/requestbody_test.go @@ -24,7 +24,7 @@ func TestContent_NonRequiredRequestBody(t *testing.T) { application/json: {schema: {type: object, properties: {n: {type: string}}}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request, "a non-required body still lowers to a present Payload") @@ -52,7 +52,7 @@ func TestContent_RequiredRequestBody(t *testing.T) { application/json: {schema: {type: object, properties: {n: {type: string}}}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request) @@ -74,7 +74,7 @@ func TestContent_ResponsePayloadStatesNoOptionality(t *testing.T) { content: application/json: {schema: {type: object, properties: {n: {type: string}}}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Responses, 1) @@ -96,7 +96,7 @@ func TestContent_RequestBodyDocsReachThePayload(t *testing.T) { application/json: {schema: {type: object, properties: {n: {type: string}}}} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request) @@ -117,7 +117,7 @@ func TestContent_ResponsePayloadStatesNoDocs(t *testing.T) { content: application/json: {schema: {type: object, properties: {n: {type: string}}}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Responses, 1) @@ -163,7 +163,7 @@ paths: application/json: schema: {type: object, properties: {sku: {type: string}}} ` - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) out := map[string]string{} for _, name := range []string{"createOrder", "replaceOrder"} { @@ -288,7 +288,7 @@ components: content: application/json: {schema: {type: object, properties: {n: {type: string}}}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request) diff --git a/compilers/openapi/internal/operation/responses_test.go b/compilers/openapi/internal/operation/responses_test.go index a593e8e7..a603bb78 100644 --- a/compilers/openapi/internal/operation/responses_test.go +++ b/compilers/openapi/internal/operation/responses_test.go @@ -33,7 +33,7 @@ func TestResponses_ErrorSplitAndRanges(t *testing.T) { "5XX": {description: server oops} default: {description: anything else} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Responses, 1) @@ -68,7 +68,7 @@ func TestResponses_ALoneDefaultIsNeitherClass(t *testing.T) { headers: {X-Rate: {schema: {type: integer}}} content: {application/json: {schema: {type: object, properties: {v: {type: string}}}}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) @@ -106,7 +106,7 @@ func TestResponses_NamedByStatusKey(t *testing.T) { "404": {description: missing} default: {description: anything else} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) @@ -143,7 +143,7 @@ func TestResponses_InvalidStatusKeyIsReported(t *testing.T) { content: {application/json: {schema: {type: object, properties: {a: {type: string}}}}} default: {description: anything else} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) @@ -185,7 +185,7 @@ func TestResponses_ValidStatusKeysAreNotReported(t *testing.T) { "4xx": {description: lowercase wildcard} default: {description: anything else} `) - _, _, diags := lowerServiceSpec(t, spec) + _, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.False(t, openapitest.HasDiag(diags, diag.InvalidStatusKey), "every key here names a status; got %+v", diags) @@ -210,7 +210,7 @@ func TestResponses_ErrorHeadersAreStructural(t *testing.T) { Retry-After: {schema: {type: integer}} X-RateLimit-Remaining: {schema: {type: integer}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Errors, 1) @@ -243,7 +243,7 @@ func TestResponses_HeadersLowered(t *testing.T) { headers: X-Rate-Limit: {required: true, schema: {type: integer}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Responses, 1) @@ -266,7 +266,7 @@ func TestResponses_LinksPreserved(t *testing.T) { links: GetUserByUserId: {operationId: getUser} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Responses, 1) @@ -472,7 +472,7 @@ func TestResponses_TwoKeysForOneRangeAreReported(t *testing.T) { "4XX": {description: upper} "4xx": {description: lower} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) op := openapitest.FirstOp(t, svc) require.Len(t, op.Errors, 2, "both are kept: neither key is wrong on its own") @@ -496,7 +496,7 @@ func TestResponses_ACollisionIsReportedTheSameInEitherOrder(t *testing.T) { diagsFor := func(keys string) []ir.Diagnostic { t.Helper() spec := openapitest.PathsSpec(" /w:\n get:\n operationId: w\n responses:\n" + keys) - _, _, diags := lowerServiceSpec(t, spec) + _, _, diags := compileService(t, spec) var found []ir.Diagnostic for _, d := range diags { if d.Code == diag.DuplicateStatusKey { @@ -539,7 +539,7 @@ func TestResponses_ASharedBodyIsNamedAlikeFromEitherStatusClass(t *testing.T) { ` hintOf := func(paths string) string { t.Helper() - doc, _, diags := lowerServiceSpec(t, openapitest.PathsSpec(paths)) + doc, _, diags := compileService(t, openapitest.PathsSpec(paths)) openapitest.RequireNoErrorDiags(t, diags) td, ok := doc.Types["t/anon/paths/~1a/get/responses/200/content/application~1json/schema"] require.True(t, ok, "the shared body is interned once at its declaration pointer; got %v", slices.Sorted(maps.Keys(doc.Types))) @@ -583,7 +583,7 @@ components: responses: NF: {description: not found} ` - _, _, diags := lowerServiceSpec(t, spec) + _, _, diags := compileService(t, spec) for _, op := range []string{"~1a", "~1b"} { responses := "/paths/" + op + "/get/responses" @@ -630,7 +630,7 @@ components: content: application/json: {schema: {type: object, properties: {n: {type: string}}}} ` - doc, _, diags := lowerServiceSpec(t, spec) + doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) a := openapitest.FindOp(t, doc, "a") @@ -665,7 +665,7 @@ func TestResponses_SummaryIsReadAtEveryVersion(t *testing.T) { for _, version := range []string{"3.2.0", "3.1.0", "3.0.3"} { t.Run(version, func(t *testing.T) { t.Parallel() - doc, _, diags := lowerServiceSpec(t, "openapi: "+version+"\ninfo: {title: T, version: \"1\"}\npaths:\n"+body) + doc, _, diags := compileService(t, "openapi: "+version+"\ninfo: {title: T, version: \"1\"}\npaths:\n"+body) openapitest.RequireNoErrorDiags(t, diags) resp := openapitest.FindOp(t, doc, "a").Responses[0] assert.Equal(t, "The item was deleted", resp.Docs.Summary) @@ -688,7 +688,7 @@ func TestResponses_SummaryIsReadAtEveryVersion(t *testing.T) { // no form. One merged in through `<<` is read as a written one is. func TestResponses_SummaryNoReaderTakesIsStillReported(t *testing.T) { t.Parallel() - doc, _, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.2.0", ` /a: + doc, _, diags := compileService(t, openapitest.PathsSpecVer("3.2.0", ` /a: get: operationId: a responses: @@ -715,7 +715,7 @@ func TestResponses_SummaryNoReaderTakesIsStillReported(t *testing.T) { // the $ref describes this mount, so it wins over the declaration's own. func TestResponses_SummaryUseSiteBeatsTheDeclaration(t *testing.T) { t.Parallel() - doc, _, diags := lowerServiceSpec(t, `openapi: 3.2.0 + doc, _, diags := compileService(t, `openapi: 3.2.0 info: {title: T, version: "1"} paths: /a: diff --git a/compilers/openapi/internal/operation/typespelling_test.go b/compilers/openapi/internal/operation/typespelling_test.go index 86762d67..7cba69a8 100644 --- a/compilers/openapi/internal/operation/typespelling_test.go +++ b/compilers/openapi/internal/operation/typespelling_test.go @@ -305,7 +305,7 @@ func TestParamContent_MergedFieldIsKept(t *testing.T) { schema: {type: integer} responses: {"200": {description: ok}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 1) @@ -340,7 +340,7 @@ paths: bogus: B responses: {"200": {description: ok}} ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 1) @@ -386,7 +386,7 @@ paths: itemSchema: {type: string} x-hdr: hdr ` - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) header := openapitest.FirstOp(t, svc).Responses[0].Headers[0] @@ -415,7 +415,7 @@ func TestParamAndHeaderSchema_NeverRecordContentFields(t *testing.T) { headers: X-Plain: {schema: {type: string}} `) - _, svc, diags := lowerServiceSpec(t, spec) + _, svc, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Params, 1) diff --git a/compilers/openapi/internal/schema/allof_internal_test.go b/compilers/openapi/internal/schema/allof_internal_test.go index 1dc8b80a..c5699227 100644 --- a/compilers/openapi/internal/schema/allof_internal_test.go +++ b/compilers/openapi/internal/schema/allof_internal_test.go @@ -80,7 +80,7 @@ func TestAllOfBranches_DepthCapKeepsWrapperVerbatim(t *testing.T) { } b.WriteString(strings.Repeat(" ", tc.wrappers) + " - {$ref: '#/components/schemas/A'}\n") - doc, diags := lowerSpec(t, openapitest.ComponentSpec(b.String())) + doc, diags := LowerSpec(t, openapitest.ComponentSpec(b.String())) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[ir.TypeID("t/openapi/components/schemas/M")].(*ir.Model) require.True(t, ok, "M should be a model") @@ -109,7 +109,7 @@ func TestAllOfBranches_DepthCapKeepsWrapperVerbatim(t *testing.T) { // composed schema, and that a wrapper follows its own branches. func TestAllOfBranches_KeysSpellEachEntrysPath(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(` M: + l, _ := LoweredFor(t, openapitest.ComponentSpec(` M: allOf: - {type: object} - allOf: @@ -118,7 +118,7 @@ func TestAllOfBranches_KeysSpellEachEntrysPath(t *testing.T) { - {type: object} - {type: object} `)) - decl, ok := l.ctx.Doc.Components.Schemas.Get("M") + decl, ok := l.Ctx.Doc.Components.Schemas.Get("M") require.True(t, ok) comp := allOfBranches(decl.GetSchema(), "/components/schemas/M") entries := comp.branches @@ -147,7 +147,7 @@ func TestAllOfBranches_KeysSpellEachEntrysPath(t *testing.T) { // its entries, and at a $ref branch the view yields. func TestWalkAllOf_StopsWhenTheConsumerDoes(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(` A: {type: object} + l, _ := LoweredFor(t, openapitest.ComponentSpec(` A: {type: object} M: allOf: - allOf: @@ -158,7 +158,7 @@ func TestWalkAllOf_StopsWhenTheConsumerDoes(t *testing.T) { - {$ref: '#/components/schemas/A'} - {$ref: '#/components/schemas/A'} `)) - decl, ok := l.ctx.Doc.Components.Schemas.Get("M") + decl, ok := l.Ctx.Doc.Components.Schemas.Get("M") require.True(t, ok) m := decl.GetSchema() @@ -186,7 +186,7 @@ func TestWalkAllOf_StopsWhenTheConsumerDoes(t *testing.T) { } assert.Equal(t, 1, refs) - flatDecl, ok := l.ctx.Doc.Components.Schemas.Get("Flat") + flatDecl, ok := l.Ctx.Doc.Components.Schemas.Get("Flat") require.True(t, ok) var flatRefs int for range allOfRefBranches(flatDecl.GetSchema()) { @@ -222,7 +222,7 @@ func TestAllOfBranches_DepthCapNamesWhatElseTheWrapperWrites(t *testing.T) { b.WriteString(pad + " - " + tc.sibling + "\n" + pad + " allOf:\n" + pad + " - {$ref: '#/components/schemas/A'}\n") - _, diags := lowerSpec(t, openapitest.ComponentSpec(b.String())) + _, diags := LowerSpec(t, openapitest.ComponentSpec(b.String())) openapitest.RequireNoErrorDiags(t, diags) path := strings.TrimSuffix(strings.Repeat("allOf/0/", MaxAllOfFlattenDepth+1), "/") msg := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, diff --git a/compilers/openapi/internal/schema/allof_test.go b/compilers/openapi/internal/schema/allof_test.go index 2882bf3c..98efc124 100644 --- a/compilers/openapi/internal/schema/allof_test.go +++ b/compilers/openapi/internal/schema/allof_test.go @@ -15,6 +15,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/pass" ) @@ -86,7 +87,7 @@ func TestAllOf_SoleRefBecomesBase(t *testing.T) { properties: bark: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) dog, ok := doc.Types[componentID("Dog")].(*ir.Model) require.True(t, ok, "Dog should be a model") @@ -117,7 +118,7 @@ func TestAllOf_OverlappingInlineBranchesReconcile(t *testing.T) { id: {type: integer} url: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Forkish")].(*ir.Model) require.True(t, ok, "Forkish should be a model") @@ -165,7 +166,7 @@ func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { examples: [abc] xml: {name: tok} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) require.True(t, ok, "Tokenish should be a model") @@ -201,7 +202,7 @@ func TestAllOf_RedeclarationOrsSecret(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" Tokenish:\n allOf:\n" + branch + tc.first + branch + tc.second) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) assert.Empty(t, diags, "nothing conflicts") m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) require.True(t, ok, "Tokenish should be a model") @@ -231,7 +232,7 @@ func TestAllOf_ConflictingRedeclaredDescriptionDiagnosed(t *testing.T) { properties: id: {type: integer, description: a different meaning} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) // a description clash is info-level, never an error m, ok := doc.Types[componentID("Clashish")].(*ir.Model) require.True(t, ok, "Clashish should be a model") @@ -258,7 +259,7 @@ func TestAllOf_ConflictingRedeclaredTypeDiagnosedAndKept(t *testing.T) { properties: id: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) // a redeclaration conflict is a warning, not a refusal m, ok := doc.Types[componentID("Conflictish")].(*ir.Model) require.True(t, ok, "Conflictish should be a model") @@ -309,7 +310,7 @@ func TestAllOf_OverlappingPrimitivesAreReportedNotConflicting(t *testing.T) { properties: n: {type: integer, format: %s, minimum: %d} `, order[0], bounds[0], order[1], bounds[1])) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Overlapish")].(*ir.Model) require.True(t, ok, "Overlapish should be a model") @@ -363,7 +364,7 @@ func TestAllOf_EnumPairsAreJudgedByKind(t *testing.T) { } t.Run(fmt.Sprintf("%s swapped=%t", tc.name, swap), func(t *testing.T) { t.Parallel() - _, diags := lowerSpec(t, allOfConflictSpec(a, b)) + _, diags := schema.LowerSpec(t, allOfConflictSpec(a, b)) openapitest.RequireNoErrorDiags(t, diags) assert.Equal(t, tc.info, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "the overlap info") @@ -401,7 +402,7 @@ func TestAllOf_ALosingRedeclarationIsKeptAsWritten(t *testing.T) { properties: id: false `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for _, tc := range []struct{ model, want string }{ @@ -432,7 +433,7 @@ func TestAllOf_ConflictingRedeclaredConstraintDiagnosed(t *testing.T) { properties: code: {type: string, maxLength: 20} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Boundish")].(*ir.Model) require.True(t, ok, "Boundish should be a model") @@ -466,7 +467,7 @@ func TestAllOf_CompatibleRedeclarationStaysSilent(t *testing.T) { properties: id: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Compatish")].(*ir.Model) require.True(t, ok, "Compatish should be a model") @@ -492,7 +493,7 @@ func TestAllOf_PropertyAlongsideAllOfReconciles(t *testing.T) { id: {type: integer, description: the identifier} name: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Mixish")].(*ir.Model) require.True(t, ok, "Mixish should be a model") @@ -595,7 +596,7 @@ func TestAllOf_CompositionRequiredAttaches(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, tc.spec) + doc, diags := schema.LowerSpec(t, tc.spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID(tc.model)].(*ir.Model) require.True(t, ok, "%s should be a model", tc.model) @@ -627,7 +628,7 @@ func TestAllOf_RequiredOnlyBranchNamingBaseOwnedPropertyLandsInRequires(t *testi properties: gender: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) m, ok := doc.Types[componentID("SuperBaby")].(*ir.Model) require.True(t, ok, "SuperBaby should be a model") require.NotNil(t, m.Base, "the sole $ref still becomes Base") @@ -656,7 +657,7 @@ func TestAllOf_RequiredNameNothingDeclaresLandsInRequires(t *testing.T) { id: {type: integer} - required: [ghost] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Thing")].(*ir.Model) require.True(t, ok, "Thing should be a model") @@ -693,7 +694,7 @@ func TestAllOf_RefBranchWithSiblingRequired31(t *testing.T) { properties: level: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("SuperBoyLike")].(*ir.Model) require.True(t, ok, "SuperBoyLike should be a model") @@ -722,7 +723,7 @@ func TestAllOf_InlineBranchResidueKeptVerbatim(t *testing.T) { description: BranchDoc x-vendor: keepme `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "S").(*ir.Model) require.True(t, ok, "S should be a model") @@ -759,7 +760,7 @@ func TestAllOf_InlineBranchNonObjectTypeWarns(t *testing.T) { allOf: - {type: string, maxLength: 3} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "T").(*ir.Model) require.True(t, ok, "the composed node is still a model") @@ -790,7 +791,7 @@ func TestAllOf_BranchWrittenByReferenceStillDerivesResidue(t *testing.T) { Merged: {allOf: [{<<: *br}]} Overridden: {allOf: [{<<: *br, type: object, properties: {a: {type: string}}}]} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) cases := []struct { @@ -848,7 +849,7 @@ func TestAllOf_MergedBranchKeywordsAreNotResidue(t *testing.T) { properties: {name: {type: string}} - required: [name] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for _, name := range []string{"TypedBranch", "UntypedBranch", "RequiredOnlyBranch"} { m, ok := typeByName(doc, name).(*ir.Model) @@ -878,7 +879,7 @@ func TestAllOf_BranchResidueDerivedFromDeclaredKeys(t *testing.T) { - type: [object, 'null'] properties: {a: {type: string}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for name, want := range map[string]string{ @@ -911,7 +912,7 @@ func TestAllOf_EachInlineBranchKeyedSeparately(t *testing.T) { properties: {b: {type: string}} minProperties: 1 `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "Multi").(*ir.Model) require.True(t, ok, "Multi should be a model") @@ -947,7 +948,7 @@ func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { - {$ref: '#/components/schemas/A'} - {$ref: '#/components/schemas/B'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Distributed").(*ir.Union) require.True(t, ok, "Distributed distributes into a union") @@ -982,7 +983,7 @@ func TestAllOf_RequiresIsTheSameInEitherComponentOrder(t *testing.T) { ) nodes := make([]*ir.Model, 0, 2) for _, src := range []string{user + created, created + user} { - doc, diags := lowerSpec(t, openapitest.ComponentSpec(src)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(src)) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "CreatedUser").(*ir.Model) require.True(t, ok, "CreatedUser is a model") @@ -1013,7 +1014,7 @@ func TestAllOf_ARequiredNameWrittenInSeveralListsIsListedOnce(t *testing.T) { - {required: [extra, other]} required: [extra, id] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, diags, "no list repeats a name, so nothing is reported") @@ -1040,7 +1041,7 @@ func TestAllOf_ExtraRefsBecomeMixins(t *testing.T) { properties: c: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) c, ok := doc.Types[componentID("C")].(*ir.Model) require.True(t, ok, "C should be a model") @@ -1075,7 +1076,7 @@ func TestAllOf_DiscriminatorHierarchy(t *testing.T) { - {$ref: '#/components/schemas/Extra'} - {type: object, properties: {bark: {type: boolean}}} `) - doc, _ := lowerSpec(t, spec) + doc, _ := schema.LowerSpec(t, spec) pet := typeByName(doc, "Pet").(*ir.Model) require.NotNil(t, pet.Discriminator) @@ -1100,7 +1101,7 @@ func TestAllOf_UnresolvedRefBranch(t *testing.T) { - {$ref: '#'} - {type: object, properties: {a: {type: string}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) assert.True(t, openapitest.HasDiag(diags, diag.UnresolvedRef)) } @@ -1117,7 +1118,7 @@ func TestAllOf_MultiRefWithUnresolvedDoesNotAnchor(t *testing.T) { - {$ref: '#/components/schemas/Base'} - {type: object, properties: {a: {type: string}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) sub := typeByName(doc, "Sub").(*ir.Model) require.NotNil(t, sub.Base, "the discriminator-anchoring Base becomes base despite an unresolved sibling ref") @@ -1140,7 +1141,7 @@ func TestAllOf_BoolBranchSkippedInCompositionRequired(t *testing.T) { id: {type: integer} - true `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Thing")].(*ir.Model) require.True(t, ok, "Thing should be a model") @@ -1169,7 +1170,7 @@ func TestAllOf_AnInadmissibleBroadValueIsKeptInEitherOrder(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" Ranged:\n allOf:\n"+ + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Ranged:\n allOf:\n"+ branch+tc.first+branch+tc.second)) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Ranged")].(*ir.Model) @@ -1206,7 +1207,7 @@ func TestAllOf_ANullOnARefOverTheTopTypeIsKept(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" Y: {description: free}\n Held:\n allOf:\n"+ + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Y: {description: free}\n Held:\n allOf:\n"+ branch+tc.first+branch+tc.second)) assert.Empty(t, diags, "nothing is refused") m, ok := doc.Types[componentID("Held")].(*ir.Model) @@ -1245,7 +1246,7 @@ func TestAllOf_NonBareNarrowingIsOrderInvariant(t *testing.T) { t.Parallel() types := make([]ir.TypeDef, 0, 2) for _, order := range [][2]string{{tc.narrow, tc.broad}, {tc.broad, tc.narrow}} { - doc, diags := lowerSpec(t, nonBareSpec(tc.extra, order[0], order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec(tc.extra, order[0], order[1])) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok) @@ -1274,7 +1275,7 @@ func TestAllOf_AnUnknownFormatWinsInEitherOrder(t *testing.T) { plain := "{type: string}" formatted := "{type: string, format: hex-token, minLength: 8, default: x, description: the token}" for i, order := range [][2]string{{plain, formatted}, {formatted, plain}} { - doc, diags := lowerSpec(t, nonBareSpec("", order[0], order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok) @@ -1318,7 +1319,7 @@ func TestAllOf_ReconcileAccumulatesRicherDetailForAnUnknownFormat(t *testing.T) examples: [abc] xml: {name: tok} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) require.True(t, ok, "Tokenish should be a model") @@ -1345,7 +1346,7 @@ func TestAllOf_TheTopTypeAndANullableKindMeetAsNullableInEitherOrder(t *testing. t.Parallel() top, nullable := "{}", `{type: [integer, "null"]}` for i, order := range [][2]string{{top, nullable}, {nullable, top}} { - doc, diags := lowerSpec(t, nonBareSpec("", order[0], order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok) @@ -1372,7 +1373,7 @@ func TestAllOf_ADefaultTheNarrowerNodeRefusesIsKeptInEitherOrder(t *testing.T) { {[2]string{enum, broad}, "/components/schemas/T/allOf/1/properties/v"}, {[2]string{broad, enum}, "/components/schemas/T/allOf/0/properties/v"}, } { - doc, diags := lowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok) @@ -1399,7 +1400,7 @@ func TestAllOf_AWinnersConstraintsRefuseADefaultInEitherOrder(t *testing.T) { named := "{$ref: '#/components/schemas/C'}" lowerings := map[string]func(extra, a, b string) (*ir.Document, []ir.Diagnostic){ "declared before": func(extra, a, b string) (*ir.Document, []ir.Diagnostic) { - return lowerSpec(t, nonBareSpec(extra, a, b)) + return schema.LowerSpec(t, nonBareSpec(extra, a, b)) }, "declared after": func(extra, a, b string) (*ir.Document, []ir.Diagnostic) { return settledSpec(t, extra, a, b) }, } @@ -1448,7 +1449,7 @@ func TestAllOf_TwoEqualListsRefuseANullTheMergeDoesNotAdmit(t *testing.T) { {[2]string{nullable, plain}, "/components/schemas/T/allOf/0/properties/v"}, {[2]string{plain, nullable}, "/components/schemas/T/allOf/1/properties/v"}, } { - doc, diags := lowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok) @@ -1474,7 +1475,7 @@ func TestAllOf_TwoEqualListsRefuseANullTheMergeDoesNotAdmit(t *testing.T) { // info, with the first kept and the second's $ref kept verbatim. func TestAllOf_IdenticalNodesAreOneType(t *testing.T) { t.Parallel() - for _, schema := range []string{ + for _, body := range []string{ "{type: string, enum: [a, b]}", "{const: a}", "{type: string, format: hex}", @@ -1482,22 +1483,22 @@ func TestAllOf_IdenticalNodesAreOneType(t *testing.T) { "{type: array, items: {type: integer, format: int32}}", "{type: object, additionalProperties: {type: integer}}", } { - t.Run(schema, func(t *testing.T) { + t.Run(body, func(t *testing.T) { t.Parallel() - extra := " P: " + schema + "\n Q: " + schema + "\n" + extra := " P: " + body + "\n Q: " + body + "\n" p, q := "{$ref: '#/components/schemas/P'}", "{$ref: '#/components/schemas/Q'}" for i, tc := range []struct { order [2]string win ir.TypeID // the type that stands same bool }{ - {[2]string{schema, schema}, "t/anon/components/schemas/T/allOf/0/properties/v", true}, - {[2]string{p, schema}, componentID("P"), true}, - {[2]string{schema, p}, componentID("P"), true}, + {[2]string{body, body}, "t/anon/components/schemas/T/allOf/0/properties/v", true}, + {[2]string{p, body}, componentID("P"), true}, + {[2]string{body, p}, componentID("P"), true}, {[2]string{p, q}, componentID("P"), false}, {[2]string{q, p}, componentID("Q"), false}, } { - doc, diags := lowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok, "order %d", i) require.Len(t, m.Properties, 1, "order %d", i) @@ -1545,7 +1546,7 @@ func TestAllOf_ARefToAnInlineNodeIsDeclaredApart(t *testing.T) { {[2]string{c, d}, at("c"), false}, {[2]string{d, c}, at("d"), false}, } { - doc, diags := lowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok, "order %d", i) require.Len(t, m.Properties, 1, "order %d", i) @@ -1583,7 +1584,7 @@ func TestAllOf_ACopyANarrowingKeptIsStillACopy(t *testing.T) { if colorLast { schemas = composed + color } - doc, diags := lowerSpec(t, openapitest.ComponentSpec(schemas)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(schemas)) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok, "order %v, Color last %t", order, colorLast) require.Len(t, m.Properties, 1, "order %v, Color last %t", order, colorLast) @@ -1603,7 +1604,7 @@ func TestAllOf_AListOfAComponentWinsOverAListOfItsCopy(t *testing.T) { const enum = "{type: string, enum: [a, b]}" ofRef, ofCopy := "{type: array, items: {$ref: '#/components/schemas/Color'}}", "{type: array, items: "+enum+"}" for i, order := range [][2]string{{ofRef, ofCopy}, {ofCopy, ofRef}} { - doc, diags := lowerSpec(t, nonBareSpec(" Color: "+enum+"\n", order[0], order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec(" Color: "+enum+"\n", order[0], order[1])) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok, "order %d", i) require.Len(t, m.Properties, 1, "order %d", i) @@ -1631,7 +1632,7 @@ func TestAllOf_ARefToASiblingFieldIsDeclaredApart(t *testing.T) { if i == 1 { branches[0], branches[1] = branches[1], branches[0] } - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" T:\n allOf:\n"+branches[0]+branches[1])) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" T:\n allOf:\n"+branches[0]+branches[1])) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok, "order %d", i) var v *ir.Property @@ -1676,7 +1677,7 @@ func TestAllOf_ARedeclarationIsJudgedOnceTheComponentsHaveLowered(t *testing.T) pq := " P: " + tc.pdecl + "\n Q: " + tc.qdecl + "\n" qp := " Q: " + tc.qdecl + "\n P: " + tc.pdecl + "\n" for i, lower := range []func() (*ir.Document, []ir.Diagnostic){ - func() (*ir.Document, []ir.Diagnostic) { return lowerSpec(t, nonBareSpec(pq, a, b)) }, + func() (*ir.Document, []ir.Diagnostic) { return schema.LowerSpec(t, nonBareSpec(pq, a, b)) }, func() (*ir.Document, []ir.Diagnostic) { return settledSpec(t, pq, a, b) }, func() (*ir.Document, []ir.Diagnostic) { return settledSpec(t, qp, a, b) }, } { @@ -1711,7 +1712,7 @@ func TestAllOf_WhatAFieldKeepsOfItsDetailIsOrderFree(t *testing.T) { a := "{type: number, default: 1, x-foo: 1, x-same: s}" b := "{type: number, default: 1.0, x-foo: 2, x-same: s, deprecated: true, x-deprecated-reason: use q}" for _, order := range [][2]string{{a, b}, {b, a}} { - doc, diags := lowerSpec(t, nonBareSpec("", order[0], order[1])) + doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) m, ok := doc.Types[componentID("T")].(*ir.Model) require.True(t, ok) require.Len(t, m.Properties, 1) @@ -1752,7 +1753,7 @@ func TestAllOf_ContainerMembersAreJudgedAsFieldsAre(t *testing.T) { {"both lists admitting the empty one", "", diag.DegradedConstruct, ir.SeverityInfo}, } { for _, order := range [][2]string{{fmt.Sprintf(str, tc.min), num}, {num, fmt.Sprintf(str, tc.min)}} { - _, diags := lowerSpec(t, nonBareSpec("", order[0], order[1])) + _, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) require.Len(t, diags, 1, "%s, order %v: %+v", tc.name, order, diags) assert.Equal(t, tc.code, diags[0].Code, "%s, order %v", tc.name, order) assert.Equal(t, tc.sev, diags[0].Severity, "%s, order %v", tc.name, order) @@ -2281,7 +2282,7 @@ func TestAllOf_UntypedRedeclarationDoesNotConflict(t *testing.T) { properties: id: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "Anyish").(*ir.Model) require.True(t, ok, "Anyish should be a model") @@ -2303,7 +2304,7 @@ func TestAllOf_EquivalentNumericBoundsDoNotConflict(t *testing.T) { properties: n: {type: number, minimum: 10.0} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, conflictDiags(diags), "10 and 10.0 are the same numeric bound, not a conflict") @@ -2322,7 +2323,7 @@ func TestAllOf_DifferingNumericBoundsConflict(t *testing.T) { properties: n: {type: integer, minimum: 10} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) require.Len(t, conflicts, 1, "differing numeric bounds are diagnosed once") @@ -2344,7 +2345,7 @@ func TestAllOf_ScalarVersusObjectRedeclarationConflicts(t *testing.T) { properties: x: {type: string} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) require.Len(t, conflicts, 1, "a scalar against an object is diagnosed once") @@ -2372,7 +2373,7 @@ func TestAllOf_DistinctInlineObjectsDoNotConflict(t *testing.T) { properties: x: {type: string} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, conflictDiags(diags), "two distinct inline objects for one field are not a provable conflict") @@ -2446,7 +2447,7 @@ func TestAllOf_ConstraintAndFormatConflictsDiagnosed(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, diags := lowerSpec(t, allOfConflictSpec(tc.a, tc.b)) + _, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) require.Len(t, conflicts, 1, "%s is diagnosed exactly once", tc.name) @@ -2482,7 +2483,7 @@ func TestAllOf_PrimitiveNarrowingIsLosslessInEitherOrder(t *testing.T) { t.Parallel() var merged [2]ir.TypeRef for i, spec := range []string{allOfConflictSpec(tc.broad, tc.narrow), allOfConflictSpec(tc.narrow, tc.broad)} { - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, diags, "a narrowing draws no diagnostic (order %d)", i) model, ok := doc.Types[componentID("T")].(*ir.Model) @@ -2575,7 +2576,7 @@ func TestAllOf_CompatibleConstraintRedeclarationsStaySilent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, allOfConflictSpec(tc.a, tc.b)) + doc, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) assert.Empty(t, diags, "%s draws no diagnostic", tc.name) if tc.assertMerged == nil { return @@ -2602,7 +2603,7 @@ func TestAllOf_OpaqueScalarVsPrimitiveNoConflict(t *testing.T) { properties: id: {format: custom} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, conflictDiags(diags), "opaque scalar vs primitive is not flagged as a conflict") @@ -2625,7 +2626,7 @@ func TestAllOf_ThreeWayRedeclarationProducesTwoDiagnostics(t *testing.T) { properties: id: {type: boolean} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) require.Len(t, conflicts, 2, "three-way redeclaration produces two diagnostics") @@ -2647,7 +2648,7 @@ func TestAllOf_ThreeWayCompatibleRedeclarationStaysSilent(t *testing.T) { properties: id: {type: string} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, conflictDiags(diags), "three-way compatible redeclaration stays silent") @@ -2670,7 +2671,7 @@ func TestAllOf_SatisfiableNarrowingsStaySilent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, diags := lowerSpec(t, allOfConflictSpec(tc.a, tc.b)) + _, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) openapitest.RequireNoErrorDiags(t, diags) assert.Empty(t, conflictDiags(diags), "%s must not be reported as a conflict", tc.name) }) @@ -2682,7 +2683,7 @@ func TestAllOf_EnumVersusIncompatibleTypeStillConflicts(t *testing.T) { // Guard against over-correcting the enum false-positive fix: an enum whose // declared ValueType is string still provably conflicts with an // incompatible primitive (integer). - _, diags := lowerSpec(t, allOfConflictSpec( + _, diags := schema.LowerSpec(t, allOfConflictSpec( "{type: string, enum: [active, inactive]}", "{type: integer}")) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) @@ -2695,7 +2696,7 @@ func TestAllOf_TypeConflictMessageNamesBothTypes(t *testing.T) { // The type-conflict diagnostic must say what actually differs, not just // that something did: the two conflicting type identities, so the author // can see at a glance what disagreed without cross-referencing the spec. - _, diags := lowerSpec(t, allOfConflictSpec("{type: string}", "{type: integer}")) + _, diags := schema.LowerSpec(t, allOfConflictSpec("{type: string}", "{type: integer}")) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) require.Len(t, conflicts, 1) @@ -2720,7 +2721,7 @@ func TestAllOf_PropertyAlongsideAllOfConflictMessageIsAccurate(t *testing.T) { properties: id: {type: integer} `) - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) conflicts := conflictDiags(diags) require.Len(t, conflicts, 1, "the co-declared property conflict is diagnosed once") @@ -2738,7 +2739,7 @@ func TestAllOf_PropertyAlongsideAllOfConflictMessageIsAccurate(t *testing.T) { // the same answer a bare `false` schema gets in its own right. func TestAllOf_FalseBranchClosesTheComposition(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` Never: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` Never: allOf: - false - type: object @@ -2762,7 +2763,7 @@ func TestAllOf_FalseBranchClosesTheComposition(t *testing.T) { // happen either. func TestAllOf_UnpreservableBranchIsNotAnnounced(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` M: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` M: allOf: - {type: string, maxLength: 3, x-t: `+unpreservableValue+`} `)) diff --git a/compilers/openapi/internal/schema/array_test.go b/compilers/openapi/internal/schema/array_test.go index c7c4ebce..d2729adf 100644 --- a/compilers/openapi/internal/schema/array_test.go +++ b/compilers/openapi/internal/schema/array_test.go @@ -75,7 +75,7 @@ func TestLower_TupleRest(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" Tup:\n"+tc.body)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Tup:\n"+tc.body)) openapitest.RequireNoErrorDiags(t, diags) tup, ok := typeByName(doc, "Tup").(*ir.Tuple) @@ -104,7 +104,7 @@ func TestLower_ListConstraints(t *testing.T) { maxItems: 9 uniqueItems: true `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) l, ok := typeByName(doc, "L").(*ir.List) require.True(t, ok) @@ -122,7 +122,7 @@ func TestLower_ListWithoutItems(t *testing.T) { for _, version := range []string{"3.0.3", "3.1.0", "3.2.0"} { t.Run(version, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpecVer(version, " L: {type: array}\n")) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer(version, " L: {type: array}\n")) openapitest.RequireNoErrorDiags(t, diags) l, ok := typeByName(doc, "L").(*ir.List) require.True(t, ok) @@ -190,8 +190,8 @@ func TestLower_MissingArrayItems(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - l, loadDiags := loweredFor(t, openapitest.ComponentSpecVer(tc.version, tc.schemas)) - raw := schema.LowerComponentSchemas(t.Context(), l.ctx, l.types) + l, loadDiags := schema.LoweredFor(t, openapitest.ComponentSpecVer(tc.version, tc.schemas)) + raw := schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types) openapitest.RequireNoErrorDiags(t, raw) var invalid []string for _, d := range loadDiags { @@ -221,15 +221,15 @@ func TestLower_MissingArrayItemsAtAReferencedPositionWarnsOnce(t *testing.T) { for name, schemas := range map[string]string{"ref first": ref + holder, "declaration first": holder + ref} { t.Run(name, func(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpecVer("3.0.3", schemas)) - raw := schema.LowerComponentSchemas(t.Context(), l.ctx, l.types) + l, _ := schema.LoweredFor(t, openapitest.ComponentSpecVer("3.0.3", schemas)) + raw := schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types) require.Len(t, missingItemsAt(t, raw), 1, "the declaration lowers the property once") - _, finished := l.types.Finish(t.Context()) + _, finished := l.Types.Finish(t.Context()) raw = append(raw, finished...) require.Len(t, missingItemsAt(t, raw), 1, "and the reference's promise lowers it no more") - l.diags.AppendAll(raw) - assert.Equal(t, []string{tags}, missingItemsAt(t, l.diags.List())) + l.Diags.AppendAll(raw) + assert.Equal(t, []string{tags}, missingItemsAt(t, l.Diags.List())) }) } } diff --git a/compilers/openapi/internal/schema/branch_internal_test.go b/compilers/openapi/internal/schema/branch_internal_test.go index d399830e..3213ae58 100644 --- a/compilers/openapi/internal/schema/branch_internal_test.go +++ b/compilers/openapi/internal/schema/branch_internal_test.go @@ -13,13 +13,13 @@ import ( // no target, which is the answer a caller that did not filter to $refs gets. func TestRefBranchTarget_InlineBranchNamesNothing(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(` M: + l, _ := LoweredFor(t, openapitest.ComponentSpec(` M: allOf: - {type: object} `)) - decl, ok := l.ctx.Doc.Components.Schemas.Get("M") + decl, ok := l.Ctx.Doc.Components.Schemas.Get("M") require.True(t, ok) - assert.Nil(t, refBranchTarget(l.ctx.Bindings(), decl.GetSchema().GetAllOf()[0])) + assert.Nil(t, refBranchTarget(l.Ctx.Bindings(), decl.GetSchema().GetAllOf()[0])) } func TestIsRefBranch_Nil(t *testing.T) { diff --git a/compilers/openapi/internal/schema/census_internal_test.go b/compilers/openapi/internal/schema/census_internal_test.go index 5e57fc5c..2cbec26c 100644 --- a/compilers/openapi/internal/schema/census_internal_test.go +++ b/compilers/openapi/internal/schema/census_internal_test.go @@ -17,8 +17,8 @@ import ( // rather than preserving them onto nothing. func TestPreserveUnhomedKeywords_MissingNode(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - got, diags := preserveUnhomedKeywords(l.ctx, l.types, &oas3.Schema{}, "/p", "t/anon/missing", dispatch{}) + l := NewRawLowerer(&soa.OpenAPI{}) + got, diags := preserveUnhomedKeywords(l.Ctx, l.Types, &oas3.Schema{}, "/p", "t/anon/missing", dispatch{}) assert.Equal(t, ir.TypeID("t/anon/missing"), got, "the lowering's own ID still stands") assertInternalInvariant(t, diags) } @@ -29,8 +29,8 @@ func TestPreserveUnhomedKeywords_MissingNode(t *testing.T) { // interned — so it is reached directly, as preserveUnionSiblings' guard is. func TestRecordUnhomedKeywords_MissingOwner(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - diags := recordUnhomedKeywords(l.ctx, l.types, "t/anon/missing", &oas3.Schema{}, []string{"items"}, nodeShape(ir.KindPrimitive), "/p") + l := NewRawLowerer(&soa.OpenAPI{}) + diags := recordUnhomedKeywords(l.Ctx, l.Types, "t/anon/missing", &oas3.Schema{}, []string{"items"}, nodeShape(ir.KindPrimitive), "/p") assertInternalInvariant(t, diags) } diff --git a/compilers/openapi/internal/schema/census_test.go b/compilers/openapi/internal/schema/census_test.go index 4cfb8bf4..89b5ceae 100644 --- a/compilers/openapi/internal/schema/census_test.go +++ b/compilers/openapi/internal/schema/census_test.go @@ -10,6 +10,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/pass" ) @@ -20,7 +21,7 @@ import ( // kept verbatim and reported rather than silently dropped. func TestUnhomedApplicator_KeptOnAListAndAnnounced(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` Odd: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` Odd: type: array items: {type: string} properties: {p: {type: string}} @@ -41,7 +42,7 @@ func TestUnhomedApplicator_KeptOnAListAndAnnounced(t *testing.T) { // read as though the object were an array. func TestUnhomedApplicator_ObjectCarriesNoItemKeyword(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` Odd: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` Odd: type: object properties: {p: {type: string}} items: {type: string} @@ -61,7 +62,7 @@ func TestUnhomedApplicator_ObjectCarriesNoItemKeyword(t *testing.T) { // looking in Unmodeled for something that is not there. func TestUnhomedApplicator_UnpreservableKeywordIsNotAnnounced(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` Odd: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` Odd: type: object properties: {p: {type: string}} items: {type: string, x-t: `+unpreservableValue+`} @@ -85,7 +86,7 @@ func TestUnhomedApplicator_UnpreservableKeywordIsNotAnnounced(t *testing.T) { // docs/compilers/openapi.md#shape-applicator for this keyword. func TestUnhomedApplicator_FormatWithNoTypeIsKept(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" Odd: {format: date-time}\n")) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Odd: {format: date-time}\n")) openapitest.RequireNoErrorDiags(t, diags) td := typeByName(doc, "Odd") @@ -140,7 +141,7 @@ func TestUnhomedKeywords_ElectedLoweringKeepsWhatItCannotRead(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, keywordCensusSpec(" S: "+tc.body+"\n")) + doc, diags := schema.LowerSpec(t, keywordCensusSpec(" S: "+tc.body+"\n")) openapitest.RequireNoErrorDiags(t, diags) td := typeByName(doc, "S") @@ -171,7 +172,7 @@ func TestUnhomedKeywords_BoundsThatLandedAreNotAlsoKept(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, keywordCensusSpec(" S: "+tc.body+"\n")) + doc, diags := schema.LowerSpec(t, keywordCensusSpec(" S: "+tc.body+"\n")) openapitest.RequireNoErrorDiags(t, diags) td := typeByName(doc, "S") @@ -189,7 +190,7 @@ func TestUnhomedKeywords_BoundsThatLandedAreNotAlsoKept(t *testing.T) { // an array reaches nothing however full the field looks. func TestUnhomedKeywords_ArrayBoundsKeepWhatCollectionConstraintsDoesNotRead(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, keywordCensusSpec( + doc, diags := schema.LowerSpec(t, keywordCensusSpec( " S: {type: array, items: {type: string}, minItems: 1, minLength: 3}\n")) openapitest.RequireNoErrorDiags(t, diags) diff --git a/compilers/openapi/internal/schema/constraints_internal_test.go b/compilers/openapi/internal/schema/constraints_internal_test.go index 53e7a0ea..8e47b950 100644 --- a/compilers/openapi/internal/schema/constraints_internal_test.go +++ b/compilers/openapi/internal/schema/constraints_internal_test.go @@ -20,14 +20,14 @@ import ( // either way. func TestConstraintsOf_NonSchemaInputs(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) for _, js := range []*oas3.Schema{ annotation.SchemaOf(nil), annotation.SchemaOf(oas3.NewJSONSchemaFromBool(true)), annotation.SchemaOf(oas3.NewJSONSchemaFromReference("#/components/schemas/Other")), } { var kept ir.Unmodeled - cons, diags := ConstraintsOf(l.ctx, &kept, js, "/p") + cons, diags := ConstraintsOf(l.Ctx, &kept, js, "/p") assert.Nil(t, cons) assert.Empty(t, diags) assert.Empty(t, kept) @@ -41,10 +41,10 @@ func TestConstraintsOf_NonSchemaInputs(t *testing.T) { // never emits this shape, so it is built by hand. func TestConstraintsOf_EmptyRefSchema(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) js := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{Ref: new(references.Reference(""))}) var kept ir.Unmodeled - cons, diags := ConstraintsOf(l.ctx, &kept, annotation.SchemaOf(js), "/p") + cons, diags := ConstraintsOf(l.Ctx, &kept, annotation.SchemaOf(js), "/p") assert.Nil(t, cons) assert.Empty(t, diags) assert.Empty(t, kept) @@ -56,13 +56,13 @@ func TestConstraintsOf_EmptyRefSchema(t *testing.T) { // it, not at the position that declared it. func TestStampConstraintDiags_RelocatesEveryDiagnosticToTheReadingPointer(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) in := []ir.Diagnostic{ {Code: diag.DegradedConstruct, Provenance: ir.Provenance{Pointer: "/elsewhere"}}, {Code: diag.NumericPrecision, Provenance: ir.Provenance{Source: 9, Pointer: "/other"}}, } - got := stampConstraintDiags(l.ctx, in, "/components/schemas/S") + got := stampConstraintDiags(l.Ctx, in, "/components/schemas/S") require.Len(t, got, 2) for _, d := range got { diff --git a/compilers/openapi/internal/schema/constraints_test.go b/compilers/openapi/internal/schema/constraints_test.go index 3d9b4827..d7a30535 100644 --- a/compilers/openapi/internal/schema/constraints_test.go +++ b/compilers/openapi/internal/schema/constraints_test.go @@ -9,6 +9,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -171,7 +172,7 @@ func TestConstraints_ExclusiveBoolean30(t *testing.T) { // 3.0 boolean form with a type-mismatch; because those keywords are Morphic's // to own, load suppresses that false positive, so a valid 3.0 boolean exclusive // bound lowers cleanly with the flag set and no error diagnostic. - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) c := propConstraints(t, doc, "S", "n") // The modifier is a spelling of the exclusive bound, so the literal it @@ -195,7 +196,7 @@ func TestConstraints_ExclusiveNumeric31(t *testing.T) { exclusiveMinimum: 1.5 exclusiveMaximum: 9.5 `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) c := propConstraints(t, doc, "S", "n") assert.Nil(t, c.Min) @@ -214,7 +215,7 @@ func TestConstraints_MalformedNumericLiterals(t *testing.T) { a: {type: number, minimum: .inf} b: {type: number, exclusiveMinimum: .inf} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) var count int for _, d := range diags { @@ -237,7 +238,7 @@ func TestConstraints_NumericPrecisionSurvives(t *testing.T) { maximum: 9007199254740993 multipleOf: 0.1 `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) c := m.Properties[0].Constraints @@ -267,7 +268,7 @@ func TestConstraints_LosslessNumericLiterals(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" S:\n type: object\n properties:\n n: {type: number, minimum: " + tc.literal + "}\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) // A valid number, however spelled, is accepted with no error: the // library's float64/JSON complaint is not surfaced. openapitest.RequireNoErrorDiags(t, diags) @@ -291,7 +292,7 @@ func TestConstraints_HoistedSubSchemaBadBoundSingleError(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" Foo:\n type: object\n properties:\n bar: {type: number, minimum: hello}\n" + " User:\n type: object\n properties:\n b: {$ref: '#/components/schemas/Foo/properties/bar'}\n") - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.NumericPrecision, ir.SeverityError), "one error for the shared bad bound, got: %+v", diags) } @@ -314,7 +315,7 @@ func TestConstraints_ExclusiveWrongDialectForm(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpecVer(tc.version, " S:\n type: object\n properties:\n n: {type: number, exclusiveMinimum: "+tc.value+"}\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.ExclusiveBoundForm, ir.SeverityError), "one dialect-form error, got: %+v", diags) @@ -333,7 +334,7 @@ func TestConstraints_ExclusiveWrongDialectForm(t *testing.T) { func TestConstraints_TypeWrongBoundYieldsSingleError(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" S:\n type: object\n properties:\n n: {type: number, minimum: hello}\n") - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) // Exactly one diagnostic: Morphic's error with the schema's own provenance. // The library emits two redundant float64 type-mismatch findings on the same // keyword; load suppresses both because Morphic owns numeric-bound keywords. @@ -346,7 +347,7 @@ func TestConstraints_TypeWrongBoundYieldsSingleError(t *testing.T) { func TestConstraints_NonNumericMinimumErrors(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" S:\n type: object\n properties:\n n: {type: number, minimum: hello}\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) // A genuinely non-numeric bound is never dropped silently: Morphic owns the // keyword and reports it as an error with the property's exact provenance. @@ -381,7 +382,7 @@ func propConstraints(t *testing.T, doc *ir.Document, model, wire string) *ir.Con func TestComponentConstraints_DiagnosticProvenance(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" BadN: {type: number, minimum: hello}\n") - _, diags := lowerSpec(t, spec) + _, diags := schema.LowerSpec(t, spec) var found bool for _, d := range diags { if d.Code == diag.NumericPrecision { diff --git a/compilers/openapi/internal/schema/declaration_internal_test.go b/compilers/openapi/internal/schema/declaration_internal_test.go index b2a4772d..7064a40d 100644 --- a/compilers/openapi/internal/schema/declaration_internal_test.go +++ b/compilers/openapi/internal/schema/declaration_internal_test.go @@ -22,14 +22,14 @@ func TestAttachDeclaredAnnotations_LeavesANodeStillBeingBuiltToItsBuilder(t *tes l, s, at := lowererDeclaringS(t) var midBuild []ir.Diagnostic - id := internNode(l.ctx, l.types, at, func(cm ir.TypeCommon) ir.TypeDef { - require.NotPanics(t, func() { midBuild = attachDeclaredAnnotations(l.ctx, l.types, s, at) }) + id := internNode(l.Ctx, l.Types, at, func(cm ir.TypeCommon) ir.TypeDef { + require.NotPanics(t, func() { midBuild = attachDeclaredAnnotations(l.Ctx, l.Types, s, at) }) return &ir.Model{TypeCommon: cm} }) assert.Empty(t, midBuild, "a revisit mid-build reports nothing") - assert.Empty(t, attachDeclaredAnnotations(l.ctx, l.types, s, at)) - td, ok := l.types.Node(id) + assert.Empty(t, attachDeclaredAnnotations(l.Ctx, l.Types, s, at)) + td, ok := l.Types.Node(id) require.True(t, ok) assert.Equal(t, "kept", td.Common().Docs.Description, "the builder's own pass attaches it") } @@ -42,15 +42,15 @@ func TestRecordDeclarationResidue_LeavesANodeStillBeingBuiltToItsBuilder(t *test l, s, at := lowererDeclaringS(t) var midBuild []ir.Diagnostic - id := internNode(l.ctx, l.types, at, func(cm ir.TypeCommon) ir.TypeDef { - require.NotPanics(t, func() { midBuild = recordDeclarationResidue(l.ctx, l.types, s, at, annotation.HomeOwnNode) }) + id := internNode(l.Ctx, l.Types, at, func(cm ir.TypeCommon) ir.TypeDef { + require.NotPanics(t, func() { midBuild = recordDeclarationResidue(l.Ctx, l.Types, s, at, annotation.HomeOwnNode) }) return &ir.Model{TypeCommon: cm} }) assert.Empty(t, midBuild, "a revisit mid-build records and reports nothing") - assert.NotEmpty(t, recordDeclarationResidue(l.ctx, l.types, s, at, annotation.HomeOwnNode), + assert.NotEmpty(t, recordDeclarationResidue(l.Ctx, l.Types, s, at, annotation.HomeOwnNode), "the builder's own pass reports what it keeps") - td, ok := l.types.Node(id) + td, ok := l.Types.Node(id) require.True(t, ok) assert.Contains(t, td.Common().Unmodeled, "openapi:readOnly", "and keeps it") } @@ -58,12 +58,12 @@ func TestRecordDeclarationResidue_LeavesANodeStillBeingBuiltToItsBuilder(t *test // lowererDeclaringS returns a lowerer over a component S that declares a // description and readOnly, with S's schema and pointer, for driving a reader // at S while its node is still being built and again once the build returns. -func lowererDeclaringS(t *testing.T) (*lowerer, *oas3.Schema, jsontext.Pointer) { +func lowererDeclaringS(t *testing.T) (*Lowerer, *oas3.Schema, jsontext.Pointer) { t.Helper() - l, diags := loweredFor(t, openapitest.ComponentSpec( + l, diags := LoweredFor(t, openapitest.ComponentSpec( " S: {type: object, description: kept, readOnly: true}\n")) openapitest.RequireNoErrorDiags(t, diags) - decl, ok := l.ctx.Doc.Components.Schemas.Get("S") + decl, ok := l.Ctx.Doc.Components.Schemas.Get("S") require.True(t, ok) return l, decl.GetSchema(), "/components/schemas/S" } diff --git a/compilers/openapi/internal/schema/declaration_test.go b/compilers/openapi/internal/schema/declaration_test.go index 86796b5d..8538fc3a 100644 --- a/compilers/openapi/internal/schema/declaration_test.go +++ b/compilers/openapi/internal/schema/declaration_test.go @@ -32,7 +32,7 @@ func TestLower_ValidationOnlyKeywords(t *testing.T) { unevaluatedProperties: {type: string} unevaluatedItems: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "V").(*ir.Model) // An entry the source writes as one keyword is located at that keyword; the @@ -63,7 +63,7 @@ func TestLower_PropertyNamesPreserved(t *testing.T) { propertyNames: {type: string, pattern: "^[a-z]+$"} additionalProperties: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) mt, ok := typeByName(doc, "Codes").(*ir.MapT) @@ -86,7 +86,7 @@ func TestModel_ValidationOnlyKeywordPreserved(t *testing.T) { properties: {a: {type: string}} not: {required: [b]} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) m := doc.Types[componentID("S")].(*ir.Model) raw, ok := m.Unmodeled["openapi:not"] require.True(t, ok, "not-keyword must be preserved verbatim") @@ -113,7 +113,7 @@ func TestModel_SchemaExtensionPreserved(t *testing.T) { x-rate-limit: 100 properties: {a: {type: string}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) raw, ok := m.Unmodeled["openapi:x-rate-limit"] @@ -132,7 +132,7 @@ func TestModel_TitleDescriptionDocs(t *testing.T) { description: "My Desc" properties: {a: {type: string}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) assert.Equal(t, "My Title", m.Docs.Summary) @@ -146,7 +146,7 @@ func TestSchema_UnserializableExtension(t *testing.T) { properties: {a: {type: string}} x-bad: {n: .nan} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityWarning), "unserializable extension warns") m := typeByName(doc, "S").(*ir.Model) diff --git a/compilers/openapi/internal/schema/discriminator_internal_test.go b/compilers/openapi/internal/schema/discriminator_internal_test.go index 5bf75f1b..201e02bd 100644 --- a/compilers/openapi/internal/schema/discriminator_internal_test.go +++ b/compilers/openapi/internal/schema/discriminator_internal_test.go @@ -31,7 +31,7 @@ func TestPropIDByName_NotFound(t *testing.T) { func TestMappingTargetID(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(` Cat: {type: object} + l, _ := LoweredFor(t, openapitest.ComponentSpec(` Cat: {type: object} Dog: {type: object} A/B: {type: object} Pet: @@ -42,25 +42,25 @@ func TestMappingTargetID(t *testing.T) { `)) d := petDiscriminator(t, l) // A $ref to a declared component. - id, ok := mappingTargetID(l.ctx, l.ctx.RefScope(), d, "#/components/schemas/Cat") + id, ok := mappingTargetID(l.Ctx, l.Ctx.RefScope(), d, "#/components/schemas/Cat") require.True(t, ok) assert.Equal(t, ids.Document{}.NamedType("/components/schemas/Cat"), id) // A bare schema name. - id, ok = mappingTargetID(l.ctx, l.ctx.RefScope(), d, "Dog") + id, ok = mappingTargetID(l.Ctx, l.Ctx.RefScope(), d, "Dog") require.True(t, ok) assert.Equal(t, ids.Document{}.NamedType(ids.Ptr("components", "schemas", "Dog")), id) // A bare name that contains '/' but names an existing schema must resolve, not // dangle as a misclassified external $ref (issue #14, f07). - id, ok = mappingTargetID(l.ctx, l.ctx.RefScope(), d, "A/B") + id, ok = mappingTargetID(l.Ctx, l.Ctx.RefScope(), d, "A/B") require.True(t, ok) assert.Equal(t, ids.Document{}.NamedType(ids.Ptr("components", "schemas", "A/B")), id) // An undeclared component and a genuine external ref are dropped, never // synthesized into a dangling ID. - _, ok = mappingTargetID(l.ctx, l.ctx.RefScope(), d, "#/components/schemas/Ghost") + _, ok = mappingTargetID(l.Ctx, l.Ctx.RefScope(), d, "#/components/schemas/Ghost") assert.False(t, ok, "undeclared component target dropped") - _, ok = mappingTargetID(l.ctx, l.ctx.RefScope(), d, "a.yaml#/A") + _, ok = mappingTargetID(l.Ctx, l.Ctx.RefScope(), d, "a.yaml#/A") assert.False(t, ok, "external target dropped") - _, ok = mappingTargetID(l.ctx, l.ctx.RefScope(), &oas3.Discriminator{}, "#/components/schemas/Cat") + _, ok = mappingTargetID(l.Ctx, l.Ctx.RefScope(), &oas3.Discriminator{}, "#/components/schemas/Cat") assert.False(t, ok, "a discriminator the document does not hold maps nothing") // A declared but empty-named component ("") is interned anonymously, so its // bare mapping name must resolve to that anon ID, not an unbacked ids.NamedType @@ -76,22 +76,22 @@ func TestMappingTargetID(t *testing.T) { func TestDiscriminatorDefault_ResolvesDeclaredComponent(t *testing.T) { t.Parallel() - l := newRawLowerer(openapitest.DocDeclaring("Cat")) + l := NewRawLowerer(openapitest.DocDeclaring("Cat")) d := &oas3.Discriminator{PropertyName: "kind", DefaultMapping: new("Cat")} - id, diags := discriminatorDefault(l.ctx, l.types, d, nil, "/components/schemas/Pet") + id, diags := discriminatorDefault(l.Ctx, l.Types, d, nil, "/components/schemas/Pet") assert.Equal(t, ids.Document{}.NamedType("/components/schemas/Cat"), id) assert.Empty(t, diags, "a resolvable defaultMapping produces no diagnostic") } func TestDiscriminatorDefault_DroppedWhenUnresolved(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) // "Missing" is neither a declared component nor an internal pointer, so the // defaultMapping does not resolve and is dropped with one error diagnostic. d := &oas3.Discriminator{PropertyName: "kind", DefaultMapping: new("Missing")} - id, diags := discriminatorDefault(l.ctx, l.types, d, nil, "/components/schemas/Pet") + id, diags := discriminatorDefault(l.Ctx, l.Types, d, nil, "/components/schemas/Pet") assert.Empty(t, id, "an unresolved defaultMapping yields no target") require.Len(t, diags, 1) assert.Equal(t, diag.UnresolvedRef, diags[0].Code) @@ -99,8 +99,8 @@ func TestDiscriminatorDefault_DroppedWhenUnresolved(t *testing.T) { func TestDiscriminatorDefault_EmptyIsNoOp(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - id, diags := discriminatorDefault(l.ctx, l.types, &oas3.Discriminator{PropertyName: "kind"}, nil, "/components/schemas/Pet") + l := NewRawLowerer(&soa.OpenAPI{}) + id, diags := discriminatorDefault(l.Ctx, l.Types, &oas3.Discriminator{PropertyName: "kind"}, nil, "/components/schemas/Pet") assert.Empty(t, id) assert.Empty(t, diags) } @@ -115,7 +115,7 @@ func TestDiscriminatorDefault_EmptyIsNoOp(t *testing.T) { // own until every declaration has lowered. func TestResolveMappingTarget_Branches(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(` Cat: {type: string} + l, _ := LoweredFor(t, openapitest.ComponentSpec(` Cat: {type: string} Pet: type: object properties: {kind: {type: string}} @@ -130,7 +130,7 @@ func TestResolveMappingTarget_Branches(t *testing.T) { `)) d := petDiscriminator(t, l) resolveTarget := func(target string) (ir.TypeID, bool) { - return resolveMappingTarget(l.ctx, l.types, d, target, nil) + return resolveMappingTarget(l.Ctx, l.Types, d, target, nil) } cat := ids.Document{}.NamedType(ids.Ptr("components", "schemas", "Cat")) @@ -140,7 +140,7 @@ func TestResolveMappingTarget_Branches(t *testing.T) { assert.Equal(t, cat, id) } - before := l.types.Len() + before := l.Types.Len() for target, why := range map[string]string{ "other.yaml#/A": "an external reference never resolves", "#/components/schemas/Ghost": "an undeclared component is refused, not hoisted as an inline position", @@ -149,22 +149,22 @@ func TestResolveMappingTarget_Branches(t *testing.T) { _, ok := resolveTarget(target) assert.False(t, ok, why) } - assert.Equal(t, before, l.types.Len(), "a refused target interns nothing") + assert.Equal(t, before, l.Types.Len(), "a refused target interns nothing") const scalarTarget = "#/components/schemas/Pet/properties/kind" kind := ids.Document{}.ForPointer("/components/schemas/Pet/properties/kind") - got, mappingOK := mappingTargetID(l.ctx, l.ctx.RefScope(), d, scalarTarget) + got, mappingOK := mappingTargetID(l.Ctx, l.Ctx.RefScope(), d, scalarTarget) require.True(t, mappingOK, "mappingTargetID names an un-interned scalar position by its pointer") assert.Equal(t, kind, got) id, ok := resolveTarget(scalarTarget) require.True(t, ok, "resolveMappingTarget promises it") assert.Equal(t, kind, id) - _, interned := l.types.Node(id) + _, interned := l.Types.Node(id) assert.False(t, interned, "a promise builds nothing until every declaration has lowered") - _, finished := l.types.Finish(t.Context()) + _, finished := l.Types.Finish(t.Context()) assert.Empty(t, finished) - _, interned = l.types.Node(id) + _, interned = l.Types.Node(id) assert.True(t, interned, "the hoisted alias is registered in the type registry") } @@ -177,25 +177,25 @@ func TestMappingTargetID_NamesAPositionByItsPointer(t *testing.T) { t.Parallel() const sub = "#/components/schemas/Pet/properties/kind" - empty := newRawLowerer(openapitest.DocDeclaring("Pet")) - _, ok := mappingTargetID(empty.ctx, empty.ctx.RefScope(), &oas3.Discriminator{}, sub) + empty := NewRawLowerer(openapitest.DocDeclaring("Pet")) + _, ok := mappingTargetID(empty.Ctx, empty.Ctx.RefScope(), &oas3.Discriminator{}, sub) assert.False(t, ok, "with no bindings the target lands nowhere, so it does not resolve") - l, _ := loweredFor(t, openapitest.ComponentSpec(" Pet:\n type: object\n"+ + l, _ := LoweredFor(t, openapitest.ComponentSpec(" Pet:\n type: object\n"+ " properties: {kind: {type: object, properties: {a: {type: string}}}}\n"+ " discriminator: {propertyName: k, mapping: {s: '"+sub+"'}}\n")) want := ids.Document{}.ForPointer("/components/schemas/Pet/properties/kind") - before, ok := mappingTargetID(l.ctx, l.ctx.RefScope(), petDiscriminator(t, l), sub) + before, ok := mappingTargetID(l.Ctx, l.Ctx.RefScope(), petDiscriminator(t, l), sub) require.True(t, ok, "nothing is interned yet, and the position is named all the same") assert.Equal(t, want, before) - l.diags.AppendAll(LowerComponentSchemas(t.Context(), l.ctx, l.types)) - _, finished := l.types.Finish(t.Context()) - l.diags.AppendAll(finished) - after, ok := mappingTargetID(l.ctx, l.ctx.RefScope(), petDiscriminator(t, l), sub) + l.Diags.AppendAll(LowerComponentSchemas(t.Context(), l.Ctx, l.Types)) + _, finished := l.Types.Finish(t.Context()) + l.Diags.AppendAll(finished) + after, ok := mappingTargetID(l.Ctx, l.Ctx.RefScope(), petDiscriminator(t, l), sub) require.True(t, ok) assert.Equal(t, want, after, "the node interned there has the ID it was named by") - _, interned := l.types.Node(want) + _, interned := l.Types.Node(want) assert.True(t, interned) } @@ -205,15 +205,15 @@ func TestMappingTargetID_NamesAPositionByItsPointer(t *testing.T) { // with n subtypes read each of its positions n times. func TestMappingTargetID_ReadsEachPositionOnce(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(" Cat: {type: object}\n"+ + l, _ := LoweredFor(t, openapitest.ComponentSpec(" Cat: {type: object}\n"+ " Hold: {type: object, properties: {p: {$ref: '#/components/schemas/Cat'}}}\n"+ " Pet: {discriminator: {propertyName: k, mapping: {p: '#/components/schemas/Hold/properties/p'}}}\n")) - id, ok := mappingTargetID(l.ctx, l.ctx.RefScope(), petDiscriminator(t, l), + id, ok := mappingTargetID(l.Ctx, l.Ctx.RefScope(), petDiscriminator(t, l), "#/components/schemas/Hold/properties/p") require.True(t, ok) assert.Equal(t, componentIDByName("Cat"), id, "the position reads through to Cat") - at := l.ctx.TypePosition(bind.Position{Pointer: "/components/schemas/Hold/properties/p"}, func() bind.Position { + at := l.Ctx.TypePosition(bind.Position{Pointer: "/components/schemas/Hold/properties/p"}, func() bind.Position { t.Error("the position was read again") return bind.Position{} }) @@ -228,7 +228,7 @@ func TestMappingTargetID_ReadsEachPositionOnce(t *testing.T) { // each tag keeps its own shape (GitHub #557). func TestResolveMappingTarget_DefsValueNamesItsOwnInternedSibling(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(defsSiblingMappingSchemas)) + doc, diags := LowerSpec(t, openapitest.ComponentSpec(defsSiblingMappingSchemas)) for _, d := range diags { assert.NotEqual(t, ir.SeverityError, d.Severity, "unexpected error diagnostic: %+v", d) } @@ -274,7 +274,7 @@ func TestResolveMappingTarget_DefsValueNothingInternedIsHoistedAtItsOwnPosition( } { t.Run(name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(blocks)) + doc, diags := LowerSpec(t, openapitest.ComponentSpec(blocks)) openapitest.RequireNoErrorDiags(t, diags) for _, owner := range []string{"Pet", "Toy"} { @@ -344,13 +344,13 @@ func TestMappingWhy_GivesAComponentNameNoReasonOfTheTable(t *testing.T) { } deep.WriteString("components:\n schemas:\n T: {type: string}\n A: {<<: *c70}\n" + " P: {oneOf: [{$ref: '#/components/schemas/T'}], discriminator: {propertyName: k, mapping: {u: Undeclared}}}\n") - l, _ := loweredFor(t, deep.String()) - require.True(t, l.ctx.Bindings().Table().Truncated(), "the merge bound cut a read") - p, ok := l.ctx.Doc.Components.GetSchemas().Get("P") + l, _ := LoweredFor(t, deep.String()) + require.True(t, l.Ctx.Bindings().Table().Truncated(), "the merge bound cut a read") + p, ok := l.Ctx.Doc.Components.GetSchemas().Get("P") require.True(t, ok) d := p.GetSchema().GetDiscriminator() require.NotNil(t, d) - assert.Empty(t, mappingWhy(l.ctx, d, "Undeclared")) + assert.Empty(t, mappingWhy(l.Ctx, d, "Undeclared")) } // TestMappingWhy_GivesAnUnreadReferenceTheTablesReason pins that a mapping @@ -367,20 +367,20 @@ func TestMappingWhy_GivesAnUnreadReferenceTheTablesReason(t *testing.T) { fmt.Fprintf(&deep, "x-c%d: &c%d {<<: *c%d}\n", i, i, i-1) } deep.WriteString("components:\n schemas:\n T: {properties: {k: {type: string}}}\n P: {<<: *c70}\n") - l, _ := loweredFor(t, deep.String()) - p, ok := l.ctx.Doc.Components.GetSchemas().Get("P") + l, _ := LoweredFor(t, deep.String()) + p, ok := l.Ctx.Doc.Components.GetSchemas().Get("P") require.True(t, ok) d := p.GetSchema().GetDiscriminator() require.NotNil(t, d, "the parser expands the whole chain") assert.Equal(t, ": it is past a `<<` merge chain deeper than the 64 levels the compiler expands", - mappingWhy(l.ctx, d, "#/components/schemas/T")) + mappingWhy(l.Ctx, d, "#/components/schemas/T")) } // petDiscriminator returns the discriminator the component schema Pet holds // in l's document. -func petDiscriminator(t *testing.T, l *lowerer) *oas3.Discriminator { +func petDiscriminator(t *testing.T, l *Lowerer) *oas3.Discriminator { t.Helper() - js, ok := l.ctx.Doc.Components.GetSchemas().Get("Pet") + js, ok := l.Ctx.Doc.Components.GetSchemas().Get("Pet") require.True(t, ok) d := js.GetSchema().GetDiscriminator() require.NotNil(t, d) diff --git a/compilers/openapi/internal/schema/discriminator_test.go b/compilers/openapi/internal/schema/discriminator_test.go index 28f673a7..40fc541f 100644 --- a/compilers/openapi/internal/schema/discriminator_test.go +++ b/compilers/openapi/internal/schema/discriminator_test.go @@ -13,6 +13,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/pass" ) @@ -40,7 +41,7 @@ func TestAllOf_DiscriminatorSubtypeValue(t *testing.T) { properties: bark: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) pet, ok := doc.Types[componentID("Pet")].(*ir.Model) @@ -100,7 +101,7 @@ func TestAllOf_InlineSubtypeHasNoImplicitDiscriminatorValue(t *testing.T) { properties: meow: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) kennelDog := ir.TypeID("t/anon/components/schemas/Kennel/properties/Dog") @@ -152,7 +153,7 @@ func TestAllOf_InlineSubtypeDiscriminatorValueFromMapping(t *testing.T) { properties: bark: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) kennelDog := ir.TypeID("t/anon/components/schemas/Kennel/properties/Dog") @@ -199,7 +200,7 @@ func TestAllOf_DiscriminatorValueFromDistantMapping(t *testing.T) { - {$ref: '#/components/schemas/Dog'} properties: {weeks: {type: integer}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) dog := typeByName(doc, "Dog").(*ir.Model) @@ -242,7 +243,7 @@ func TestAllOf_DiscriminatorValueFromNearestAncestor(t *testing.T) { - {$ref: '#/components/schemas/Near'} properties: {l: {type: string}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) leaf := typeByName(doc, "Leaf").(*ir.Model) @@ -268,7 +269,7 @@ func TestAllOf_DiscriminatorValueUndiscriminatedChain(t *testing.T) { - {$ref: '#/components/schemas/Shrub'} properties: {rings: {type: integer}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for _, name := range []string{"Shrub", "Sapling"} { @@ -301,7 +302,7 @@ func TestAllOf_DiscriminatorValueCyclicComposition(t *testing.T) { fmt.Fprintf(&b, " X%d:\n allOf: [%s]\n properties: {p%d: {type: string}}\n", i, strings.Join(refs, ", "), i) } - doc, diags := lowerSpec(t, openapitest.ComponentSpec(b.String())) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(b.String())) openapitest.RequireNoErrorDiags(t, diags) for i := range n { @@ -331,7 +332,7 @@ func TestAllOf_DiscriminatorValueSelfAncestorInCycle(t *testing.T) { allOf: [{$ref: '#/components/schemas/A'}] properties: {b: {type: string}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) a, ok := typeByName(doc, "A").(*ir.Model) @@ -374,7 +375,7 @@ func TestAllOf_DiscriminatorValueChainDeeperThanCap(t *testing.T) { " properties: {p%d: {type: string}}\n", i, prev, i) prev = fmt.Sprintf("S%d", i) } - doc, diags := lowerSpec(t, openapitest.ComponentSpec(b.String())) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(b.String())) openapitest.RequireNoErrorDiags(t, diags) leaf := typeByName(doc, prev).(*ir.Model) @@ -394,7 +395,7 @@ func TestModelDiscriminator_UndeclaredPropertyAndBadMapping(t *testing.T) { car: '#' properties: {name: {type: string}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) v := typeByName(doc, "Vehicle").(*ir.Model) require.NotNil(t, v.Discriminator) assert.Empty(t, v.Discriminator.Property, "undeclared property") @@ -415,7 +416,7 @@ func TestOneOf_DiscriminatorWithDefault(t *testing.T) { Circle: {type: object, properties: {r: {type: number}}} Square: {type: object, properties: {s: {type: number}}} `) - doc, _ := lowerSpec(t, spec) + doc, _ := schema.LowerSpec(t, spec) u := typeByName(doc, "Shape").(*ir.Union) require.NotNil(t, u.Discriminator) assert.Equal(t, "shapeType", u.Discriminator.PropertyName) @@ -431,7 +432,7 @@ func TestAllOf_ModelWithOwnDiscriminator(t *testing.T) { properties: {kind: {type: string}} Common: {type: object, properties: {id: {type: string}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) base := typeByName(doc, "Base").(*ir.Model) require.NotNil(t, base.Discriminator, "allOf model may declare its own discriminator") @@ -446,7 +447,7 @@ func TestAllOf_BoolRefBranchHasNoDiscriminator(t *testing.T) { - {$ref: '#/components/schemas/BoolComp'} - {type: object, properties: {a: {type: string}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) sub := typeByName(doc, "Sub").(*ir.Model) assert.Empty(t, sub.DiscriminatorValue, "a bool-schema ref target anchors no hierarchy") @@ -483,7 +484,7 @@ func TestAllOf_DiscriminatorAliasTagIsOrderInvariant(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(base+tc.mapping+sub)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(base+tc.mapping+sub)) openapitest.RequireNoErrorDiags(t, diags) dog, ok := doc.Types[componentID("Dog")].(*ir.Model) diff --git a/compilers/openapi/internal/schema/dynamicref_internal_test.go b/compilers/openapi/internal/schema/dynamicref_internal_test.go index 7aaa0f4d..fbb39548 100644 --- a/compilers/openapi/internal/schema/dynamicref_internal_test.go +++ b/compilers/openapi/internal/schema/dynamicref_internal_test.go @@ -23,9 +23,9 @@ import ( // defect. func TestDeclaresResourceIDAbove_WithoutARawTree(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) - assert.False(t, declaresResourceIDAbove(l.ctx, "/components/schemas/A"), + assert.False(t, declaresResourceIDAbove(l.Ctx, "/components/schemas/A"), "a document with no raw tree declares no resource anywhere") } @@ -42,7 +42,7 @@ func TestDeclaresResourceIDAbove_WithoutARawTree(t *testing.T) { // $id both read false. func TestDeclaresResourceIDAbove_EmptySegmentIsATokenNotAnArtifact(t *testing.T) { t.Parallel() - l, diags := loweredFor(t, `openapi: 3.1.0 + l, diags := LoweredFor(t, `openapi: 3.1.0 info: {title: T, version: "1"} paths: {} "": {$id: https://example.com/root-member} @@ -84,7 +84,7 @@ components: } { t.Run(name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, declaresResourceIDAbove(l.ctx, tc.pointer), tc.why) + assert.Equal(t, tc.want, declaresResourceIDAbove(l.Ctx, tc.pointer), tc.why) }) } } @@ -100,7 +100,7 @@ components: // right answer (#202). func TestComponentSchemaAt_OnlyATopLevelComponentPointerHasABody(t *testing.T) { t.Parallel() - l, diags := loweredFor(t, openapitest.ComponentSpec( + l, diags := LoweredFor(t, openapitest.ComponentSpec( " Outer:\n type: object\n title: outer\n"+ " properties: {inner: {type: string, title: inner}}\n"+ " \"\": {type: string, title: empty}\n")) @@ -122,7 +122,7 @@ func TestComponentSchemaAt_OnlyATopLevelComponentPointerHasABody(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := componentSchemaAt(l.ctx, tc.pointer) + got := componentSchemaAt(l.Ctx, tc.pointer) if tc.wantTitle == "" { assert.Nil(t, got, "only a top-level component schema has a body here") return @@ -183,15 +183,15 @@ func TestDynamicHop_HopsOnlyWhenExactlyOneAnchorSiteIsNamed(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - l, diags := loweredFor(t, openapitest.ComponentSpec(tc.schemas)) + l, diags := LoweredFor(t, openapitest.ComponentSpec(tc.schemas)) openapitest.RequireNoErrorDiags(t, diags) if tc.anchor != "" { - sites, siteDiags := l.ctx.DynamicAnchorSites(tc.anchor) + sites, siteDiags := l.Ctx.DynamicAnchorSites(tc.anchor) require.Len(t, sites, tc.wantSites, "the fixture must set up the case it is named for") require.Empty(t, siteDiags) } - next, hops, hopDiags := dynamicHop(l.ctx, tc.at) + next, hops, hopDiags := dynamicHop(l.Ctx, tc.at) assert.Equal(t, tc.wantNext != "", hops) assert.Equal(t, tc.wantNext, next) diff --git a/compilers/openapi/internal/schema/enum_test.go b/compilers/openapi/internal/schema/enum_test.go index a15e9646..23827c45 100644 --- a/compilers/openapi/internal/schema/enum_test.go +++ b/compilers/openapi/internal/schema/enum_test.go @@ -11,6 +11,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -20,7 +21,7 @@ func TestEnum_StringClosed(t *testing.T) { type: string enum: [a, b] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) e, ok := doc.Types[componentID("E")].(*ir.Enum) require.True(t, ok, "E should be an enum") @@ -110,7 +111,7 @@ func TestEnum_NullMemberNormalizesToNullable(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, enumPropertySpec(tc.version, tc.schema)) + doc, diags := schema.LowerSpec(t, enumPropertySpec(tc.version, tc.schema)) openapitest.RequireNoErrorDiags(t, diags) assert.False(t, openapitest.HasDiag(diags, diag.DegradedConstruct), "a normalized nullable enum is not a degradation; got %+v", diags) @@ -204,7 +205,7 @@ func TestEnum_NullMemberKeepsUnionFallback(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, enumPropertySpec(tc.version, tc.schema)) + doc, diags := schema.LowerSpec(t, enumPropertySpec(tc.version, tc.schema)) openapitest.RequireNoErrorDiags(t, diags) assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "the degraded-enum diagnostic still fires; got %+v", diags) @@ -298,7 +299,7 @@ func TestEnum_EmptyMemberListMatchesNoValue(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(tc.schema+"\n")) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(tc.schema+"\n")) openapitest.RequireNoErrorDiags(t, diags) e, ok := typeByName(doc, "S").(*ir.Enum) @@ -339,7 +340,7 @@ func TestEnum_EmptyMemberListIsAUnionSibling(t *testing.T) { enum: [] oneOf: [{type: string}, {type: integer}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) e, ok := typeByName(doc, "S").(*ir.Enum) @@ -357,7 +358,7 @@ func TestConst_BecomesLiteral(t *testing.T) { spec := openapitest.ComponentSpec(` K: const: "fixed" `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) k, ok := doc.Types[componentID("K")].(*ir.Literal) require.True(t, ok, "K should be a literal") @@ -370,7 +371,7 @@ func TestHoistLiteral_UnconvertibleConstBecomesAny(t *testing.T) { // it), forcing hoistLiteral's fallback. Before the fix this silently // produced a Literal asserting the value is null, which the spec never said. spec := openapitest.ComponentSpec(" K:\n const: !foo bar\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) k, ok := doc.Types[componentID("K")].(*ir.Any) require.True(t, ok, "an unconvertible const hoists the schemaless top type at its own pointer") assert.Equal(t, ir.KindAny, k.Kind()) @@ -390,7 +391,7 @@ func TestEnumAsUnion_UnconvertibleMemberBecomesAny(t *testing.T) { // The convertible member ("ok") must still hoist a real Literal; only the // genuinely unconvertible member ("!foo bar") falls back to the top type. spec := openapitest.ComponentSpec(" M:\n enum: [ok, !foo bar]\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) u, ok := doc.Types[componentID("M")].(*ir.Union) require.True(t, ok, "heterogeneous enum still lowers to a union of literals") require.Len(t, u.Variants, 2) @@ -427,7 +428,7 @@ func TestEnum_UnquotedDatesStayClosedEnum(t *testing.T) { default: 2021-01-01 enum: [2021-01-01, 2022-02-02] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.Len(t, diags, 2, "the dates convert cleanly: neither diagnostic is about a member") assert.Equal(t, []jsontext.Pointer{"/components/schemas/D", "/components/schemas/D/default"}, []jsontext.Pointer{diags[0].Provenance.Pointer, diags[1].Provenance.Pointer}) @@ -450,7 +451,7 @@ func TestEnum_ValueTypeVariants(t *testing.T) { ENoTypeStr: {enum: [a, b]} EBytes: {enum: [!!binary aGk=, !!binary Ynll]} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) want := map[string]ir.PrimKind{ "EInt": ir.PrimInteger, "ENum": ir.PrimNumber, "EBool": ir.PrimBool, @@ -472,7 +473,7 @@ func TestEnum_ValueTypeVariants(t *testing.T) { func TestEnum_ByteMembersAreNamedAndTyped(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" EBytes: {enum: [!!binary aGk=, !!binary Ynll]}\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) e, ok := typeByName(doc, "EBytes").(*ir.Enum) @@ -489,7 +490,7 @@ func TestEnum_ByteMembersAreNamedAndTyped(t *testing.T) { func TestEnum_HeterogeneousBecomesUnionWithBadValue(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(" Mixed:\n enum: [active, .inf]\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) u, ok := typeByName(doc, "Mixed").(*ir.Union) require.True(t, ok, "heterogeneous enum lowers to a union of literals") assert.Len(t, u.Variants, 2) @@ -508,7 +509,7 @@ func TestEnum_NonScalarAndMidListMismatch(t *testing.T) { MidMismatch: enum: [alpha, beta, 3] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) _, objIsUnion := typeByName(doc, "ObjEnum").(*ir.Union) assert.True(t, objIsUnion, "object-valued enum degrades to a union") @@ -519,7 +520,7 @@ func TestEnum_NonScalarAndMidListMismatch(t *testing.T) { func TestEnumMemberIDs_SharedCanonicalNameStillDistinct(t *testing.T) { t.Parallel() - doc, _ := lowerSpec(t, openapitest.ComponentSpec(` E: + doc, _ := schema.LowerSpec(t, openapitest.ComponentSpec(` E: type: string enum: [a-b, a_b, "A B"] `)) @@ -532,7 +533,7 @@ func TestEnumMemberIDs_SharedCanonicalNameStillDistinct(t *testing.T) { func TestEnumMemberIDs_SignedNumbersAreThreeIDs(t *testing.T) { t.Parallel() - doc, _ := lowerSpec(t, openapitest.ComponentSpec(` N: + doc, _ := schema.LowerSpec(t, openapitest.ComponentSpec(` N: type: integer enum: [-1, 1, 0] `)) @@ -544,7 +545,7 @@ func TestEnumMemberIDs_SignedNumbersAreThreeIDs(t *testing.T) { // each keeping the spelling it was written with. func TestEnumMemberIDs_OneNumberSpelledTwiceIsARepeat(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` N: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` N: type: number enum: [10, 10.0, 1e1, 100] `)) @@ -560,7 +561,7 @@ func TestEnumMemberIDs_OneNumberSpelledTwiceIsARepeat(t *testing.T) { func TestEnumMemberIDs_NamesThatCanonicalizeToNothingStillGetIDs(t *testing.T) { t.Parallel() - doc, _ := lowerSpec(t, openapitest.ComponentSpec(` E: + doc, _ := schema.LowerSpec(t, openapitest.ComponentSpec(` E: type: string enum: ["", "-", "!"] `)) @@ -574,7 +575,7 @@ func TestEnumMemberIDs_NamesThatCanonicalizeToNothingStillGetIDs(t *testing.T) { func TestEnumMemberIDs_StringAndNumberOfEqualTextDiffer(t *testing.T) { t.Parallel() - doc, _ := lowerSpec(t, openapitest.ComponentSpec(` S: + doc, _ := schema.LowerSpec(t, openapitest.ComponentSpec(` S: type: string enum: ["1"] N: @@ -597,7 +598,7 @@ func TestEnumMemberIDs_StringAndNumberOfEqualTextDiffer(t *testing.T) { func TestEnumMemberIDs_ReorderKeepsTheIDSet(t *testing.T) { t.Parallel() byValue := func(order string) map[string]ir.EnumMemberID { - doc, _ := lowerSpec(t, openapitest.ComponentSpec(` E: + doc, _ := schema.LowerSpec(t, openapitest.ComponentSpec(` E: type: string enum: `+order+"\n")) out := map[string]ir.EnumMemberID{} @@ -611,7 +612,7 @@ func TestEnumMemberIDs_ReorderKeepsTheIDSet(t *testing.T) { func TestEnumMemberIDs_RepeatedValueIsKeptSuffixedAndWarned(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` E: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` E: type: string enum: [a, b, a, a] `)) @@ -634,7 +635,7 @@ func TestEnumMemberIDs_RepeatedValueIsKeptSuffixedAndWarned(t *testing.T) { // it names, are positions in the enum as written, not in the member list. func TestEnumMemberIDs_RepeatIsReportedAtItsSourceIndex(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` E: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` E: type: [string, "null"] enum: [null, a, b, a] `)) @@ -650,7 +651,7 @@ func TestEnumMemberIDs_RepeatIsReportedAtItsSourceIndex(t *testing.T) { // by its elements' values, an object regardless of member order. func TestEnumRepeats_AreReportedWhateverTheSetLowersTo(t *testing.T) { t.Parallel() - _, diags := lowerSpec(t, openapitest.ComponentSpec(` Mixed: {enum: [a, 1, a, 1.0]} + _, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` Mixed: {enum: [a, 1, a, 1.0]} Nullable: {type: [string, "null"], enum: [a, null, null]} Nulls: {enum: [null, null]} Lists: {enum: [[1, 2], [2, 1], [1, 2.0]]} @@ -674,7 +675,7 @@ func TestEnumRepeats_AreReportedWhateverTheSetLowersTo(t *testing.T) { func TestEnumMemberIDs_AreScopedToTheirEnum(t *testing.T) { t.Parallel() - doc, _ := lowerSpec(t, openapitest.ComponentSpec(` A: + doc, _ := schema.LowerSpec(t, openapitest.ComponentSpec(` A: type: string enum: [x] B: @@ -695,11 +696,11 @@ func TestEnumMemberIDs_AreScopedToTheirEnum(t *testing.T) { // enumPropertySpec puts a schema at one property of a model S, so a test can // read both the node the schema lowers to and the reference the position holds. -func enumPropertySpec(version, schema string) string { +func enumPropertySpec(version, body string) string { return openapitest.ComponentSpecVer(version, ` S: type: object properties: - p: `+schema+"\n") + p: `+body+"\n") } // enumOf returns the members of the enum at id. diff --git a/compilers/openapi/internal/schema/export_test.go b/compilers/openapi/internal/schema/export_test.go new file mode 100644 index 00000000..a6db52a9 --- /dev/null +++ b/compilers/openapi/internal/schema/export_test.go @@ -0,0 +1,81 @@ +package schema + +import ( + "testing" + + soa "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/compile" + "github.com/dexpace/morphic/compilers/openapi/internal/load" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/overlay" + "github.com/dexpace/morphic/ir" +) + +// Lowerer is test scaffolding, not a compiler type. The compiler has no such +// struct: every lowering is a function of the context and the registry, and +// the caller owns the document being built (#177). It is declared here, once, +// so the package's in-package and external tests drive one fixture. +type Lowerer struct { + Ctx lowering.Ctx + Out *ir.Document + Types *compile.Types + Diags compile.Diags +} + +// LowererOver is the only place the fixture's fields are initialised. Both +// entry points below build on it, so a field added to Lowerer cannot reach one +// of them and miss the other. +func LowererOver(ctx lowering.Ctx) *Lowerer { + types := compile.NewTypes() + return &Lowerer{ + Ctx: ctx, + Out: &ir.Document{}, + Types: types, + } +} + +// LoweredFor loads src and returns the fixture over it with nothing lowered yet, +// plus the load diagnostics, so a test can drive one entry point at a time. +func LoweredFor(t *testing.T, src string) (*Lowerer, []ir.Diagnostic) { + t.Helper() + loadedDoc, diags, err := load.Load(t.Context(), 0, openapitest.SourceOf(src), load.Options{}) + require.NoError(t, err) + require.NotNil(t, loadedDoc, "load returned no document: %+v", diags) + return LowererOver(lowering.New(0, loadedDoc.Doc, loadedDoc.Source, + lowering.GroupByTags, lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}). + WithLoaded(loadedDoc)), diags +} + +// LowerSpec loads src and lowers its component schemas, returning the document +// under construction and all diagnostics. +func LowerSpec(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { + t.Helper() + l, diags := LoweredFor(t, src) + l.Diags.AppendAll(LowerComponentSchemas(t.Context(), l.Ctx, l.Types)) + doc := l.Document(t) + return doc, append(diags, l.Diags.List()...) +} + +// NewRawLowerer builds a fixture over a hand-constructed document, bypassing the +// parser so nil slice/map entries (which the parser panics on) can be exercised. +func NewRawLowerer(doc *soa.OpenAPI) *Lowerer { + return LowererOver(lowering.New(0, doc, ir.SourceInfo{}, "", lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{})) +} + +// Document finishes the registry as run does once every lowering has +// returned, and returns the document under construction holding it. What the +// settling reports joins the fixture's diagnostics; a refusal is a compiler bug +// no fixture provokes, so one fails the test. +func (l *Lowerer) Document(t *testing.T) *ir.Document { + t.Helper() + reg, settled := l.Types.Finish(t.Context()) + for _, d := range settled { + require.NotEqual(t, compile.RegistryRefused, d.Code, "the registry refused an entry: %s", d.Message) + } + l.Diags.AppendAll(settled) + l.Out.Types = reg + return l.Out +} diff --git a/compilers/openapi/internal/schema/helpers_internal_test.go b/compilers/openapi/internal/schema/helpers_internal_test.go index f40ccdf5..5eb5d46b 100644 --- a/compilers/openapi/internal/schema/helpers_internal_test.go +++ b/compilers/openapi/internal/schema/helpers_internal_test.go @@ -3,17 +3,12 @@ package schema import ( "testing" - soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" - "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/compilers/openapi/internal/load" - "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/compilers/openapi/internal/overlay" "github.com/dexpace/morphic/ir" ) @@ -37,56 +32,6 @@ func useValue(t *testing.T, src string) *yaml.Node { return node } -// lowerer is test scaffolding, not a compiler type. The compiler has no such -// struct: every lowering is a function of the context and the registry, and -// the caller owns the document being built (#177). -type lowerer struct { - ctx lowering.Ctx - out *ir.Document - types *compile.Types - diags compile.Diags -} - -// lowererOver is the only place the fixture's fields are initialised. Both -// entry points below build on it, so a field added to lowerer cannot reach one -// of them and miss the other. -func lowererOver(ctx lowering.Ctx) *lowerer { - types := compile.NewTypes() - return &lowerer{ - ctx: ctx, - out: &ir.Document{}, - types: types, - } -} - -// loweredFor loads src and returns the fixture over it with nothing lowered yet, -// plus the load diagnostics, so a test can drive one entry point at a time. -func loweredFor(t *testing.T, src string) (*lowerer, []ir.Diagnostic) { - t.Helper() - loadedDoc, diags, err := load.Load(t.Context(), 0, openapitest.SourceOf(src), load.Options{}) - require.NoError(t, err) - require.NotNil(t, loadedDoc, "load returned no document: %+v", diags) - return lowererOver(lowering.New(0, loadedDoc.Doc, loadedDoc.Source, - lowering.GroupByTags, lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}). - WithLoaded(loadedDoc)), diags -} - -// lowerSpec loads src and lowers its component schemas, returning the document -// under construction and all diagnostics. -func lowerSpec(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { - t.Helper() - l, diags := loweredFor(t, src) - l.diags.AppendAll(LowerComponentSchemas(t.Context(), l.ctx, l.types)) - doc := l.document(t) - return doc, append(diags, l.diags.List()...) -} - -// newRawLowerer builds a fixture over a hand-constructed document, bypassing the -// parser so nil slice/map entries (which the parser panics on) can be exercised. -func newRawLowerer(doc *soa.OpenAPI) *lowerer { - return lowererOver(lowering.New(0, doc, ir.SourceInfo{}, "", lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{})) -} - // assertInternalInvariant requires diags to report a broken internal invariant. // It is the only diagnostic this half of the suite asserts by code, and that is // not a coincidence: these tests drive the states no source document can reach, @@ -100,18 +45,3 @@ func assertInternalInvariant(t *testing.T, diags []ir.Diagnostic) { } t.Fatalf("expected an internal-invariant error, got %+v", diags) } - -// document finishes the registry as run does once every lowering has -// returned, and returns the document under construction holding it. What the -// settling reports joins the fixture's diagnostics; a refusal is a compiler bug -// no fixture provokes, so one fails the test. -func (l *lowerer) document(t *testing.T) *ir.Document { - t.Helper() - reg, settled := l.types.Finish(t.Context()) - for _, d := range settled { - require.NotEqual(t, compile.RegistryRefused, d.Code, "the registry refused an entry: %s", d.Message) - } - l.diags.AppendAll(settled) - l.out.Types = reg - return l.out -} diff --git a/compilers/openapi/internal/schema/helpers_test.go b/compilers/openapi/internal/schema/helpers_test.go index 23876997..790db859 100644 --- a/compilers/openapi/internal/schema/helpers_test.go +++ b/compilers/openapi/internal/schema/helpers_test.go @@ -12,18 +12,13 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/compilers/openapi/internal/load" - "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/compilers/openapi/internal/overlay" "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/engine/reader" "github.com/dexpace/morphic/ir" @@ -51,70 +46,19 @@ func parseFull(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { return doc, diags } -// lowerer is test scaffolding, not a compiler type. The compiler has no such -// struct: every lowering is a function of the context and the registry, and -// the caller owns the document being built (#177). Tests that drive one -// lowering in isolation still want those in one place. -type lowerer struct { - ctx lowering.Ctx - out *ir.Document - types *compile.Types - diags compile.Diags -} - -// lowererOver is the only place the fixture's fields are initialised. Both -// entry points below build on it, so a field added to lowerer cannot reach one -// of them and miss the other. -func lowererOver(ctx lowering.Ctx) *lowerer { - types := compile.NewTypes() - return &lowerer{ - ctx: ctx, - out: &ir.Document{}, - types: types, - } -} - -// loweredFor loads src and returns the fixture over it with nothing lowered yet, -// plus the load diagnostics, so a test can drive one entry point at a time. -func loweredFor(t *testing.T, src string) (*lowerer, []ir.Diagnostic) { - t.Helper() - loadedDoc, diags, err := load.Load(t.Context(), 0, openapitest.SourceOf(src), load.Options{}) - require.NoError(t, err) - require.NotNil(t, loadedDoc, "load returned no document: %+v", diags) - return lowererOver(lowering.New(0, loadedDoc.Doc, loadedDoc.Source, - lowering.GroupByTags, lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}). - WithLoaded(loadedDoc)), diags -} - -// lowerSpec loads src and lowers its component schemas, returning the document -// under construction and all diagnostics. -func lowerSpec(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { - t.Helper() - l, diags := loweredFor(t, src) - l.diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.ctx, l.types)) - doc := l.document(t) - return doc, append(diags, l.diags.List()...) -} - -// lowerSpecOnly is lowerSpec without the load phase's diagnostics mixed in. A -// $ref the lowering cannot follow is reported both by the load phase, which -// binds it, and by the lowering itself, at the same pointer -// (compilers/openapi's withoutRereported drops the second once both reach the -// compiler). A test that wants to pin what the -// lowering itself reports — not what a full compile does with the two reports -// after deduplication — needs the lowering's diagnostics on their own. +// lowerSpecOnly is schema.LowerSpec without the load phase's diagnostics +// mixed in. A $ref the lowering cannot follow is reported both by the load +// phase, which binds it, and by the lowering itself, at the same pointer +// (compilers/openapi's withoutRereported drops the second once both reach +// the compiler). A test that wants to pin what the lowering itself reports — +// not what a full compile does with the two reports after deduplication — +// needs the lowering's diagnostics on their own. func lowerSpecOnly(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { t.Helper() - l, _ := loweredFor(t, src) - l.diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.ctx, l.types)) - doc := l.document(t) - return doc, l.diags.List() -} - -// newRawLowerer builds a fixture over a hand-constructed document, bypassing the -// parser so nil slice/map entries (which the parser panics on) can be exercised. -func newRawLowerer(doc *soa.OpenAPI) *lowerer { - return lowererOver(lowering.New(0, doc, ir.SourceInfo{}, "", lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{})) + l, _ := schema.LoweredFor(t, src) + l.Diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types)) + doc := l.Document(t) + return doc, l.Diags.List() } // componentID is the stable TypeID of a components-named schema, or of a @@ -141,26 +85,11 @@ func conflictDiags(diags []ir.Diagnostic) []ir.Diagnostic { return out } -// document finishes the registry as run does once every lowering has -// returned, and returns the document under construction holding it. What the -// settling reports joins the fixture's diagnostics; a refusal is a compiler bug -// no fixture provokes, so one fails the test. -func (l *lowerer) document(t *testing.T) *ir.Document { - t.Helper() - reg, settled := l.types.Finish(t.Context()) - for _, d := range settled { - require.NotEqual(t, compile.RegistryRefused, d.Code, "the registry refused an entry: %s", d.Message) - } - l.diags.AppendAll(settled) - l.out.Types = reg - return l.out -} - // settledSpec is nonBareSpec with the components after the schema composing // them, so a redeclaration naming one is judged once the registry is whole. func settledSpec(t *testing.T, extra, schemaA, schemaB string) (*ir.Document, []ir.Diagnostic) { t.Helper() - return lowerSpec(t, openapitest.ComponentSpec( + return schema.LowerSpec(t, openapitest.ComponentSpec( " T:\n allOf:\n"+ " - type: object\n properties: {v: "+schemaA+"}\n"+ " - type: object\n properties: {v: "+schemaB+"}\n"+extra)) diff --git a/compilers/openapi/internal/schema/hoist_internal_test.go b/compilers/openapi/internal/schema/hoist_internal_test.go index ebc9980d..32c3ed8c 100644 --- a/compilers/openapi/internal/schema/hoist_internal_test.go +++ b/compilers/openapi/internal/schema/hoist_internal_test.go @@ -154,7 +154,7 @@ func TestUnionVariant_StopsAtTheHopCap(t *testing.T) { spec.WriteString(" S" + strconv.Itoa(i) + ": {oneOf: [{$ref: '" + next + "'}, {type: integer}]}\n") } spec.WriteString(" T: {type: object, properties: {t: {type: string}}}\n") - doc, diags := lowerSpec(t, openapitest.ComponentSpec(spec.String())) + doc, diags := LowerSpec(t, openapitest.ComponentSpec(spec.String())) openapitest.RequireNoErrorDiags(t, diags) choice, ok := doc.Types[ids.Document{}.ForPointer(ids.Ptr("components", "schemas", "Choice"))].(*ir.Union) diff --git a/compilers/openapi/internal/schema/hoist_test.go b/compilers/openapi/internal/schema/hoist_test.go index 078250b8..055c7861 100644 --- a/compilers/openapi/internal/schema/hoist_test.go +++ b/compilers/openapi/internal/schema/hoist_test.go @@ -9,6 +9,7 @@ import ( "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -61,7 +62,7 @@ func TestEscapedComponentName_DecodesInDiscriminatorValueAndHints(t *testing.T) UsesInner: $ref: '#/components/schemas/Host/$defs/inner' `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) catDog, ok := doc.Types[componentID("Cat~1Dog")].(*ir.Model) @@ -131,7 +132,7 @@ func TestLower_InlineSchemaHoistedOnce(t *testing.T) { properties: name: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) itemsID := ir.TypeID("t/anon/components/schemas/S/properties/tags/items") item, ok := doc.Types[itemsID].(*ir.Model) @@ -423,7 +424,7 @@ func TestAlias_CarriesTheBoundsWrittenBesideIt(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" A: "+tc.schema+"\n")) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" A: "+tc.schema+"\n")) openapitest.RequireNoErrorDiags(t, diags) sc, ok := typeByName(doc, "A").(*ir.Scalar) require.True(t, ok, "the position hoists an alias of its own") diff --git a/compilers/openapi/internal/schema/model_test.go b/compilers/openapi/internal/schema/model_test.go index 3bb29a6f..2cd556b0 100644 --- a/compilers/openapi/internal/schema/model_test.go +++ b/compilers/openapi/internal/schema/model_test.go @@ -10,6 +10,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -26,7 +27,7 @@ func TestModel_PlainRequiredUndeclaredPropertyLandsInRequires(t *testing.T) { properties: id: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) m, ok := doc.Types[componentID("Plain")].(*ir.Model) require.True(t, ok, "Plain should be a model") require.Len(t, m.Properties, 1) @@ -49,7 +50,7 @@ func TestModel_FourOptionalityStates(t *testing.T) { optPlain: {type: string} optNull: {type: [string, "null"]} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) @@ -72,7 +73,7 @@ func TestModel_AdditionalPropertiesFalseClosed(t *testing.T) { properties: {a: {type: string}} additionalProperties: false `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) assert.Equal(t, ir.AdditionalClosed, m.Additional) @@ -104,7 +105,7 @@ func TestObject_MapAndNothingElseLowersToMapT(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" S: "+tc.schema+"\n")) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" S: "+tc.schema+"\n")) assert.Empty(t, diags, "a map holds everything the position declared") mt, ok := doc.Types[componentID("S")].(*ir.MapT) require.True(t, ok, "a map and nothing else is a MapT; got %T", doc.Types[componentID("S")]) @@ -120,7 +121,7 @@ func TestObject_MapAndNothingElseLowersToMapT(t *testing.T) { // ride on TypeCommon unchanged. func TestObject_MapKeepsItsDeclaration(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` S: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: type: object description: Labels by key. deprecated: true @@ -147,7 +148,7 @@ func TestObject_ACatchAllBesideARequiredNameStaysAModel(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(spec)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(spec)) m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok, "got %T", doc.Types[componentID("S")]) assert.Equal(t, []string{"a"}, m.Requires) @@ -188,7 +189,7 @@ func TestObject_CatchAllBesideAnotherFactStaysModel(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(base+" S: "+tc.schema+"\n")) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(base+" S: "+tc.schema+"\n")) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok, "got %T", doc.Types[componentID("S")]) @@ -205,7 +206,7 @@ func TestModel_PatternPropertiesOrder(t *testing.T) { "^x-": {type: string} "^y-": {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) require.NotNil(t, m.AdditionalProps) @@ -221,7 +222,7 @@ func TestModel_UnevaluatedPropertiesClosedAfterComposition(t *testing.T) { properties: {a: {type: string}} unevaluatedProperties: false `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) assert.Equal(t, ir.AdditionalClosedAfterComposition, m.Additional) diff --git a/compilers/openapi/internal/schema/nulls_internal_test.go b/compilers/openapi/internal/schema/nulls_internal_test.go index cd2718b1..52c231e5 100644 --- a/compilers/openapi/internal/schema/nulls_internal_test.go +++ b/compilers/openapi/internal/schema/nulls_internal_test.go @@ -26,7 +26,7 @@ func TestRefNullRef_AnUnresolvedRefIsNotNullable(t *testing.T) { require.NoError(t, err) require.Empty(t, valErrs, "the fixture parses cleanly") - ref, diags := refNullRef(newRawLowerer(&soa.OpenAPI{}).ctx, "t/x", js, siteAt("/x")) + ref, diags := refNullRef(NewRawLowerer(&soa.OpenAPI{}).Ctx, "t/x", js, siteAt("/x")) assert.Equal(t, ir.TypeRef{Target: "t/x"}, ref) assert.Empty(t, diags) } diff --git a/compilers/openapi/internal/schema/nulls_test.go b/compilers/openapi/internal/schema/nulls_test.go index 1c90d7e7..3ba8eb6e 100644 --- a/compilers/openapi/internal/schema/nulls_test.go +++ b/compilers/openapi/internal/schema/nulls_test.go @@ -15,6 +15,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -299,7 +300,7 @@ func TestAllOf_NullabilityFollowsTheConjuncts(t *testing.T) { viaAllOfPlain: {$ref: '#/components/schemas/WrapPlain'} viaAllOfBoth: {$ref: '#/components/schemas/WrapBoth'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) holder, ok := typeByName(doc, "Holder").(*ir.Model) require.True(t, ok, "Holder is a model") @@ -341,7 +342,7 @@ func TestConst_FixesWhetherTheValueSpaceHoldsNull(t *testing.T) { justNull: {const: null} narrowed: {type: [string, "null"], const: "a"} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "S").(*ir.Model) require.True(t, ok, "S is a model") @@ -380,7 +381,7 @@ func TestSchemaRef_NullableNormalization(t *testing.T) { " type: object\n" + " properties:\n" + " p: " + tc.schema + "\n" - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) model, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) @@ -399,7 +400,7 @@ func TestSchema_Ref30NullableSiblings(t *testing.T) { p: {$ref: '#/components/schemas/Target', nullable: true} Target: {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) _ = diags m := typeByName(doc, "Owner").(*ir.Model) @@ -727,7 +728,7 @@ func TestSchema_RefNullableAcrossSpellings(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpecVer(tc.version, tc.schemas) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "Owner").(*ir.Model) require.Len(t, m.Properties, 1) @@ -760,7 +761,7 @@ func TestSchema_NullabilityAgreesAcrossEnumSpellings(t *testing.T) { admittedByType: {type: [string, "null"], enum: [red, green, null]} admittedByUnion: {enum: [red, green, null], oneOf: [{type: string}, {type: "null"}]} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := typeByName(doc, "S").(*ir.Model) require.True(t, ok, "S is a model") @@ -807,7 +808,7 @@ oneOf: [{type: string}, {type: "null"}]` viaRef: {$ref: '#/components/schemas/Target'} inline: ` + indent(10)) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "Owner").(*ir.Model) props := openapitest.PropsByWire(m.Properties) @@ -832,7 +833,7 @@ func TestSchema_RefNullableAtNonPropertyPosition(t *testing.T) { Target: oneOf: [{type: string}, {type: integer}, {type: "null"}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) list, ok := doc.Types["t/anon/components/schemas/Owner/properties/p"].(*ir.List) require.True(t, ok) diff --git a/compilers/openapi/internal/schema/property_test.go b/compilers/openapi/internal/schema/property_test.go index 7c29c627..f2578b9f 100644 --- a/compilers/openapi/internal/schema/property_test.go +++ b/compilers/openapi/internal/schema/property_test.go @@ -28,7 +28,7 @@ func TestProperty_UnquotedDateDefaultPreserved(t *testing.T) { format: date default: 2021-01-01 `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) assert.Empty(t, diags, "no diagnostics at all: the date default converts cleanly") m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) @@ -50,7 +50,7 @@ func TestLower_PropertyDetailRichSchema(t *testing.T) { withExt: {type: string, x-foo: bar} badDefault: {type: number, default: .inf} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) m := typeByName(doc, "D").(*ir.Model) assert.NotEmpty(t, m.Docs.ExternalDocs) byWire := openapitest.PropsByWire(m.Properties) @@ -80,7 +80,7 @@ func TestLower_RefTargetDescriptionFallback(t *testing.T) { type: string description: target-desc `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "Owner").(*ir.Model) assert.Equal(t, "target-desc", m.Properties[0].Docs.Description) @@ -93,7 +93,7 @@ func TestModel_DefaultBigLiteral(t *testing.T) { properties: n: {type: integer, default: 9007199254740993} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) require.NotNil(t, m.Properties[0].Default) @@ -107,7 +107,7 @@ func TestFillPropertyDetail_UnconvertibleExampleDiagnosed(t *testing.T) { // (an example is an annotation, not a structural hole) but never silently — // the conversion error was previously discarded on the floor. spec := openapitest.ComponentSpec(" S:\n type: object\n properties:\n n:\n type: string\n example: !foo bar\n") - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) assert.Empty(t, m.Properties[0].Examples, "the unconvertible example is skipped, not appended") @@ -126,7 +126,7 @@ func TestModel_ReadOnlyWriteOnlyVisibility(t *testing.T) { r: {type: string, readOnly: true} w: {type: string, writeOnly: true} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) byName := openapitest.PropsByWire(m.Properties) @@ -147,7 +147,7 @@ func TestModel_ReadOnlyAndWriteOnlyTogetherAreVisibleNowhere(t *testing.T) { both: {type: string, readOnly: true, writeOnly: true} r: {type: string, readOnly: true} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) byName := openapitest.PropsByWire(m.Properties) @@ -169,7 +169,7 @@ func TestModel_PasswordFormatSecret(t *testing.T) { properties: pw: {type: string, format: password} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) assert.True(t, m.Properties[0].Secret) @@ -182,7 +182,7 @@ func TestModel_PropertyDeprecation(t *testing.T) { properties: old: {type: string, deprecated: true} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) assert.NotNil(t, m.Properties[0].Deprecation) @@ -195,7 +195,7 @@ func TestModel_PropertyXML(t *testing.T) { properties: p: {type: string, xml: {name: n, attribute: true}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) require.NotNil(t, m.Properties[0].XML) @@ -213,7 +213,7 @@ func TestModel_RefSiblingDescriptionWins(t *testing.T) { $ref: '#/components/schemas/Target' description: "sibling desc" `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := doc.Types[componentID("S")].(*ir.Model) assert.Equal(t, "sibling desc", m.Properties[0].Docs.Description) @@ -227,7 +227,7 @@ func TestSchema_RefTargetReadOnlyVisibility(t *testing.T) { p: {$ref: '#/components/schemas/RO'} RO: {type: string, readOnly: true} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "Owner").(*ir.Model) assert.NotEmpty(t, m.Properties[0].Visibility.Only, "readOnly from the ref target applies") @@ -448,7 +448,7 @@ type carrierDocKeyword struct { // sub-schema pointer rather than a named component. func TestFillPropertyAnnotations_RefSiblingExampleBindsTheSite(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, `openapi: 3.1.0 + l, _ := schema.LoweredFor(t, `openapi: 3.1.0 info: {title: t, version: "1"} paths: {} components: @@ -461,14 +461,14 @@ components: $ref: '#/components/schemas/Target' example: at-reference `) - l.diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.ctx, l.types)) + l.Diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types)) - target := typeByName(l.document(t), "Target") + target := typeByName(l.Document(t), "Target") require.NotNil(t, target) assert.Empty(t, target.Common().Examples, "an example beside a $ref must not attach to the referent") - holder, ok := typeByName(l.document(t), "Holder").(*ir.Model) + holder, ok := typeByName(l.Document(t), "Holder").(*ir.Model) require.True(t, ok, "Holder must own a Model node") f, ok := openapitest.PropsByWire(holder.Properties)["f"] require.True(t, ok, "property f must be present") diff --git a/compilers/openapi/internal/schema/refsite_internal_test.go b/compilers/openapi/internal/schema/refsite_internal_test.go index c386b589..977e5d10 100644 --- a/compilers/openapi/internal/schema/refsite_internal_test.go +++ b/compilers/openapi/internal/schema/refsite_internal_test.go @@ -27,15 +27,15 @@ import ( // a reference object resolved to. func TestRefTypeRef_SaysWhyAnUnboundRefIsUnresolved(t *testing.T) { t.Parallel() - message := func(t *testing.T, l *lowerer, js *oas3.JSONSchema[oas3.Referenceable]) string { + message := func(t *testing.T, l *Lowerer, js *oas3.JSONSchema[oas3.Referenceable]) string { t.Helper() - ref, diags := refTypeRef(l.ctx, l.types, js, "/components/schemas/A") + ref, diags := refTypeRef(l.Ctx, l.Types, js, "/components/schemas/A") assert.Equal(t, ir.TypeID("t/prim/any"), ref.Target) require.Len(t, diags, 1) return diags[0].Message } - bare := newRawLowerer(&soa.OpenAPI{}) + bare := NewRawLowerer(&soa.OpenAPI{}) assert.Equal(t, `unresolved $ref "#/x"`, message(t, bare, oas3.NewJSONSchemaFromReference("#/x"))) var deep strings.Builder @@ -44,22 +44,22 @@ func TestRefTypeRef_SaysWhyAnUnboundRefIsUnresolved(t *testing.T) { fmt.Fprintf(&deep, "x-c%d: &c%d {<<: *c%d}\n", i, i, i-1) } deep.WriteString("components:\n schemas:\n T: {type: string}\n A: {<<: *c70}\n") - past, _ := loweredFor(t, deep.String()) - a, ok := past.ctx.Doc.Components.GetSchemas().Get("A") + past, _ := LoweredFor(t, deep.String()) + a, ok := past.Ctx.Doc.Components.GetSchemas().Get("A") require.True(t, ok) assert.Equal(t, `unresolved $ref "#/components/schemas/T": it is past a `+"`<<`"+` merge chain deeper than `+ "the 64 levels the compiler expands", message(t, past, a)) unheld := unheldLowerer(t) - a, ok = unheld.ctx.Doc.Components.GetSchemas().Get("A") + a, ok = unheld.Ctx.Doc.Components.GetSchemas().Get("A") require.True(t, ok) assert.Equal(t, `unresolved $ref "#/components/schemas/H/properties/p"`, message(t, unheld, a)) - bd := typed.NewBuilder(bind.Bind(unheld.ctx.Doc.GetRootNode(), bind.Config{Self: "spec.yaml"})) + bd := typed.NewBuilder(bind.Bind(unheld.Ctx.Doc.GetRootNode(), bind.Config{Self: "spec.yaml"})) bd.Unplaced(a, "/paths/~1a/get/parameters/0") - unplaced := lowererOver(lowering.New(0, unheld.ctx.Doc, ir.SourceInfo{}, "", lowering.Limits{}, + unplaced := LowererOver(lowering.New(0, unheld.Ctx.Doc, ir.SourceInfo{}, "", lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}).WithLoaded( - &load.Document{Doc: unheld.ctx.Doc, Bindings: bd.Bindings()})) + &load.Document{Doc: unheld.Ctx.Doc, Bindings: bd.Bindings()})) assert.Equal(t, `unresolved $ref "#/components/schemas/H/properties/p": it is in what the reference at `+ "/paths/~1a/get/parameters/0 resolved to, which the compiler cannot read where the parser placed it", message(t, unplaced, a)) @@ -71,56 +71,56 @@ func TestRefTypeRef_SaysWhyAnUnboundRefIsUnresolved(t *testing.T) { // cannot hoist resolves to nothing. func TestResolvePointer_PromisesWhatItNames(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) object := oas3.NewJSONSchemaFromSchema[oas3.Referenceable]( &oas3.Schema{Type: oas3.NewTypeFromString(oas3.SchemaTypeObject)}) - id, ok := resolvePointer(l.ctx, l.types, deepPointer, object) + id, ok := resolvePointer(l.Ctx, l.Types, deepPointer, object) require.True(t, ok) assert.Equal(t, ids.Document{}.AnonType(deepPointer), id) - _, built := l.types.Lookup(deepPointer) + _, built := l.Types.Lookup(deepPointer) assert.False(t, built, "the reference builds nothing itself") - _, finished := l.types.Finish(t.Context()) + _, finished := l.Types.Finish(t.Context()) assert.Empty(t, finished, "an object body hoists cleanly, and nothing is refused") - seeded, built := l.types.Lookup(deepPointer) + seeded, built := l.Types.Lookup(deepPointer) require.True(t, built, "Finish builds what no lowering interned") assert.Equal(t, id, seeded) - _, ok = resolvePointer(l.ctx, l.types, deepPointer, nil) + _, ok = resolvePointer(l.Ctx, l.Types, deepPointer, nil) assert.False(t, ok, "a target the bindings hold no schema at cannot be hoisted") } func TestResolveSchemaRef_UnboundDeepRefDropped(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) // A same-file $ref to a deep pointer no binding read: no interned node and // nothing the bindings know it lands on, so the reference is dropped. js := oas3.NewJSONSchemaFromReference("#" + deepPointer) - _, ok := resolveSchemaRef(l.ctx, l.types, js) + _, ok := resolveSchemaRef(l.Ctx, l.Types, js) assert.False(t, ok, "an unresolved deep sub-schema $ref is dropped, not synthesized") } func TestHoistSubSchema_BodyInternsAtPointer(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) // An object body interns a node at the sub-schema's own pointer, so the // pointer-owns-a-node branch keeps that node rather than aliasing it. object := oas3.NewJSONSchemaFromSchema[oas3.Referenceable]( &oas3.Schema{Type: oas3.NewTypeFromString(oas3.SchemaTypeObject)}) - assert.Empty(t, hoistSubSchema(l.ctx, l.types, object, deepPointer), "an object body hoists cleanly") - seeded, _ := l.types.Lookup(deepPointer) + assert.Empty(t, hoistSubSchema(l.Ctx, l.Types, object, deepPointer), "an object body hoists cleanly") + seeded, _ := l.Types.Lookup(deepPointer) assert.Equal(t, ids.Document{}.AnonType(deepPointer), seeded) - td, ok := l.types.NodeAt(deepPointer) + td, ok := l.Types.NodeAt(deepPointer) require.True(t, ok) assert.Equal(t, ir.KindModel, td.Kind(), "the body's own node, not an alias over it") } // unheldLowerer is a lowerer over bindings that know where A's $ref lands but // hold no schema there: only A was recorded. -func unheldLowerer(t *testing.T) *lowerer { +func unheldLowerer(t *testing.T) *Lowerer { t.Helper() const spec = "openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\ncomponents:\n schemas:\n" + " A: {$ref: '#/components/schemas/H/properties/p'}\n H: {properties: {p: {type: object}}}\n" @@ -132,5 +132,5 @@ func unheldLowerer(t *testing.T) *lowerer { bd.Schema(a, bind.Position{Pointer: "/components/schemas/A"}) ctx := lowering.New(0, doc, ir.SourceInfo{}, "", lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}).WithLoaded(&load.Document{Doc: doc, Bindings: bd.Bindings()}) - return lowererOver(ctx) + return LowererOver(ctx) } diff --git a/compilers/openapi/internal/schema/refsite_test.go b/compilers/openapi/internal/schema/refsite_test.go index ae4da6f2..9f6cbeca 100644 --- a/compilers/openapi/internal/schema/refsite_test.go +++ b/compilers/openapi/internal/schema/refsite_test.go @@ -191,10 +191,10 @@ components: unlisted := *loaded unlisted.Others = load.Others{} - l := lowererOver(lowering.New(0, unlisted.Doc, unlisted.Source, lowering.GroupByTags, lowering.Limits{}, + l := schema.LowererOver(lowering.New(0, unlisted.Doc, unlisted.Source, lowering.GroupByTags, lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}).WithLoaded(&unlisted)) - l.diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.ctx, l.types)) - doc := l.document(t) + l.Diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types)) + doc := l.Document(t) unresolved := func(pointer jsontext.Pointer, message string) ir.Diagnostic { return ir.Diagnostic{Severity: ir.SeverityError, Code: "openapi/unresolved-ref", Message: message, @@ -207,7 +207,7 @@ components: `discriminator mapping "x" references unresolved schema "./ext.yaml#/components/schemas/Sub"`), unresolved("/components/schemas/Base/discriminator/mapping/x", `discriminator mapping "x" references unresolved schema "./ext.yaml#/components/schemas/Sub"`), - }, withCode(l.diags.List(), "openapi/unresolved-ref"))) + }, withCode(l.Diags.List(), "openapi/unresolved-ref"))) sub, ok := doc.Types[ids.Document{}.NamedType("/components/schemas/Sub")].(*ir.Model) require.True(t, ok) assert.Equal(t, "Sub", sub.DiscriminatorValue, "the mapping tags no subtype, which takes its own name") @@ -224,7 +224,7 @@ func TestLower_UnresolvedRefDiagnostics(t *testing.T) { properties: ghost: {$ref: '#/components/schemas/Ghost'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) assert.True(t, openapitest.HasDiag(diags, diag.UnresolvedRef), "unresolved ref diagnostic emitted") } @@ -302,7 +302,7 @@ func TestSchema_EmptyFragmentRef(t *testing.T) { properties: p: {$ref: '#'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) assert.True(t, openapitest.HasDiag(diags, diag.UnresolvedRef), "the '#' ref form is unresolved") } @@ -322,7 +322,7 @@ func TestSchema_EmptyStringRefMirrorBranches(t *testing.T) { - {$ref: ''} - {type: string} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) require.NotNil(t, doc) assert.GreaterOrEqual(t, openapitest.CountDiagsAt(diags, diag.UnresolvedRef, ir.SeverityError), 2, "both empty refs are unresolved") u, ok := typeByName(doc, "U").(*ir.Union) @@ -348,7 +348,7 @@ func TestSchema_ARefThroughAnEmptyRefLendsNothing(t *testing.T) { properties: p: {$ref: '#/components/schemas/Empty'} `) - doc, _ := lowerSpec(t, spec) + doc, _ := schema.LowerSpec(t, spec) require.NotNil(t, doc) owner, ok := typeByName(doc, "Owner").(*ir.Model) require.True(t, ok) @@ -752,7 +752,7 @@ func TestRefSiteKeywords_KeptAtEveryPosition(t *testing.T) { t.Run(tc.keyword, func(t *testing.T) { t.Parallel() site := "{$ref: '#/components/schemas/" + tc.target + "', " + tc.sibling + "}" - doc, diags := lowerSpec(t, keywordCensusSpec( + doc, diags := schema.LowerSpec(t, keywordCensusSpec( " Alias: "+site+"\n"+ " Holder: {type: object, properties: {p: "+site+"}}\n")) openapitest.RequireNoErrorDiags(t, diags) @@ -803,7 +803,7 @@ func TestRefSiteKeywords_UnionKeptAtEveryPosition(t *testing.T) { t.Run(tc.keyword, func(t *testing.T) { t.Parallel() site := "{$ref: '#/components/schemas/BaseObj', " + tc.sibling + "}" - doc, diags := lowerSpec(t, keywordCensusSpec( + doc, diags := schema.LowerSpec(t, keywordCensusSpec( " Alias: "+site+"\n"+ " Holder: {type: object, properties: {p: "+site+"}}\n")) openapitest.RequireNoErrorDiags(t, diags) @@ -841,7 +841,7 @@ func TestRefSiteKeywords_UnionKeptAtEveryPosition(t *testing.T) { func TestRefSiteKeywords_SiblingsWithATypedHomeAreUntouched(t *testing.T) { t.Parallel() site := "{$ref: '#/components/schemas/BaseStr', minLength: 3, description: kept}" - doc, diags := lowerSpec(t, keywordCensusSpec( + doc, diags := schema.LowerSpec(t, keywordCensusSpec( " Alias: "+site+"\n"+ " Holder: {type: object, properties: {p: "+site+"}}\n")) openapitest.RequireNoErrorDiags(t, diags) @@ -874,7 +874,7 @@ func TestRefSiteKeywords_SiblingsWithATypedHomeAreUntouched(t *testing.T) { // one keyword would be read twice. func TestRefSiteKeywords_AllOfBranchKeepsWhatTheAliasCannotHold(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, keywordCensusSpec( + doc, diags := schema.LowerSpec(t, keywordCensusSpec( " S: {allOf: [{$ref: '#/components/schemas/BaseObj', format: email, required: [a]}]}\n")) openapitest.RequireNoErrorDiags(t, diags) @@ -914,7 +914,7 @@ func TestRefSiteKeywords_AllOfBranchKeepsUnion(t *testing.T) { } { t.Run(tc.keyword, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, keywordCensusSpec( + doc, diags := schema.LowerSpec(t, keywordCensusSpec( " S: {allOf: [{$ref: '#/components/schemas/BaseObj', "+tc.sibling+"}]}\n")) openapitest.RequireNoErrorDiags(t, diags) @@ -944,7 +944,7 @@ func TestSchemaRef_ToAPointerEndingInSlashNamesAnEmptyKey(t *testing.T) { assert.Equal(t, `unresolved $ref "#/x-lib/T/": it names nothing in the document it points into`, openapitest.DiagMessageAt(t, diags, diag.UnresolvedRef, ir.SeverityError, "/components/schemas/A/properties/p")) - doc, diags := lowerSpec(t, openapitest.ComponentSpec(" A: {type: object, properties: {p: {$ref: '#/x-lib/T/'}}}\n")+ + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" A: {type: object, properties: {p: {$ref: '#/x-lib/T/'}}}\n")+ "x-lib:\n T: {'': {type: integer}, type: object}\n") openapitest.RequireNoErrorDiags(t, diags) empty, ok := doc.Types[ir.TypeID("t/anon/x-lib/T/")].(*ir.Scalar) diff --git a/compilers/openapi/internal/schema/scalar_test.go b/compilers/openapi/internal/schema/scalar_test.go index 98052192..b97c4d48 100644 --- a/compilers/openapi/internal/schema/scalar_test.go +++ b/compilers/openapi/internal/schema/scalar_test.go @@ -10,6 +10,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -23,7 +24,7 @@ func TestScalar_UnknownFormatPerBaseType(t *testing.T) { b: {type: boolean, format: weird} s: {type: string, format: weird} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "S").(*ir.Model) bases := map[string]ir.PrimKind{} @@ -85,7 +86,7 @@ func TestScalar_NumericFormatPairings(t *testing.T) { } { t.Run(tc.typ+"/"+tc.format, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(fmt.Sprintf( + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(fmt.Sprintf( " H:\n type: object\n properties:\n p: {type: %s, format: %s}\n", tc.typ, tc.format))) assert.Empty(t, diags, "the format is homed either way, so nothing is reported") diff --git a/compilers/openapi/internal/schema/schema_budgets_test.go b/compilers/openapi/internal/schema/schema_budgets_test.go index 752ecc84..4d31c8c5 100644 --- a/compilers/openapi/internal/schema/schema_budgets_test.go +++ b/compilers/openapi/internal/schema/schema_budgets_test.go @@ -39,12 +39,12 @@ components: for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, spec) - l.ctx.Limits = lowering.Limits{MaxEnumMembers: tc.limit} + l, _ := schema.LoweredFor(t, spec) + l.Ctx.Limits = lowering.Limits{MaxEnumMembers: tc.limit} - diags := schema.LowerComponentSchemas(t.Context(), l.ctx, l.types) + diags := schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types) - node := typeByName(l.document(t), "E") + node := typeByName(l.Document(t), "E") require.NotNil(t, node) if !tc.refused { openapitest.RequireNoErrorDiags(t, diags) @@ -88,13 +88,13 @@ components: A: {type: object, properties: {n: {type: string}}} B: {type: object, properties: {n: {type: string}}} ` - l, _ := loweredFor(t, spec) - require.Zero(t, l.types.Len(), "nothing is interned before the walk runs") + l, _ := schema.LoweredFor(t, spec) + require.Zero(t, l.Types.Len(), "nothing is interned before the walk runs") ctx, cancel := context.WithCancel(t.Context()) cancel() - diags := schema.LowerComponentSchemas(ctx, l.ctx, l.types) + diags := schema.LowerComponentSchemas(ctx, l.Ctx, l.Types) assert.Empty(t, diags) - assert.Zero(t, l.types.Len(), "a cancelled walk lowers no component") + assert.Zero(t, l.Types.Len(), "a cancelled walk lowers no component") } diff --git a/compilers/openapi/internal/schema/schema_internal_test.go b/compilers/openapi/internal/schema/schema_internal_test.go index 102d134f..87b3e10a 100644 --- a/compilers/openapi/internal/schema/schema_internal_test.go +++ b/compilers/openapi/internal/schema/schema_internal_test.go @@ -24,7 +24,7 @@ func TestLower_DepthCapExceeded(t *testing.T) { indent += " " } b.WriteString(indent + "type: string\n") - doc, diags := lowerSpec(t, openapitest.ComponentSpec(b.String())) + doc, diags := LowerSpec(t, openapitest.ComponentSpec(b.String())) require.NotNil(t, doc) var sawCap bool for _, d := range diags { @@ -42,8 +42,8 @@ func TestLower_DepthCapExceeded(t *testing.T) { // reaches this. func TestRecordSkippedFamilies_MissingOwner(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - diags := recordSkippedFamilies(l.ctx, l.types, "t/anon/missing", &oas3.Schema{}, + l := NewRawLowerer(&soa.OpenAPI{}) + diags := recordSkippedFamilies(l.Ctx, l.Types, "t/anon/missing", &oas3.Schema{}, dispatch{won: "const", skipped: []string{"enum"}}, "/p") assertInternalInvariant(t, diags) } diff --git a/compilers/openapi/internal/schema/schema_test.go b/compilers/openapi/internal/schema/schema_test.go index 080e7b88..ff0b5e30 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -33,7 +33,7 @@ func TestLower_NamedScalarComponentResolves(t *testing.T) { properties: id: {$ref: "#/components/schemas/MyId"} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) scalar, ok := doc.Types[componentID("MyId")].(*ir.Scalar) @@ -58,7 +58,7 @@ func TestLower_RecursiveSchemaTerminates(t *testing.T) { properties: next: {$ref: "#/components/schemas/Node"} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) node, ok := doc.Types[componentID("Node")].(*ir.Model) require.True(t, ok) @@ -75,7 +75,7 @@ func TestSchemaRef_BooleanAndUntypedShapes(t *testing.T) { untyped: {} withprops: {properties: {x: {type: string}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m := typeByName(doc, "S").(*ir.Model) byWire := openapitest.PropsByWire(m.Properties) @@ -93,8 +93,8 @@ func TestSchemaRef_BooleanAndUntypedShapes(t *testing.T) { func TestSchemaRef_EmptyEitherIsAny(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - ref, diags := schema.Ref(l.ctx, l.types, schema.TopLevelDepth, openapitest.EmptyEitherSchema(), "/p") + l := schema.NewRawLowerer(&soa.OpenAPI{}) + ref, diags := schema.Ref(l.Ctx, l.Types, schema.TopLevelDepth, openapitest.EmptyEitherSchema(), "/p") assert.Equal(t, ir.TypeID("t/prim/any"), ref.Target) assert.Empty(t, diags, "an empty either lowers to any without complaint") } @@ -416,7 +416,7 @@ func TestCoDeclaredFamily_PassedOverKeywordIsKept(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec( + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec( " Base: {type: object, properties: {id: {type: string}}}\n S:\n"+tc.body)) openapitest.RequireNoErrorDiags(t, diags) @@ -441,7 +441,7 @@ func TestCoDeclaredFamily_PassedOverKeywordIsKept(t *testing.T) { // together in one report rather than one of them standing in for the rest. func TestCoDeclaredFamily_EveryPassedOverKeywordIsKept(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} S: const: a enum: [a, b] @@ -464,7 +464,7 @@ func TestCoDeclaredFamily_EveryPassedOverKeywordIsKept(t *testing.T) { // composition survived. func TestCoDeclaredFamily_UnpreservableIsNotAnnounced(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` S: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: allOf: [{type: object, x-t: `+unpreservableValue+`}] enum: [a, b] `)) @@ -800,13 +800,13 @@ func TestNullSchema_AnEntryIsTheEmptySchemaAndAKeywordIsLeftOut(t *testing.T) { t.Run("a property redeclared as a null keeps the losing declaration as a null", func(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, openapitest.ComponentSpec(" A:\n allOf:\n"+ + l, _ := schema.LoweredFor(t, openapitest.ComponentSpec(" A:\n allOf:\n"+ " - {type: object, properties: {p: {type: string, enum: [a, b]}}}\n"+ " - {type: object, properties: {p: null}}\n")) - l.ctx.Limits = lowering.Limits{MaxEnumMembers: 1} - diags := schema.LowerComponentSchemas(t.Context(), l.ctx, l.types) + l.Ctx.Limits = lowering.Limits{MaxEnumMembers: 1} + diags := schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types) - m := asKind[*ir.Model](t, typeByName(l.document(t), "A")) + m := asKind[*ir.Model](t, typeByName(l.Document(t), "A")) require.Len(t, m.Properties, 1) assert.Equal(t, ir.TypeID("t/anon/components/schemas/A/allOf/0/properties/p"), m.Properties[0].Type.Target, "the first declaration stands") @@ -836,7 +836,7 @@ func asKind[T ir.TypeDef](t *testing.T, td ir.TypeDef) T { // beside the $ref (S: {$ref: Target, minimum: 5}) binds S, not Target. func TestLowerComponentSchema_RefSiblingConstraintBindsTheSite(t *testing.T) { t.Parallel() - l, _ := loweredFor(t, `openapi: 3.1.0 + l, _ := schema.LoweredFor(t, `openapi: 3.1.0 info: {title: t, version: "1"} paths: {} components: @@ -846,15 +846,15 @@ components: $ref: '#/components/schemas/Target' minimum: 5 `) - l.diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.ctx, l.types)) + l.Diags.AppendAll(schema.LowerComponentSchemas(t.Context(), l.Ctx, l.Types)) - sc, ok := typeByName(l.document(t), "S").(*ir.Scalar) + sc, ok := typeByName(l.Document(t), "S").(*ir.Scalar) require.True(t, ok, "S aliases Target and must own a Scalar node") require.NotNil(t, sc.Constraints, "a bound beside a $ref binds S, not Target") require.NotNil(t, sc.Constraints.Min) assert.Equal(t, "5", sc.Constraints.Min.String()) - target := typeByName(l.document(t), "Target") + target := typeByName(l.Document(t), "Target") require.NotNil(t, target) tsc, ok := target.(*ir.Scalar) require.True(t, ok, "Target aliases a primitive and must own a Scalar node") @@ -879,7 +879,7 @@ func TestLowerComponentSchemas_PercentEncodedRefResolves(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec( + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec( " "+tc.decl+": {type: string}\n"+ " User: {type: object, properties: {x: {$ref: '#/components/schemas/"+tc.encoded+"'}}}\n")) openapitest.RequireNoErrorDiags(t, diags) @@ -903,7 +903,7 @@ func TestLowerComponentSchemas_PercentEncodedRefResolves(t *testing.T) { // alone lands on one node either way, and only its name would be wrong. func TestLowerComponentSchemas_PercentEncodedRefHoistsAtTheDeclaredCoordinate(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec( + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec( " Foo-Bar: {type: object, properties: {inner: {type: object, properties: {n: {type: integer}}}}}\n"+ " User:\n type: object\n properties:\n"+ " a: {$ref: '#/components/schemas/Foo-Bar/properties/inner'}\n"+ @@ -934,7 +934,7 @@ func TestLowerComponentSchemas_PercentEncodedRefHoistsAtTheDeclaredCoordinate(t // mappings alongside $ref; nothing exercised that half. func TestLowerComponentSchemas_PercentEncodedDiscriminatorMapping(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec( + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec( " Cat-A: {type: object, properties: {kind: {type: string}}}\n"+ " Pet:\n oneOf: [{$ref: '#/components/schemas/Cat-A'}]\n"+ " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat%2DA'}}\n")) diff --git a/compilers/openapi/internal/schema/union_internal_test.go b/compilers/openapi/internal/schema/union_internal_test.go index 48e8fce5..ded5028d 100644 --- a/compilers/openapi/internal/schema/union_internal_test.go +++ b/compilers/openapi/internal/schema/union_internal_test.go @@ -27,10 +27,10 @@ func TestAllNullUnion_NoBranchesFalse(t *testing.T) { func TestPreserveUnionSiblings_MissingNode(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) + l := NewRawLowerer(&soa.OpenAPI{}) // No node registered under the id: the union branches have nowhere to go, so // the guard reports the broken invariant instead of dropping them quietly. - diags := preserveUnionSiblings(l.ctx, l.types, "t/anon/missing", &oas3.Schema{}, "/p", ir.ReasonDegradedLowering, "why") + diags := preserveUnionSiblings(l.Ctx, l.Types, "t/anon/missing", &oas3.Schema{}, "/p", ir.ReasonDegradedLowering, "why") assertInternalInvariant(t, diags) } @@ -40,8 +40,8 @@ func TestPreserveUnionSiblings_MissingNode(t *testing.T) { // invariant instead of dropping it quietly. func TestPreserveNullOnlyUnion_MissingNode(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - diags := preserveNullOnlyUnion(l.ctx, l.types, "t/anon/missing", &oas3.Schema{}, "/p") + l := NewRawLowerer(&soa.OpenAPI{}) + diags := preserveNullOnlyUnion(l.Ctx, l.Types, "t/anon/missing", &oas3.Schema{}, "/p") assertInternalInvariant(t, diags) } @@ -53,10 +53,10 @@ func TestPreserveNullOnlyUnion_MissingNode(t *testing.T) { // guard. func TestPreserveNullOnlyUnion_NothingToKeep(t *testing.T) { t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - id := internNode(l.ctx, l.types, "/components/schemas/S", func(cm ir.TypeCommon) ir.TypeDef { + l := NewRawLowerer(&soa.OpenAPI{}) + id := internNode(l.Ctx, l.Types, "/components/schemas/S", func(cm ir.TypeCommon) ir.TypeDef { return &ir.Scalar{TypeCommon: cm} }) - diags := preserveNullOnlyUnion(l.ctx, l.types, id, &oas3.Schema{}, "/components/schemas/S") + diags := preserveNullOnlyUnion(l.Ctx, l.Types, id, &oas3.Schema{}, "/components/schemas/S") assert.Empty(t, diags, "nothing was kept, so nothing is announced") } diff --git a/compilers/openapi/internal/schema/union_test.go b/compilers/openapi/internal/schema/union_test.go index 6b91b64b..56d6ec72 100644 --- a/compilers/openapi/internal/schema/union_test.go +++ b/compilers/openapi/internal/schema/union_test.go @@ -14,6 +14,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/pass" ) @@ -35,7 +36,7 @@ func TestDistributedUnion_EachVariantCarriesTheRequiresOfItsBody(t *testing.T) { - {$ref: '#/components/schemas/X'} - {$ref: '#/components/schemas/Y'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Shape").(*ir.Union) @@ -84,7 +85,7 @@ func TestDistributedUnion_VariantCarriesTheBranchNullability(t *testing.T) { - {$ref: '#/components/schemas/NullableModel'} - {$ref: '#/components/schemas/PlainModel'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) nullableOf := func(name string) []bool { @@ -125,7 +126,7 @@ func TestUnionVariant_IsNamedAsTheNodeItHolds(t *testing.T) { A: {type: array, items: {type: object, properties: {b: {type: string}}}} Y: {type: array, items: {type: object, properties: {y: {type: string}}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) choice, ok := typeByName(doc, "Choice").(*ir.Union) @@ -273,7 +274,7 @@ func TestOneOf_WithDiscriminator(t *testing.T) { mapping: cat: "#/components/schemas/Cat" `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) pet, ok := doc.Types[componentID("Pet")].(*ir.Union) require.True(t, ok, "Pet should be a union") @@ -293,7 +294,7 @@ func TestAnyOf_IsNonExclusiveUnion(t *testing.T) { - {type: string} - {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := doc.Types[componentID("U")].(*ir.Union) require.True(t, ok, "U should be a union") @@ -312,7 +313,7 @@ func TestOneOf_NullVariantCollapses(t *testing.T) { - {type: string} - {type: "null"} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) s, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) @@ -342,7 +343,7 @@ func TestOneOfAnyOf_NullOnlyLowersToNullableAny(t *testing.T) { AnyOfTwoNull: anyOf: [{type: "null"}, {type: "null"}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for _, tc := range []struct{ name, key string }{ @@ -380,7 +381,7 @@ func TestOneOf_ThreeVariantsWithNullStripsNullLiftsNullable(t *testing.T) { - {type: integer} - {type: "null"} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) s := doc.Types[componentID("S")].(*ir.Model) require.Len(t, s.Properties, 1) @@ -404,7 +405,7 @@ func TestAnyOf_ThreeVariantsWithNull(t *testing.T) { - {type: integer} - {type: 'null'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u := typeByName(doc, "N").(*ir.Union) assert.Len(t, u.Variants, 2, "null branch stripped from variants") @@ -419,7 +420,7 @@ func TestUnion_VariantHints(t *testing.T) { - {type: string} Named: {type: object, properties: {a: {type: string}}} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u := typeByName(doc, "U").(*ir.Union) require.Len(t, u.Variants, 2) @@ -558,7 +559,7 @@ func TestUnion_ADecoratedBranchIsHintedAsItsRef(t *testing.T) { spec += " - " + tc.bare + "\n" wantVariants = 3 } - doc, diags := lowerSpec(t, openapitest.ComponentSpecVer(version, spec)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer(version, spec)) if tc.wantDangling { assert.True(t, openapitest.HasDiagAt(diags, diag.UnresolvedRef, ir.SeverityError), "the dangling ref is reported: %+v", diags) @@ -645,7 +646,7 @@ func TestOneOf_CoDeclaredCompositionDistributes(t *testing.T) { - {$ref: '#/components/schemas/A'} - {$ref: '#/components/schemas/B'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Combo").(*ir.Union) @@ -683,7 +684,7 @@ func TestAnyOf_CoDeclaredPropertiesDistributeAsBase(t *testing.T) { - {$ref: '#/components/schemas/A'} - {$ref: '#/components/schemas/B'} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Combo").(*ir.Union) @@ -720,7 +721,7 @@ func TestOneOf_CoDeclaredVariantNotStolenByRefToBranch(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, comboRefStealSpec(tc.branch, tc.outsiderFirst)) + doc, diags := schema.LowerSpec(t, comboRefStealSpec(tc.branch, tc.outsiderFirst)) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Combo").(*ir.Union) @@ -817,7 +818,7 @@ func TestNullCollapse_BranchHintIsOrderIndependent(t *testing.T) { // the node the mapping names. func TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, comboDiscriminatedSpec(true)) + doc, diags := schema.LowerSpec(t, comboDiscriminatedSpec(true)) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Combo").(*ir.Union) @@ -860,7 +861,7 @@ func TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue(t *te - $ref: '#/components/schemas/A' - $ref: '#/components/schemas/B' `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := doc.Types[ir.TypeID("t/anon/components/schemas/Kennel/properties/Combo")].(*ir.Union) @@ -888,7 +889,7 @@ func TestOneOf_CoDeclaredAdditionalPropsHintNamesTheBody(t *testing.T) { additionalProperties: {type: object, properties: {v: {type: string}}} oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u := typeByName(doc, "Combo").(*ir.Union) @@ -912,7 +913,7 @@ func TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten(t *testing.T) { properties: {kind: {type: string}} oneOf: [{$ref: '#/components/schemas/Scalar'}, {$ref: '#/components/schemas/Choice'}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "Combo").(*ir.Union) @@ -1017,7 +1018,7 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { discriminator: {propertyName: kind} oneOf: [{$ref: '#/components/schemas/A'}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for name, reason := range map[string]string{ @@ -1106,7 +1107,7 @@ func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(tc.schemas)) + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(tc.schemas)) openapitest.RequireNoErrorDiags(t, diags) sc, ok := typeByName(doc, "A").(*ir.Scalar) @@ -1130,7 +1131,7 @@ func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) // union and the other is kept beside it. func TestUnionCombinators_PassedOverBranchSetIsKept(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` S: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: oneOf: [{type: string}, {type: integer}] anyOf: [{type: number}, {type: boolean}] `)) @@ -1156,7 +1157,7 @@ func TestUnionCombinators_PassedOverBranchSetIsKept(t *testing.T) { // the first place, so it stays a Union and keeps the loser on it. func TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` S: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: oneOf: [{type: string}, {type: "null"}] anyOf: [{type: number}, {type: boolean}] `)) @@ -1176,7 +1177,7 @@ func TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf(t *testing.T) { // at the keyword, and nothing claims the branch set survived. func TestUnionCombinators_UnpreservableIsNotAnnounced(t *testing.T) { t.Parallel() - doc, diags := lowerSpec(t, openapitest.ComponentSpec(` S: + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: oneOf: [{type: string}, {type: integer}] anyOf: [{type: number, x-t: `+unpreservableValue+`}] `)) @@ -1242,7 +1243,7 @@ func TestLower_ConstraintOnlyUnionIsValidationOnly(t *testing.T) { - {required: [a]} - {required: [b]} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Thing")].(*ir.Model) @@ -1278,7 +1279,7 @@ func TestLower_BooleanUnionBranchDeclaresNoShape(t *testing.T) { - true - false `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Flag")].(*ir.Model) @@ -1307,7 +1308,7 @@ func TestLower_AllOfWithOneOfKeepsBoth(t *testing.T) { - {type: string} - {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) m, ok := doc.Types[componentID("Combo")].(*ir.Model) require.True(t, ok, "allOf composition survives (Model), oneOf preserved raw") @@ -1324,7 +1325,7 @@ func TestLower_MultiTypeUnion(t *testing.T) { properties: {x: {type: string}} items: {type: integer} `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "MT").(*ir.Union) require.True(t, ok) @@ -1350,7 +1351,7 @@ func TestLower_UnionWithStructuralSiblingVariants(t *testing.T) { type: string oneOf: [{$ref: '#/components/schemas/A'}, {type: integer}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for _, name := range []string{"A", "B", "C", "D"} { td := typeByName(doc, name) @@ -1371,7 +1372,7 @@ func TestSchema_UnionSiblingsAdditionalAndRequired(t *testing.T) { required: [x] oneOf: [{type: string}, {type: integer}] `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) for _, name := range []string{"A", "B"} { _, ok := typeByName(doc, name).Common().Unmodeled["openapi:oneOf"] @@ -1386,7 +1387,7 @@ func TestSchema_OneOfWithBoolBranch(t *testing.T) { - {type: string} - true `) - doc, diags := lowerSpec(t, spec) + doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) u, ok := typeByName(doc, "U").(*ir.Union) require.True(t, ok) @@ -1401,7 +1402,7 @@ func TestSchema_OneOfWithBoolBranch(t *testing.T) { // preserved construct that a claim could be about. func TestUnionSiblings_UnpreservableIsReportedNotClaimed(t *testing.T) { t.Parallel() - _, diags := lowerSpec(t, openapitest.ComponentSpec(` S: + _, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: items: {type: string, x-t: `+unpreservableValue+`} oneOf: [{type: string, x-t: `+unpreservableValue+`}, {type: integer}] `)) From 211b3aac28aaf3dee7157989358f3d786b6ce9fb Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 02:53:05 +0300 Subject: [PATCH 05/18] test: file each test beside the code its name says it tests Sixty-two tests sat in a file named for one source file while their names, and their subjects, were declarations of another. load's source_internal_test.go held twenty tests of hold, which external.go declares, and spellings_internal_test.go five more; pass's validate_refs tests held collectTypeIDs's, which #1102 moved to validate_graphql.go; bind's documents tests held prefix, which defs.go declares. Each moves to the test file of the file declaring its subject, created where none existed: hold's twenty-five tests to external_hold_internal_test.go, with the helpers only they use, and the rest to the bindings, models, unprepared, source, typejudge, losers, nullability, visibility, constraints, owners, code, format, resolve and amplification test files of their packages. Two helpers several load files share, diagLines and response200, go to load's helpers_internal_test.go. What stays is a test named for its package's entry point or for a construct and filed by the subject it exercises through it: the CLI's TestRun_ tests, load's TestResolve_, operation's TestContent_ and TestOperations_, and schema's TestLower_, TestSchema_, TestModel_ and TestAllOf_. Every package's tests and subtests are the same before and after. --- cmd/morphic/compile_test.go | 25 + cmd/morphic/options_test.go | 25 - .../internal/bind/defs_internal_test.go | 20 + .../internal/bind/documents_internal_test.go | 11 - .../internal/bind/grammar_internal_test.go | 12 - .../internal/bind/hop_internal_test.go | 19 + .../internal/load/beside_internal_test.go | 9 - .../internal/load/bindings_internal_test.go | 114 +++ .../internal/load/elsewhere_internal_test.go | 64 -- .../load/external_hold_internal_test.go | 850 ++++++++++++++++++ .../internal/load/helpers_internal_test.go | 22 + .../internal/load/models_internal_test.go | 16 + .../internal/load/others_internal_test.go | 22 - .../internal/load/reached_internal_test.go | 34 - .../internal/load/source_internal_test.go | 630 +------------ .../internal/load/spellings_internal_test.go | 278 ------ .../internal/load/unprepared_internal_test.go | 25 + .../internal/load/validate_internal_test.go | 22 - .../internal/merge/admits_internal_test.go | 48 - .../merge/constraints_internal_test.go | 53 ++ .../merge/fold_cases_internal_test.go | 343 ------- .../internal/merge/fold_internal_test.go | 62 +- .../internal/merge/losers_internal_test.go | 127 +++ .../internal/merge/merge_internal_test.go | 23 - .../merge/nullability_internal_test.go | 39 + .../internal/merge/typejudge_internal_test.go | 98 ++ .../merge/visibility_internal_test.go | 119 +++ .../internal/naming/naming_internal_test.go | 18 - .../internal/naming/owners_internal_test.go | 27 + .../internal/oas/code_internal_test.go | 14 + .../internal/oas/engine_internal_test.go | 16 - .../internal/oas/format_internal_test.go | 16 + .../internal/resolve/locate_internal_test.go | 142 --- .../internal/resolve/resolve_internal_test.go | 145 +++ .../scan/amplification_internal_test.go | 68 +- .../internal/scan/site_internal_test.go | 65 -- pass/validate_graphql_test.go | 37 + pass/validate_refs_test.go | 37 - 38 files changed, 1884 insertions(+), 1811 deletions(-) create mode 100644 compilers/openapi/internal/bind/defs_internal_test.go create mode 100644 compilers/openapi/internal/bind/hop_internal_test.go create mode 100644 compilers/openapi/internal/load/external_hold_internal_test.go create mode 100644 compilers/openapi/internal/load/models_internal_test.go delete mode 100644 compilers/openapi/internal/load/spellings_internal_test.go create mode 100644 compilers/openapi/internal/merge/losers_internal_test.go create mode 100644 compilers/openapi/internal/merge/nullability_internal_test.go create mode 100644 compilers/openapi/internal/merge/visibility_internal_test.go create mode 100644 compilers/openapi/internal/naming/owners_internal_test.go create mode 100644 compilers/openapi/internal/oas/code_internal_test.go create mode 100644 compilers/openapi/internal/oas/format_internal_test.go diff --git a/cmd/morphic/compile_test.go b/cmd/morphic/compile_test.go index 0e9719b3..600e6e69 100644 --- a/cmd/morphic/compile_test.go +++ b/cmd/morphic/compile_test.go @@ -399,3 +399,28 @@ func TestRun_CompactOutputKeepsDocumentOrder(t *testing.T) { assert.Less(t, bytes.Index(compact, []byte("Alpha")), bytes.Index(compact, []byte("Zeta")), "schemas must be emitted in sorted-key order, not declaration order") } + +// TestSpecOptions_DisabledPasses pins that --skip-validate adds validate to +// whatever --disable-pass named, and that names reach the engine as typed. +func TestSpecOptions_DisabledPasses(t *testing.T) { + t.Parallel() + tests := []struct { + name string + opts specOptions + want []string + }{ + {"neither flag", specOptions{}, nil}, + {"--skip-validate", specOptions{skipValidate: true}, []string{"validate"}}, + {"--disable-pass validate", specOptions{disable: passFlag{"validate"}}, []string{"validate"}}, + {"both", specOptions{skipValidate: true, disable: passFlag{"validate"}}, + []string{"validate", "validate"}}, + {"another name with --skip-validate", specOptions{skipValidate: true, disable: passFlag{"Nope"}}, + []string{"Nope", "validate"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Empty(t, cmp.Diff(tt.want, tt.opts.disabledPasses())) + }) + } +} diff --git a/cmd/morphic/options_test.go b/cmd/morphic/options_test.go index 6868d61a..3df096ed 100644 --- a/cmd/morphic/options_test.go +++ b/cmd/morphic/options_test.go @@ -89,28 +89,3 @@ func TestPassFlag_SetKeepsNamesAsTyped(t *testing.T) { assert.Empty(t, cmp.Diff(passFlag{"validate", "validate", ""}, got)) assert.Equal(t, "validate validate ", got.String()) } - -// TestSpecOptions_DisabledPasses pins that --skip-validate adds validate to -// whatever --disable-pass named, and that names reach the engine as typed. -func TestSpecOptions_DisabledPasses(t *testing.T) { - t.Parallel() - tests := []struct { - name string - opts specOptions - want []string - }{ - {"neither flag", specOptions{}, nil}, - {"--skip-validate", specOptions{skipValidate: true}, []string{"validate"}}, - {"--disable-pass validate", specOptions{disable: passFlag{"validate"}}, []string{"validate"}}, - {"both", specOptions{skipValidate: true, disable: passFlag{"validate"}}, - []string{"validate", "validate"}}, - {"another name with --skip-validate", specOptions{skipValidate: true, disable: passFlag{"Nope"}}, - []string{"Nope", "validate"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Empty(t, cmp.Diff(tt.want, tt.opts.disabledPasses())) - }) - } -} diff --git a/compilers/openapi/internal/bind/defs_internal_test.go b/compilers/openapi/internal/bind/defs_internal_test.go new file mode 100644 index 00000000..dc413238 --- /dev/null +++ b/compilers/openapi/internal/bind/defs_internal_test.go @@ -0,0 +1,20 @@ +package bind + +import ( + "encoding/json/jsontext" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestPrefix_SlicesItsPointer pins prefix: the pointer to the first n tokens, +// cut from the pointer itself, so it allocates nothing however long it is. +func TestPrefix_SlicesItsPointer(t *testing.T) { + pointer := jsontext.Pointer("/a~1b/c/~0d/e") + for n, want := range []string{"", "/a~1b", "/a~1b/c", "/a~1b/c/~0d", "/a~1b/c/~0d/e", "/a~1b/c/~0d/e"} { + assert.Equal(t, want, prefix(pointer, n), n) + } + long := jsontext.Pointer(strings.Repeat("/p", 1000)) + assert.Zero(t, testing.AllocsPerRun(10, func() { _ = prefix(long, 500) })) +} diff --git a/compilers/openapi/internal/bind/documents_internal_test.go b/compilers/openapi/internal/bind/documents_internal_test.go index c546f260..529becbd 100644 --- a/compilers/openapi/internal/bind/documents_internal_test.go +++ b/compilers/openapi/internal/bind/documents_internal_test.go @@ -76,14 +76,3 @@ func TestDocs_ReadsAPointerToItsTokenCap(t *testing.T) { _, reason = d.read(Source, jsontext.Pointer(strings.Repeat("/p", maxPointerTokens+1))) assert.Equal(t, NoTarget, reason) } - -// TestPrefix_SlicesItsPointer pins prefix: the pointer to the first n tokens, -// cut from the pointer itself, so it allocates nothing however long it is. -func TestPrefix_SlicesItsPointer(t *testing.T) { - pointer := jsontext.Pointer("/a~1b/c/~0d/e") - for n, want := range []string{"", "/a~1b", "/a~1b/c", "/a~1b/c/~0d", "/a~1b/c/~0d/e", "/a~1b/c/~0d/e"} { - assert.Equal(t, want, prefix(pointer, n), n) - } - long := jsontext.Pointer(strings.Repeat("/p", 1000)) - assert.Zero(t, testing.AllocsPerRun(10, func() { _ = prefix(long, 500) })) -} diff --git a/compilers/openapi/internal/bind/grammar_internal_test.go b/compilers/openapi/internal/bind/grammar_internal_test.go index 5e6adb27..8e00e8bf 100644 --- a/compilers/openapi/internal/bind/grammar_internal_test.go +++ b/compilers/openapi/internal/bind/grammar_internal_test.go @@ -164,15 +164,3 @@ func containerOf(of map[state]state, holds state) state { } return sValue } - -// TestIsAnchorName pins the names a $anchor can declare, by JSON Schema -// 2020-12 section 8.2.2: none empty, none opening with a digit, '-' or '.'. -func TestIsAnchorName(t *testing.T) { - t.Parallel() - for name, want := range map[string]bool{ - "a": true, "_": true, "Z9-_.": true, "": false, "1a": false, "-a": false, ".a": false, "a b": false, - "a/b": false, "é": false, - } { - assert.Equal(t, want, isAnchorName(name), "%q", name) - } -} diff --git a/compilers/openapi/internal/bind/hop_internal_test.go b/compilers/openapi/internal/bind/hop_internal_test.go new file mode 100644 index 00000000..06ad2bec --- /dev/null +++ b/compilers/openapi/internal/bind/hop_internal_test.go @@ -0,0 +1,19 @@ +package bind + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestIsAnchorName pins the names a $anchor can declare, by JSON Schema +// 2020-12 section 8.2.2: none empty, none opening with a digit, '-' or '.'. +func TestIsAnchorName(t *testing.T) { + t.Parallel() + for name, want := range map[string]bool{ + "a": true, "_": true, "Z9-_.": true, "": false, "1a": false, "-a": false, ".a": false, "a b": false, + "a/b": false, "é": false, + } { + assert.Equal(t, want, isAnchorName(name), "%q", name) + } +} diff --git a/compilers/openapi/internal/load/beside_internal_test.go b/compilers/openapi/internal/load/beside_internal_test.go index 1e09cb62..3c67e2d5 100644 --- a/compilers/openapi/internal/load/beside_internal_test.go +++ b/compilers/openapi/internal/load/beside_internal_test.go @@ -270,12 +270,3 @@ func (bareReference) IsReference() bool { return true } func (coredReference) GetRootNode() *yaml.Node { return nil } func (coredReference) IsReference() bool { return true } func (r coredReference) GetCoreAny() any { return r.core } - -// TestOwnRoot_AModelWithNoReadableCoreHasNone pins ownRoot's two guards: a $ref -// whose own mapping cannot be read has none to answer with, rather than the -// target's. -func TestOwnRoot_AModelWithNoReadableCoreHasNone(t *testing.T) { - t.Parallel() - assert.Nil(t, ownRoot(bareReference{})) - assert.Nil(t, ownRoot(coredReference{core: 42})) -} diff --git a/compilers/openapi/internal/load/bindings_internal_test.go b/compilers/openapi/internal/load/bindings_internal_test.go index 6525d6ba..9fe8b493 100644 --- a/compilers/openapi/internal/load/bindings_internal_test.go +++ b/compilers/openapi/internal/load/bindings_internal_test.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "strings" "testing" "github.com/google/go-cmp/cmp" + oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -342,3 +344,115 @@ func inSource(at func(jsontext.Pointer) ir.Provenance) func(bind.Position) ir.Pr func byDocument(p bind.Position) ir.Provenance { return ir.Provenance{Source: int(p.Doc), Pointer: p.Pointer} } + +// TestPositionOf_PlacesASiteInTheDocumentTheTableRead pins positionOf and +// siteIn: a source site is the source's position, another document's is found +// by the key a request reads a URL by (readKey), however the table or the site +// spells it, and one in a document the table never read is placed nowhere. +func TestPositionOf_PlacesASiteInTheDocumentTheTableRead(t *testing.T) { + t.Parallel() + _, root := buildDoc(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n schemas:\n"+ + " A: {$ref: 'other.yaml#/T'}\n B: {$ref: 'http://h/a%20b.yaml#/T'}\n C: {$ref: 'http://h/c d.yaml#/T'}\n") + table := bind.Bind(root, bind.Config{Self: "spec.yaml", Open: func(string) (*yaml.Node, error) { + var n yaml.Node + return &n, yaml.Unmarshal([]byte("T: {type: string}\n"), &n) + }}) + require.Equal(t, 4, table.Documents(), "the source, other.yaml and the two URLs") + other, remote, spaced := bind.DocID(1), bind.DocID(2), bind.DocID(3) + require.Equal(t, "other.yaml", table.Document(other).Name) + require.Equal(t, "http://h/a%20b.yaml", table.Document(remote).Name) + require.Equal(t, "http://h/c d.yaml", table.Document(spaced).Name) + self := sourceDocument{path: "spec.yaml"} + + for site, want := range map[refSite]bind.Position{ + {pointer: "/x"}: {Doc: bind.Source, Pointer: "/x"}, + {doc: "other.yaml", pointer: "/y"}: {Doc: other, Pointer: "/y"}, + {doc: "http://h/a b.yaml", pointer: "/z"}: {Doc: remote, Pointer: "/z"}, + self.siteIn("spec.yaml", "/w"): {Doc: bind.Source, Pointer: "/w"}, + self.siteIn("http://h/a b.yaml", "/v"): {Doc: remote, Pointer: "/v"}, + {doc: "http://h/c%20d.yaml", pointer: "/u"}: {Doc: spaced, Pointer: "/u"}, + } { + got, ok := self.positionOf(table, site) + require.True(t, ok, "%+v", site) + assert.Equal(t, want, got, "%+v", site) + } + _, ok := self.positionOf(table, refSite{doc: "gone.yaml", pointer: "/x"}) + assert.False(t, ok, "a document the table never read") +} + +// TestUnplaced_NamesTheDocumentOfASiteOutsideTheSource pins how unplaced +// spells the reference each schema of an end the table could not place was +// read under: by its pointer in the source, and by its pointer in its document +// elsewhere. +func TestUnplaced_NamesTheDocumentOfASiteOutsideTheSource(t *testing.T) { + t.Parallel() + inSource := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{}) + inA := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{}) + bd := typed.NewBuilder(nil) + + unplaced(t.Context(), bd, referenceEnd{walk: walkOf(&soa.ReferencedParameter{Object: &soa.Parameter{Schema: inSource}})}, + refSite{pointer: "/paths/~1x/get/parameters/0"}) + unplaced(t.Context(), bd, referenceEnd{walk: walkOf(&soa.ReferencedParameter{Object: &soa.Parameter{Schema: inA}})}, + refSite{doc: "a.yaml", pointer: "/components/parameters/P"}) + + site, ok := bd.Bindings().Unplaced(inSource) + require.True(t, ok) + assert.Equal(t, "/paths/~1x/get/parameters/0", site) + site, ok = bd.Bindings().Unplaced(inA) + require.True(t, ok) + assert.Equal(t, "/components/parameters/P in a.yaml", site) +} + +// TestPlacer_PlacesAFindingInTheDocumentThatHoldsIt pins where a finding at a +// position of the bindings' table is placed: the source's at its own +// Provenance, another document's in that document's Document.Sources entry, +// and one in a document no entry lists, which a load never produces, at the +// source's root rather than nowhere. +func TestPlacer_PlacesAFindingInTheDocumentThatHoldsIt(t *testing.T) { + t.Parallel() + doc, codes := loadReferencing(t, "./ext.yaml#/X") + require.Empty(t, codes) + at := func(p jsontext.Pointer) ir.Provenance { return ir.Provenance{Pointer: "/at" + p} } + ext := bind.Position{Doc: 1, Pointer: "/X"} + + listed := bound{bindings: doc.Bindings, others: doc.Others}.placer(at) + assert.Equal(t, ir.Provenance{Pointer: "/at/components"}, listed(bind.Position{Pointer: "/components"})) + assert.Equal(t, ir.Provenance{Source: 1, Pointer: "/X"}, listed(ext)) + + unlisted := bound{bindings: doc.Bindings}.placer(at) + assert.Equal(t, ir.Provenance{Pointer: "/at"}, unlisted(ext)) +} + +// TestBindSchemas_AChainEndsInTheDocumentItEndsIn is TestTargetOf for schema +// $refs, which the bindings read: each chain ends in the document its last +// hop lands in, not the one it first leaves for, and a schema the model does +// not hold there is built for the landing, a boolean one included, and lends +// its use site that schema, whichever documents the chain passes. +func TestBindSchemas_AChainEndsInTheDocumentItEndsIn(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": reachedFixtureOther, "third.yaml": reachedFixtureThird}) + got, _ := loadExternal(t, dir, reachedFixtureRoot, Options{}) + doc, bindings := got.Doc, got.Bindings + table := bindings.Table() + for name, want := range map[string]struct { + doc string + pointer jsontext.Pointer + }{ + "S": {filepath.Join(dir, "other.yaml"), "/components/schemas/Obj"}, + "Bool": {filepath.Join(dir, "other.yaml"), "/components/schemas/AnyBool"}, + "Chained": {filepath.Join(dir, "third.yaml"), "/components/schemas/Leaf"}, + "ToS": {filepath.Join(dir, "other.yaml"), "/components/schemas/Obj"}, + "ToLocal": {filepath.Join(dir, "root.yaml"), "/components/schemas/Local"}, + } { + js, ok := doc.Components.Schemas.Get(name) + require.True(t, ok, name) + e, ok := bindings.Entry(js) + require.True(t, ok, name) + require.Equal(t, bind.Resolved, e.Chain.Status, name) + assert.Equal(t, want.doc, table.Document(e.Chain.End.Doc).Name, name) + assert.Equal(t, want.pointer, e.Chain.End.Pointer, name) + held := bindings.At(e.Chain.End) + assert.NotNil(t, held, "%s ends on a schema held for it", name) + assert.Same(t, held, bindings.End(js), name) + } +} diff --git a/compilers/openapi/internal/load/elsewhere_internal_test.go b/compilers/openapi/internal/load/elsewhere_internal_test.go index 526db2e1..a4e7ecc6 100644 --- a/compilers/openapi/internal/load/elsewhere_internal_test.go +++ b/compilers/openapi/internal/load/elsewhere_internal_test.go @@ -5,14 +5,8 @@ import ( "slices" "testing" - oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" - soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - yaml "gopkg.in/yaml.v3" - "github.com/dexpace/morphic/compilers/openapi/internal/bind" - "github.com/dexpace/morphic/compilers/openapi/internal/bind/typed" "github.com/dexpace/morphic/ir" ) @@ -49,61 +43,3 @@ func TestSitePlacer_PlacesASiteInItsDocument(t *testing.T) { assert.Equal(t, ir.Provenance{Source: 2, Pointer: "/y"}, place(refSite{doc: "dir/a.yaml", pointer: "/y"})) assert.Equal(t, ir.Provenance{Source: 7}, place(refSite{doc: "gone.yaml", pointer: "/z"})) } - -// TestPositionOf_PlacesASiteInTheDocumentTheTableRead pins positionOf and -// siteIn: a source site is the source's position, another document's is found -// by the key a request reads a URL by (readKey), however the table or the site -// spells it, and one in a document the table never read is placed nowhere. -func TestPositionOf_PlacesASiteInTheDocumentTheTableRead(t *testing.T) { - t.Parallel() - _, root := buildDoc(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n schemas:\n"+ - " A: {$ref: 'other.yaml#/T'}\n B: {$ref: 'http://h/a%20b.yaml#/T'}\n C: {$ref: 'http://h/c d.yaml#/T'}\n") - table := bind.Bind(root, bind.Config{Self: "spec.yaml", Open: func(string) (*yaml.Node, error) { - var n yaml.Node - return &n, yaml.Unmarshal([]byte("T: {type: string}\n"), &n) - }}) - require.Equal(t, 4, table.Documents(), "the source, other.yaml and the two URLs") - other, remote, spaced := bind.DocID(1), bind.DocID(2), bind.DocID(3) - require.Equal(t, "other.yaml", table.Document(other).Name) - require.Equal(t, "http://h/a%20b.yaml", table.Document(remote).Name) - require.Equal(t, "http://h/c d.yaml", table.Document(spaced).Name) - self := sourceDocument{path: "spec.yaml"} - - for site, want := range map[refSite]bind.Position{ - {pointer: "/x"}: {Doc: bind.Source, Pointer: "/x"}, - {doc: "other.yaml", pointer: "/y"}: {Doc: other, Pointer: "/y"}, - {doc: "http://h/a b.yaml", pointer: "/z"}: {Doc: remote, Pointer: "/z"}, - self.siteIn("spec.yaml", "/w"): {Doc: bind.Source, Pointer: "/w"}, - self.siteIn("http://h/a b.yaml", "/v"): {Doc: remote, Pointer: "/v"}, - {doc: "http://h/c%20d.yaml", pointer: "/u"}: {Doc: spaced, Pointer: "/u"}, - } { - got, ok := self.positionOf(table, site) - require.True(t, ok, "%+v", site) - assert.Equal(t, want, got, "%+v", site) - } - _, ok := self.positionOf(table, refSite{doc: "gone.yaml", pointer: "/x"}) - assert.False(t, ok, "a document the table never read") -} - -// TestUnplaced_NamesTheDocumentOfASiteOutsideTheSource pins how unplaced -// spells the reference each schema of an end the table could not place was -// read under: by its pointer in the source, and by its pointer in its document -// elsewhere. -func TestUnplaced_NamesTheDocumentOfASiteOutsideTheSource(t *testing.T) { - t.Parallel() - inSource := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{}) - inA := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{}) - bd := typed.NewBuilder(nil) - - unplaced(t.Context(), bd, referenceEnd{walk: walkOf(&soa.ReferencedParameter{Object: &soa.Parameter{Schema: inSource}})}, - refSite{pointer: "/paths/~1x/get/parameters/0"}) - unplaced(t.Context(), bd, referenceEnd{walk: walkOf(&soa.ReferencedParameter{Object: &soa.Parameter{Schema: inA}})}, - refSite{doc: "a.yaml", pointer: "/components/parameters/P"}) - - site, ok := bd.Bindings().Unplaced(inSource) - require.True(t, ok) - assert.Equal(t, "/paths/~1x/get/parameters/0", site) - site, ok = bd.Bindings().Unplaced(inA) - require.True(t, ok) - assert.Equal(t, "/components/parameters/P in a.yaml", site) -} diff --git a/compilers/openapi/internal/load/external_hold_internal_test.go b/compilers/openapi/internal/load/external_hold_internal_test.go new file mode 100644 index 00000000..5ba9b676 --- /dev/null +++ b/compilers/openapi/internal/load/external_hold_internal_test.go @@ -0,0 +1,850 @@ +package load + +import ( + "encoding/json/jsontext" + "fmt" + "maps" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + soa "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/overlay" + "github.com/dexpace/morphic/engine/reader" +) + +// TestHold_ABackReferenceReachesTheSourceAsHeld pins GitHub #759. A $ref from +// another document back into the source reaches the source's own declaration, +// not the file read again: the finding on it is the source's alone, an overlay +// that fixes it fixes it for the $ref too, and the file on disk is never what +// answers. +func TestHold_ABackReferenceReachesTheSourceAsHeld(t *testing.T) { + t.Parallel() + root := backRoot(backPath("/a", "back.yaml#/components/responses/Back")) + dir := externalDir(t, map[string]string{ + "back.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/components/responses/R'}\n", + // The file on disk is not the source compiled: a read of it would find + // R described, and lose the source's finding. + "root.yaml": strings.Replace(root, "content: {}", "description: on disk", 1), + }) + + t.Run("the finding is the source's own", func(t *testing.T) { + t.Parallel() + got, diags := loadExternal(t, dir, root, Options{}) + + assert.Empty(t, cmp.Diff([]string{"/components/responses/R openapi/validation/validation-required-field"}, diagLines(diags)), + "reported once, by the source's own validation, and not again at the $ref") + r, ok := got.Doc.Components.Responses.Get("R") + require.True(t, ok) + assert.Same(t, r.GetObject(), response200(t, got.Doc, "/a").GetObject(), + "the $ref reaches the source's own R") + }) + t.Run("an overlay fixes it for the $ref too", func(t *testing.T) { + t.Parallel() + opts := overlayOptions(" - target: $.components.responses.R\n update: {description: patched}\n") + got, diags := loadExternal(t, dir, root, opts) + + assert.Empty(t, diags) + assert.Equal(t, "patched", response200(t, got.Doc, "/a").GetObject().GetDescription()) + }) +} + +// TestHold_AFindingOnlyValidationMakesIsTheSourcesOwn pins the other channel a +// back reference could report through. A parameter's location is checked by +// validation, not drawn when the parameter is read, so it is the validation of +// what references reach that would report it again: a trail into the source +// ends in the source, which its own validation covers. +func TestHold_AFindingOnlyValidationMakesIsTheSourcesOwn(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{ + "back.yaml": "components:\n parameters:\n Back: {$ref: 'root.yaml#/components/parameters/Q'}\n", + }) + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + parameters: [{$ref: 'back.yaml#/components/parameters/Back'}] + responses: {"200": {description: ok}} +components: + parameters: + Q: {name: q, in: sideways, schema: {type: string}} +` + _, diags := loadExternal(t, dir, root, Options{}) + + assert.Empty(t, cmp.Diff([]string{"/components/parameters/Q/in openapi/validation/validation-allowed-values"}, + diagLines(diags))) +} + +// TestHold_EverySpellingOfTheSourceReachesIt pins that a back reference reaches +// the source's own declaration however its path is spelled: one the resolver +// keys as the source's path or that path cleaned, and one it keys otherwise, +// which it opens, finds held, and reads no further. An alias, a declaration +// that is itself a $ref, is reached through to what it names. +func TestHold_EverySpellingOfTheSourceReachesIt(t *testing.T) { + t.Parallel() + dir := t.TempDir() + clean := filepath.Join(dir, "root.yaml") + for _, c := range []struct { + name, source, spelling string + }{ + {"its file name", clean, "root.yaml"}, + {"through the current directory", clean, "./root.yaml"}, + {"through its parent", clean, "../" + filepath.Base(dir) + "/root.yaml"}, + {"its absolute path", clean, clean}, + {"an absolute path not cleaned", clean, dir + "/./root.yaml"}, + // Read lexically, as a URI reference is, though no file system walks it + // (GitHub #780). + {"through a directory that does not exist", clean, dir + "/nope/../root.yaml"}, + {"its file name, from a source path not cleaned", dir + "/./root.yaml", "root.yaml"}, + } { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + name := strings.ReplaceAll(c.name, " ", "-") + ".yaml" + writeFile(t, dir, name, "components:\n responses:\n"+ + " Back: {$ref: '"+c.spelling+"#/components/responses/R'}\n"+ + " Alias: {$ref: '"+c.spelling+"#/components/responses/RA'}\n") + root := strings.Replace(backRoot(backPath("/a", name+"#/components/responses/Back")+ + backPath("/b", name+"#/components/responses/Alias")), + "components:\n responses:\n", "components:\n responses:\n RA: {$ref: '#/components/responses/R'}\n", 1) + got, diags, err := Load(t.Context(), 0, + compilers.Source{Path: c.source, Data: []byte(root)}, Options{Reader: reader.Disk{}}) + require.NoError(t, err) + require.NotNil(t, got, "%+v", diags) + + assert.Empty(t, cmp.Diff([]string{"/components/responses/R openapi/validation/validation-required-field"}, diagLines(diags))) + r, ok := got.Doc.Components.Responses.Get("R") + require.True(t, ok) + for _, path := range []string{"/a", "/b"} { + assert.Same(t, r.GetObject(), response200(t, got.Doc, path).GetObject(), + "%s reaches the source's own declaration, although no root.yaml is on disk", path) + } + }) + } +} + +// TestHold_ARelativeSourceIsReachedHoweverItIsSpelled pins GitHub #759 for a +// source named relative to the working directory, which a back reference can +// spell otherwise: by climbing out of its directory and back, or by an absolute +// path. Each reaches the source's own path item, whose references are resolved +// as the source's are, where a copy of it would hold them unresolved and the +// lowering would drop what they name. +// +// Not run in parallel: the source's path is relative to the working directory. +func TestHold_ARelativeSourceIsReachedHoweverItIsSpelled(t *testing.T) { + dir := t.TempDir() + api := filepath.Join(dir, "api") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "common"), 0o750)) + require.NoError(t, os.MkdirAll(api, 0o750)) + t.Chdir(api) + for _, c := range []struct { + name, file, spelling string + }{ + {"climbing out of its directory", "../common/x.yaml", "../api/root.yaml"}, + {"from beside it", "beside.yaml", "root.yaml"}, + {"by its absolute path", "absolute.yaml", filepath.Join(api, "root.yaml")}, + {"by an absolute path not cleaned", "unclean.yaml", api + "/./root.yaml"}, + } { + t.Run(c.name, func(t *testing.T) { + writeFile(t, api, c.file, "paths:\n /a: {$ref: '"+c.spelling+"#/components/pathItems/P'}\n") + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: {$ref: '` + c.file + `#/paths/~1a'} +components: + pathItems: + P: + get: + parameters: [{$ref: '#/components/parameters/Q'}] + responses: {"200": {$ref: '#/components/responses/R'}} + parameters: + Q: {name: q, in: query, schema: {type: string}} + responses: + R: {description: ok} +` + got, diags, err := Load(t.Context(), 0, + compilers.Source{Path: "root.yaml", Data: []byte(root)}, Options{Reader: reader.Disk{}}) + require.NoError(t, err) + require.NotNil(t, got, "%+v", diags) + + assert.Empty(t, diags) + p, ok := got.Doc.Components.PathItems.Get("P") + require.True(t, ok) + a, ok := got.Doc.Paths.Get("/a") + require.True(t, ok) + require.Same(t, p.GetObject(), a.GetObject(), "the source's own path item") + get := a.GetObject().Get() + require.NotNil(t, get) + require.Len(t, get.GetParameters(), 1) + assert.NotNil(t, get.GetParameters()[0].GetObject(), "its parameter $ref is resolved") + resp, ok := get.GetResponses().Get("200") + require.True(t, ok) + assert.NotNil(t, resp.GetObject(), "and its response $ref") + }) + } +} + +// TestHold_ABackReferenceResolvesAsItDoesInEitherOrder pins that what a back +// reference reaches does not follow declaration order: the source's own +// declaration, whether an internal $ref to it was resolved before it or after. +func TestHold_ABackReferenceResolvesAsItDoesInEitherOrder(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{ + "back.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/components/responses/R'}\n", + }) + back := backPath("/back", "back.yaml#/components/responses/Back") + internal := backPath("/internal", "#/components/responses/R") + + reports := make([][]string, 0, 2) + for _, root := range []string{backRoot(back + internal), backRoot(internal + back)} { + got, diags := loadExternal(t, dir, root, Options{}) + reports = append(reports, diagLines(diags)) + assert.Same(t, response200(t, got.Doc, "/internal").GetObject(), response200(t, got.Doc, "/back").GetObject()) + } + assert.Empty(t, cmp.Diff(reports[0], reports[1])) +} + +// TestHold_AFindingTheSourceDoesNotMakeIsReportedOnce pins what the source's +// own findings leave to the $refs. A response under an extension is no object +// the source's validation reaches, so its finding is reported for the $refs +// reaching it, an internal one and a back reference, once, at the response +// itself, as the source's own findings are placed (GitHub #879). +func TestHold_AFindingTheSourceDoesNotMakeIsReportedOnce(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{ + "back.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/x-lib/R'}\n", + }) + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: +` + backPath("/b", "back.yaml#/components/responses/Back") + backPath("/a", "#/x-lib/R") + `x-lib: + R: {content: {}} +` + _, diags := loadExternal(t, dir, root, Options{}) + + assert.Empty(t, cmp.Diff([]string{"/x-lib/R openapi/validation/validation-required-field"}, + diagLines(diags))) +} + +// TestHold_APanicStopsTheWalkWhereItStands pins that a walk which panics holds +// what it held before, and that the panic does not escape: the resolution walk +// that follows reaches the same shape and reports it. +func TestHold_APanicStopsTheWalkWhereItStands(t *testing.T) { + t.Parallel() + doc := &soa.OpenAPI{} + root := &yaml.Node{Kind: yaml.DocumentNode} + ext := newExternal(t.Context(), doc, Options{}, newExternalReads(sourceDocument{path: "root.yaml", data: []byte("x"), root: root})) + first := fakeResolvable{ref: "#/first"} + items := func(yield func(soa.WalkItem) bool) { + if !yield(fakeWalkItem("first", first)) { + return + } + panic("boom") + } + + require.NotPanics(t, func() { ext.holdWalked(items) }) + + tree, ok := doc.GetCachedExternalDocument("root.yaml") + require.True(t, ok) + assert.Same(t, root, tree) + held, ok := doc.GetCachedReferencedObject("root.yaml#/first") + require.True(t, ok, "what the walk reached before the panic is held") + assert.Equal(t, first, held) +} + +// TestHold_APanicStillHoldsTheSpellingsRecorded pins that a walk which stops +// early still holds the source under each spelling recorded, and the objects +// recorded there that it reached, where a second resolution would otherwise +// find the spelling unheld and read the file. +func TestHold_APanicStillHoldsTheSpellingsRecorded(t *testing.T) { + t.Parallel() + doc := &soa.OpenAPI{} + root := &yaml.Node{Kind: yaml.DocumentNode} + read := newExternalReads(sourceDocument{path: "root.yaml", data: []byte("x"), root: root}) + read.spelled.note("spelled.yaml", "/first") + read.spelled.note("spelled.yaml", "/second") + ext := newExternal(t.Context(), doc, Options{}, read) + items := func(yield func(soa.WalkItem) bool) { + if !yield(fakeWalkItem("first", fakeResolvable{ref: "#/first"})) { + return + } + panic("boom") + } + + require.NotPanics(t, func() { ext.holdWalked(items) }) + + _, ok := doc.GetCachedExternalDocument("spelled.yaml") + assert.True(t, ok, "the spelling's document") + _, ok = doc.GetCachedReferencedObject("spelled.yaml#/first") + assert.True(t, ok, "the object the walk reached") + _, ok = doc.GetCachedReferencedObject("spelled.yaml#/second") + assert.False(t, ok, "and not the one it did not") +} + +// TestHold_ASourceWithNoKeyIsNotHeld pins that hold stores nothing for a source +// the resolver could not look up: one with no path. +func TestHold_ASourceWithNoKeyIsNotHeld(t *testing.T) { + t.Parallel() + doc := &soa.OpenAPI{} + ext := newExternal(t.Context(), doc, Options{}, newExternalReads(sourceDocument{root: &yaml.Node{}})) + ext.holdWalked(func(func(soa.WalkItem) bool) { t.Fatal("walked for a source held under no key") }) + _, ok := doc.GetCachedExternalDocument("") + assert.False(t, ok) +} + +// TestHold_ARelativeSourceResolvesItsComponentAsItDoesInEitherOrder pins that +// the source's own component is resolved as the source resolves it when a back +// reference spelling the source otherwise reaches it first. Resolved against +// that spelling, its $ref named the file beside it otherwise, so the file was +// read twice, and a finding in it was placed by whichever spelling came first. +// +// Not run in parallel: the source's path is relative to the working directory. +func TestHold_ARelativeSourceResolvesItsComponentAsItDoesInEitherOrder(t *testing.T) { + dir := t.TempDir() + api := filepath.Join(dir, "api") + require.NoError(t, os.MkdirAll(api, 0o750)) + writeFile(t, dir, "x.yaml", "components:\n responses:\n Back: {$ref: 'api/root.yaml#/components/responses/P'}\n") + writeFile(t, api, "other.yaml", "components:\n responses:\n Q:\n description: q\n"+ + " links: {L: {operationId: getA, operationRef: '#/paths/~1a/get'}}\n") + t.Chdir(api) + back := backPath("/a", "../x.yaml#/components/responses/Back") + internal := backPath("/z", "#/components/responses/P") + root := func(paths string) string { + return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + paths + + "components:\n responses:\n P: {$ref: './other.yaml#/components/responses/Q'}\n" + } + + for _, spec := range []string{root(back + internal), root(internal + back)} { + got, diags, err := Load(t.Context(), 0, + compilers.Source{Path: "root.yaml", Data: []byte(spec)}, Options{Reader: reader.Disk{}}) + require.NoError(t, err) + require.NotNil(t, got, "%+v", diags) + require.Len(t, diags, 1, "%+v", diags) + assert.Equal(t, jsontext.Pointer("/components/responses/Q/links/L"), diags[0].Provenance.Pointer) + other, ok := got.Others.At(diags[0].Provenance.Source) + require.True(t, ok, "placed in a document read beside the source: %+v", diags[0]) + assert.Equal(t, "other.yaml", other.Label, "named as the source names it") + labels := make([]string, 0, 2) + for _, o := range got.Others.List() { + labels = append(labels, o.Label) + } + assert.Equal(t, []string{"../x.yaml", "other.yaml"}, labels, "each file read once") + } +} + +// TestHold_EachSpellingADocumentUsesIsHeldAhead pins which $refs in a document +// hold the source under their spelling, read against the key the document was +// read by: one with a document part naming the source, here an absolute path +// not cleaned, which the resolver keys as written. A spelling the source is +// held under already, a pointer alone, a $ref that is no string, another file +// and a URL hold nothing more. +func TestHold_EachSpellingADocumentUsesIsHeldAhead(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "root.yaml") + doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), path) + unclean := dir + "/./root.yaml" + tree, err := parseTree([]byte(`a: {$ref: '#/components/responses/R'} +b: {$ref: [root.yaml]} +c: {$ref: 'other.yaml#/x'} +d: {$ref: 'https://example.com/root.yaml#/x'} +e: [{$ref: '` + unclean + `#/components/responses/R'}, {$ref: '` + unclean + `'}, {$ref: '../root.yaml'}] +`)) + require.NoError(t, err) + + pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) + + assert.Equal(t, []string{unclean}, pass.reader.read.spelled.keys(), + "the spelling no key holds, and none of the source's own keys") + held, ok := doc.GetCachedReferencedObject(unclean + "#/components/responses/R") + require.True(t, ok) + r, ok := doc.Components.Responses.Get("R") + require.True(t, ok) + assert.Same(t, r, held, "the source's own object, under the spelling") + for _, key := range []string{filepath.Join(dir, "sub", "other.yaml"), "https://example.com/root.yaml"} { + _, ok := doc.GetCachedExternalDocument(key) + assert.False(t, ok, key) + } +} + +// TestHold_ASpellingTheSourceUsesForItselfIsHeldAhead pins that a $ref in the +// source naming the source's own file by a spelling no key holds, here an +// absolute path not cleaned, reaches the source's own declaration, as a $ref +// from another document spelling it so does. +func TestHold_ASpellingTheSourceUsesForItselfIsHeldAhead(t *testing.T) { + t.Parallel() + dir := t.TempDir() + got, diags := loadExternal(t, dir, backRoot(backPath("/a", dir+"/./root.yaml#/components/responses/R")), Options{}) + + assert.Empty(t, cmp.Diff([]string{"/components/responses/R openapi/validation/validation-required-field"}, diagLines(diags))) + r, ok := got.Doc.Components.Responses.Get("R") + require.True(t, ok) + assert.Same(t, r.GetObject(), response200(t, got.Doc, "/a").GetObject()) +} + +// TestHold_ASpellingHeldAlreadyIsNotHeldAgain pins that each document naming +// the source by a spelling and a pointer it is held under costs nothing more: +// holding it again would store the object again, and a new stand-in for a reference +// object, once for each such document. +func TestHold_ASpellingHeldAlreadyIsNotHeldAgain(t *testing.T) { + t.Parallel() + dir := t.TempDir() + unclean := dir + "/./root.yaml" + root := strings.Replace(backRoot(backPath("/a", "#/components/responses/RA")), + "components:\n responses:\n", "components:\n responses:\n RA: {$ref: '#/components/responses/R'}\n", 1) + doc, pass := heldResolution(t, root, filepath.Join(dir, "root.yaml")) + pass.reader.holdSite(unclean, "/components/responses/RA") + stand, ok := doc.GetCachedReferencedObject(unclean + "#/components/responses/RA") + require.True(t, ok) + + pass.reader.holdSite(unclean, "/components/responses/RA") + + again, ok := doc.GetCachedReferencedObject(unclean + "#/components/responses/RA") + require.True(t, ok) + assert.Same(t, stand, again) +} + +// TestHold_AKeyTheSourceIsHeldWholeUnderIsNotHeldAgain pins the same for the +// source's own keys, which hold every object already: a $ref spelling the +// cleaned path stores nothing over the stand-in held there. +func TestHold_AKeyTheSourceIsHeldWholeUnderIsNotHeldAgain(t *testing.T) { + t.Parallel() + dir := t.TempDir() + root := strings.Replace(backRoot(backPath("/a", "#/components/responses/RA")), + "components:\n responses:\n", "components:\n responses:\n RA: {$ref: '#/components/responses/R'}\n", 1) + doc, pass := heldResolution(t, root, dir+"/./root.yaml") + clean := filepath.Join(dir, "root.yaml") + stand, ok := doc.GetCachedReferencedObject(clean + "#/components/responses/RA") + require.True(t, ok, "a reference object is held under the cleaned path as a stand-in") + + pass.reader.holdSite(clean, "/components/responses/RA") + + again, ok := doc.GetCachedReferencedObject(clean + "#/components/responses/RA") + require.True(t, ok) + assert.Same(t, stand, again) +} + +// TestHold_SpellingsAskedForTogetherAreEachHeld pins that the source can be +// handed under several spellings at once, as a resolver reading references on +// more than one goroutine would ask, by a scan or by Open: the keys it records +// are guarded, so each spelling is held and none is lost to a concurrent write. +func TestHold_SpellingsAskedForTogetherAreEachHeld(t *testing.T) { + t.Parallel() + dir := t.TempDir() + doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) + spellings := make([]string, 0, 16) + for i := range 16 { + spellings = append(spellings, dir+strings.Repeat("/.", i+1)+"/root.yaml") + } + + var wg sync.WaitGroup + for i, spelled := range spellings { + wg.Go(func() { + if i%2 == 0 { + pass.reader.holdSite(spelled, "/components/responses/R") + return + } + pass.reader.holdOpened(spelled) + }) + } + wg.Wait() + + for _, spelled := range spellings { + _, ok := doc.GetCachedReferencedObject(spelled + "#/components/responses/R") + assert.True(t, ok, spelled) + } +} + +// TestHold_ASecondResolutionHoldsTheSpellingsTheFirstFound pins that the source +// is held, for the document a second resolution rebuilds, under each spelling +// the first found: that resolution reads a prepared document's tree without +// preparing it again, so no $ref in it is read for a spelling then. +func TestHold_ASecondResolutionHoldsTheSpellingsTheFirstFound(t *testing.T) { + t.Parallel() + dir := t.TempDir() + spelled := dir + "/./root.yaml" + doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) + pass.reader.holdSite(spelled, "/components/responses/R") + + again, _ := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) + ext := newExternal(t.Context(), again, Options{}, pass.reader.read) + ext.hold(t.Context(), Siblings{}) + + _, ok := again.GetCachedReferencedObject(spelled + "#/components/responses/R") + assert.True(t, ok, "held under the spelling the first resolution found") + _, ok = doc.GetCachedReferencedObject(spelled + "#/components/responses/R") + assert.True(t, ok) +} + +// TestHold_AReferenceNamingTheSourceCopiesNoBytesOfIt pins the cost of a $ref +// that names the source's file. The resolver reads the whole document a $ref it +// finds no held object for names, copying the bytes it holds for it. The bytes +// held beside the source's tree are none of the source's, so no read copies the +// source, and none is left holding a copy. Only reference objects are held, so +// this is the cost a response $ref pays; a schema's is the compiler's own. +func TestHold_AReferenceNamingTheSourceCopiesNoBytesOfIt(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "root.yaml") + doc, pass := heldResolution(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ + backPath("/a", "root.yaml#/components/responses/B")+ + "components:\n responses:\n B: {description: ok}\n", path) + a := response200(t, doc, "/a") + + pass.visit(t.Context(), refSite{pointer: "/paths/~1a/get/responses/200"}, a, pass.opts) + + require.Empty(t, pass.diags(sitePlacer(pointerAt(0, overlay.Origin{}), Others{}))) + assert.Equal(t, "ok", a.GetObject().Description) + cached, ok := doc.GetCachedReferenceDocument(path) + require.True(t, ok) + assert.Empty(t, cached) +} + +// TestHold_AReferenceCycleThroughTheSourceEndsAsACycle pins that a chain of +// reference objects leaving the source and coming back to where it started +// ends as the cycle it is. The resolver follows a hop it finds resolved +// without tracking it, so the held object the chain started from looped it +// until the stack ran out. This is the resolution the walk starts, before +// anything settles it. +func TestHold_AReferenceCycleThroughTheSourceEndsAsACycle(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": "A: {$ref: 'root.yaml#/components/responses/W'}\n"}) + doc, pass := heldResolution(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n"+ + "components:\n responses:\n P: {$ref: 'other.yaml#/A'}\n W: {$ref: '#/components/responses/P'}\n", + filepath.Join(dir, "root.yaml")) + p, ok := doc.Components.Responses.Get("P") + require.True(t, ok) + + _, err := p.Resolve(t.Context(), pass.opts) + + require.ErrorContains(t, err, "circular reference detected") +} + +// TestHold_ASourceComponentIsResolvedAgainstItsSelfInEitherOrder pins that the +// source's own component is resolved against the base its $self sets, as the +// source resolves it, whichever reaches it first: an internal $ref, or a $ref +// back into the source from another document. The resolver resolved what it +// had handed the back reference against the path it read the source by. +func TestHold_ASourceComponentIsResolvedAgainstItsSelfInEitherOrder(t *testing.T) { + t.Parallel() + srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("components:\n responses:\n Q: {description: by self}\n")) + assert.NoError(t, err) + }) + dir := externalDir(t, map[string]string{ + "ext.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/components/responses/P'}\n", + "other.yaml": "components:\n responses:\n Q: {description: by path}\n", + }) + root := func(paths string) string { + return "openapi: 3.2.0\n$self: " + srv.URL + "/api/root.yaml\ninfo: {title: T, version: \"1\"}\npaths:\n" + + paths + "components:\n responses:\n P: {$ref: './other.yaml#/components/responses/Q'}\n" + } + // The source's own relative $refs resolve against $self, so ext.yaml is + // named by its path. + back := backPath("/a", filepath.Join(dir, "ext.yaml")+"#/components/responses/Back") + internal := backPath("/z", "#/components/responses/P") + + reports := make([][]string, 0, 2) + for _, spec := range []string{root(back + internal), root(internal + back)} { + got, diags := loadExternal(t, dir, spec, Options{}) + reports = append(reports, diagLines(diags)) + for _, path := range []string{"/a", "/z"} { + assert.Equal(t, "by self", response200(t, got.Doc, path).GetObject().GetDescription(), path) + } + } + assert.Empty(t, cmp.Diff(reports[0], reports[1]), "the same in either order") +} + +// TestHold_AnInternalRefResolvesThroughEveryAlias pins that holding the source +// leaves an internal $ref resolving as it does when no other document is read: +// through each alias the source declares, however many. Reached as a stand-in, +// each alias would cost a resumed resolution, and a chain longer than +// maxResolutionHops would stop unresolved. +func TestHold_AnInternalRefResolvesThroughEveryAlias(t *testing.T) { + t.Parallel() + var responses strings.Builder + n := maxResolutionHops + 8 + for i := range n { + fmt.Fprintf(&responses, " R%d: {$ref: '#/components/responses/R%d'}\n", i, i+1) + } + fmt.Fprintf(&responses, " R%d: {description: ok}\n", n) + root := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + backPath("/a", "#/components/responses/R0") + "components:\n responses:\n" + responses.String() + + got, diags := loadExternal(t, t.TempDir(), root, Options{}) + + assert.Empty(t, diags) + last, ok := got.Doc.Components.Responses.Get(fmt.Sprintf("R%d", n)) + require.True(t, ok) + assert.Same(t, last.GetObject(), response200(t, got.Doc, "/a").GetObject()) +} + +// TestHold_WorkIsLinearInTheSpellings pins GitHub #772. What is stored for a +// spelling is the objects the $refs written with it name, not every object the +// source has: the resolver asks the cache for a spelling and a pointer a $ref +// writes, so the rest is never read. Counted, not timed: the entries stored +// are exactly the distinct pairs written, so doubling the $refs at most +// doubles them, where storing every object under every spelling multiplies the +// two counts. +func TestHold_WorkIsLinearInTheSpellings(t *testing.T) { + t.Parallel() + const objects = 32 + for name, shape := range map[string]func(n int) []backRef{ + "a spelling for each $ref, at a response each": func(n int) []backRef { + refs := make([]backRef, n) + for i := range refs { + refs[i] = backRef{spelling: i, response: i % objects} + } + return refs + }, + "one spelling at every response": func(n int) []backRef { + refs := make([]backRef, 0, n) + for i := range min(n, objects) { + refs = append(refs, backRef{spelling: 0, response: i}) + } + return refs + }, + "a spelling for each $ref, at one response": func(n int) []backRef { + refs := make([]backRef, n) + for i := range refs { + refs[i] = backRef{spelling: i, response: 0} + } + return refs + }, + "the same pair written again and again": func(n int) []backRef { + refs := make([]backRef, n) + for i := range refs { + refs[i] = backRef{spelling: i % 2, response: i % 2} + } + return refs + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + counted := func(n int) int { + refs := shape(n) + distinct := map[backRef]bool{} + for _, r := range refs { + distinct[r] = true + } + doc, spellings := holdBackRefs(t, objects, refs) + got := heldEntries(doc, spellings, objects) + require.Equal(t, len(distinct), got, "exactly the distinct pairs written, for %d $refs", n) + return got + } + + single, double := counted(16), counted(32) + + assert.LessOrEqual(t, double, 2*single, "doubling the $refs at most doubles what is stored") + }) + } +} + +// TestHold_OpenHoldsEveryObjectUpToTheCap pins what Open holds under a spelling +// no scanned $ref wrote: every object, for maxOpenedSpellings of them, and the +// document alone past that, where the resolver builds a copy. A name opened +// again is held already and spends nothing of the cap. +func TestHold_OpenHoldsEveryObjectUpToTheCap(t *testing.T) { + t.Parallel() + dir := t.TempDir() + doc, pass := heldResolution(t, responsesSource(4), filepath.Join(dir, "root.yaml")) + for i := range maxOpenedSpellings + 1 { + for _, name := range []string{spellingOf(dir, i), spellingOf(dir, 0)} { + f, err := pass.reader.Open(name) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + } + + for i := range maxOpenedSpellings + 1 { + key := spellingOf(dir, i) + _, ok := doc.GetCachedExternalDocument(key) + require.True(t, ok, "the document is held under spelling %d", i) + _, ok = doc.GetCachedReferencedObject(key + "#/components/responses/R0") + assert.Equal(t, i < maxOpenedSpellings, ok, "spelling %d holds the objects: %t", i, i < maxOpenedSpellings) + } +} + +// TestHold_ASecondResolutionHoldsWhatTheFirstDid pins that the record carries +// each way the first resolution held the source under a spelling to the +// second: the objects a scanned $ref names and no more, every object under a +// spelling Open held whole, and the document alone under one past the cap. +func TestHold_ASecondResolutionHoldsWhatTheFirstDid(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "root.yaml") + _, pass := heldResolution(t, responsesSource(4), path) + scanned := spellingOf(dir, 100) + tree, err := parseTree([]byte("x: {$ref: '" + scanned + "#/components/responses/R1'}\n")) + require.NoError(t, err) + pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) + schemaOnly := spellingOf(dir, 101) + tree, err = parseTree([]byte("y: {$ref: '" + schemaOnly + "#/components/schemas/S'}\n")) + require.NoError(t, err) + pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) + for i := range maxOpenedSpellings + 1 { + f, err := pass.reader.Open(spellingOf(dir, i)) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + + again, _ := heldResolution(t, responsesSource(4), path) + ext := newExternal(t.Context(), again, Options{}, pass.reader.read) + ext.hold(t.Context(), Siblings{}) + + held := func(key, response string) bool { + _, ok := again.GetCachedReferencedObject(key + "#/components/responses/" + response) + return ok + } + assert.True(t, held(scanned, "R1"), "what the scanned $ref names") + assert.False(t, held(scanned, "R2"), "and nothing more") + _, ok := again.GetCachedExternalDocument(schemaOnly) + assert.True(t, ok, "the document under a spelling whose $ref names no held object") + assert.True(t, held(spellingOf(dir, 0), "R2"), "every object under a spelling Open held whole") + last := spellingOf(dir, maxOpenedSpellings) + _, ok = again.GetCachedExternalDocument(last) + assert.True(t, ok, "the document under the spelling past the cap") + assert.False(t, held(last, "R0"), "and no object") +} + +// TestHold_EachBackReferenceReachesTheObjectItNamesThroughItsOwnSpelling pins +// that holding by reference answers every lookup holding every object did: each +// $ref in the source, spelled by a different path to the source's file, which +// is on no disk here, resolves to the source's own parameter. Without the +// source held under its spelling the resolver reads a file that is not there. +func TestHold_EachBackReferenceReachesTheObjectItNamesThroughItsOwnSpelling(t *testing.T) { + t.Parallel() + const objects, refs = 8, 24 + dir := t.TempDir() + var spec strings.Builder + spec.WriteString("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n") + for i := range refs { + fmt.Fprintf(&spec, " /p%d:\n get:\n parameters:\n - $ref: '%s#/components/parameters/P%d'\n"+ + " responses: {\"200\": {description: ok}}\n", i, spellingOf(dir, i), i%objects) + } + spec.WriteString("components:\n parameters:\n") + for i := range objects { + fmt.Fprintf(&spec, " P%d: {name: p%d, in: query, schema: {type: string}}\n", i, i) + } + + got, diags, err := Load(t.Context(), 0, + compilers.Source{Path: filepath.Join(dir, "root.yaml"), Data: []byte(spec.String())}, Options{Reader: reader.Disk{}}) + + require.NoError(t, err) + require.NotNil(t, got, "%+v", diags) + assert.Empty(t, diags) + for i := range refs { + item, ok := got.Doc.Paths.Get(fmt.Sprintf("/p%d", i)) + require.True(t, ok) + params := item.GetObject().Get().GetParameters() + require.Len(t, params, 1) + named, ok := got.Doc.Components.Parameters.Get(fmt.Sprintf("P%d", i%objects)) + require.True(t, ok) + assert.Same(t, named.GetObject(), params[0].GetObject(), "$ref %d reaches the source's own parameter", i) + } +} + +// TestHold_AnAliasedRefIsHeldAsAPlainOneIs pins that a $ref written with a YAML +// alias, for its value or for its key, is read as the model reads it: its +// spelling of the source is held by reference like any other, so it reaches the +// source's own parameter and not a copy of it, which the resolver builds, +// validates and holds unresolved wherever the scan did not hold the object ahead. +func TestHold_AnAliasedRefIsHeldAsAPlainOneIs(t *testing.T) { + t.Parallel() + for _, c := range []struct{ name, anchor, ref string }{ + {"an alias as the value", "x-r: &r '%s#/components/parameters/P0'\n", "{$ref: *r}"}, + {"an alias as the key", "x-k: &k '$ref'\n", "{*k : '%s#/components/parameters/P0'}"}, + } { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + spelled := spellingOf(dir, 0) + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + strings.ReplaceAll(c.anchor, "%s", spelled) + + "paths:\n /a: {get: {parameters: [" + strings.ReplaceAll(c.ref, "%s", spelled) + "], responses: {\"200\": {description: ok}}}}\n" + + "components:\n parameters:\n P0: {name: p0, in: query, schema: {type: string}}\n" + + got, diags := loadExternal(t, dir, spec, Options{}) + + assert.Empty(t, diags) + item, ok := got.Doc.Paths.Get("/a") + require.True(t, ok) + named, ok := got.Doc.Components.Parameters.Get("P0") + require.True(t, ok) + params := item.GetObject().Get().GetParameters() + require.Len(t, params, 1) + assert.Same(t, named.GetObject(), params[0].GetObject(), "the source's own parameter, not a copy of it") + }) + } +} + +// heldEntries counts the pairs of a spelling and a response the source's +// response is held under in doc's cache. +func heldEntries(doc *soa.OpenAPI, spellings []string, objects int) int { + n := 0 + for _, s := range spellings { + for j := range objects { + if _, ok := doc.GetCachedReferencedObject(fmt.Sprintf("%s#/components/responses/R%d", s, j)); ok { + n++ + } + } + } + return n +} + +// holdBackRefs holds a source of objects responses for each $ref in refs, as +// the scan of a document that writes them does, and returns the cache the +// resolver would look them up in, with the spellings used. +func holdBackRefs(t *testing.T, objects int, refs []backRef) (*soa.OpenAPI, []string) { + t.Helper() + dir := t.TempDir() + doc, pass := heldResolution(t, responsesSource(objects), filepath.Join(dir, "root.yaml")) + var text strings.Builder + spellings := map[int]string{} + for i, r := range refs { + spellings[r.spelling] = spellingOf(dir, r.spelling) + fmt.Fprintf(&text, "x%d: {$ref: '%s#/components/responses/R%d'}\n", i, spellings[r.spelling], r.response) + } + tree, err := parseTree([]byte(text.String())) + require.NoError(t, err) + + pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) + + return doc, slices.Collect(maps.Values(spellings)) +} + +// responsesSource is a source with count component responses, R0 to R. +// Every fourth is a $ref to R0, a reference object, which is held under a +// spelling no key holds as a stand-in. +func responsesSource(count int) string { + var b strings.Builder + b.WriteString("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n responses:\n") + for i := range count { + if i%4 == 3 { + fmt.Fprintf(&b, " R%d: {$ref: '#/components/responses/R0'}\n", i) + continue + } + fmt.Fprintf(&b, " R%d: {description: ok}\n", i) + } + return b.String() +} + +// spellingOf returns the i-th spelling of the source at dir/root.yaml that no +// key holds: dir, then i+1 current-directory steps. Each names the file the +// source is, and each differs from the others, as an absolute path is kept as +// written. +func spellingOf(dir string, i int) string { + return dir + strings.Repeat("/.", i+1) + "/root.yaml" +} + +// backRef is a $ref spelled by spelling number spelling, naming response number +// response. +type backRef struct{ spelling, response int } diff --git a/compilers/openapi/internal/load/helpers_internal_test.go b/compilers/openapi/internal/load/helpers_internal_test.go index ea3a4e0c..dc5ab2dc 100644 --- a/compilers/openapi/internal/load/helpers_internal_test.go +++ b/compilers/openapi/internal/load/helpers_internal_test.go @@ -1,6 +1,7 @@ package load import ( + "slices" "testing" soa "github.com/speakeasy-api/openapi/openapi" @@ -78,3 +79,24 @@ func countErrorsAt(diags []ir.Diagnostic, code string) int { } return n } + +// diagLines renders diags as "pointer code", sorted, which is what an order +// comparison and an expectation both need. +func diagLines(diags []ir.Diagnostic) []string { + out := make([]string, 0, len(diags)) + for _, d := range diags { + out = append(out, string(d.Provenance.Pointer)+" "+d.Code) + } + slices.Sort(out) + return out +} + +// response200 returns the 200 response the get operation at path names. +func response200(t *testing.T, doc *soa.OpenAPI, path string) *soa.ReferencedResponse { + t.Helper() + item, ok := doc.Paths.Get(path) + require.True(t, ok, path) + resp, ok := item.GetObject().Get().GetResponses().Get("200") + require.True(t, ok, path) + return resp +} diff --git a/compilers/openapi/internal/load/models_internal_test.go b/compilers/openapi/internal/load/models_internal_test.go new file mode 100644 index 00000000..c869819c --- /dev/null +++ b/compilers/openapi/internal/load/models_internal_test.go @@ -0,0 +1,16 @@ +package load + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestOwnRoot_AModelWithNoReadableCoreHasNone pins ownRoot's two guards: a $ref +// whose own mapping cannot be read has none to answer with, rather than the +// target's. +func TestOwnRoot_AModelWithNoReadableCoreHasNone(t *testing.T) { + t.Parallel() + assert.Nil(t, ownRoot(bareReference{})) + assert.Nil(t, ownRoot(coredReference{core: 42})) +} diff --git a/compilers/openapi/internal/load/others_internal_test.go b/compilers/openapi/internal/load/others_internal_test.go index e1fd7ea4..71a631c3 100644 --- a/compilers/openapi/internal/load/others_internal_test.go +++ b/compilers/openapi/internal/load/others_internal_test.go @@ -12,9 +12,7 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/openapi/internal/bind" "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/ir" ) // servedReader answers every request with one document declaring X. @@ -148,23 +146,3 @@ func TestLabelOf_IsTheLocationTheSourcesDirectoryReachesADocumentBy(t *testing.T assert.Equal(t, tc.want, labelOf(tc.self, tc.key, tc.abs), "%s from %s", tc.key, tc.self) } } - -// TestPlacer_PlacesAFindingInTheDocumentThatHoldsIt pins where a finding at a -// position of the bindings' table is placed: the source's at its own -// Provenance, another document's in that document's Document.Sources entry, -// and one in a document no entry lists, which a load never produces, at the -// source's root rather than nowhere. -func TestPlacer_PlacesAFindingInTheDocumentThatHoldsIt(t *testing.T) { - t.Parallel() - doc, codes := loadReferencing(t, "./ext.yaml#/X") - require.Empty(t, codes) - at := func(p jsontext.Pointer) ir.Provenance { return ir.Provenance{Pointer: "/at" + p} } - ext := bind.Position{Doc: 1, Pointer: "/X"} - - listed := bound{bindings: doc.Bindings, others: doc.Others}.placer(at) - assert.Equal(t, ir.Provenance{Pointer: "/at/components"}, listed(bind.Position{Pointer: "/components"})) - assert.Equal(t, ir.Provenance{Source: 1, Pointer: "/X"}, listed(ext)) - - unlisted := bound{bindings: doc.Bindings}.placer(at) - assert.Equal(t, ir.Provenance{Pointer: "/at"}, unlisted(ext)) -} diff --git a/compilers/openapi/internal/load/reached_internal_test.go b/compilers/openapi/internal/load/reached_internal_test.go index 12df4e1d..fbf8c7a2 100644 --- a/compilers/openapi/internal/load/reached_internal_test.go +++ b/compilers/openapi/internal/load/reached_internal_test.go @@ -196,40 +196,6 @@ func TestTargetOf(t *testing.T) { assert.False(t, ok, "/stops reaches no object") } -// TestBindSchemas_AChainEndsInTheDocumentItEndsIn is TestTargetOf for schema -// $refs, which the bindings read: each chain ends in the document its last -// hop lands in, not the one it first leaves for, and a schema the model does -// not hold there is built for the landing, a boolean one included, and lends -// its use site that schema, whichever documents the chain passes. -func TestBindSchemas_AChainEndsInTheDocumentItEndsIn(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": reachedFixtureOther, "third.yaml": reachedFixtureThird}) - got, _ := loadExternal(t, dir, reachedFixtureRoot, Options{}) - doc, bindings := got.Doc, got.Bindings - table := bindings.Table() - for name, want := range map[string]struct { - doc string - pointer jsontext.Pointer - }{ - "S": {filepath.Join(dir, "other.yaml"), "/components/schemas/Obj"}, - "Bool": {filepath.Join(dir, "other.yaml"), "/components/schemas/AnyBool"}, - "Chained": {filepath.Join(dir, "third.yaml"), "/components/schemas/Leaf"}, - "ToS": {filepath.Join(dir, "other.yaml"), "/components/schemas/Obj"}, - "ToLocal": {filepath.Join(dir, "root.yaml"), "/components/schemas/Local"}, - } { - js, ok := doc.Components.Schemas.Get(name) - require.True(t, ok, name) - e, ok := bindings.Entry(js) - require.True(t, ok, name) - require.Equal(t, bind.Resolved, e.Chain.Status, name) - assert.Equal(t, want.doc, table.Document(e.Chain.End.Doc).Name, name) - assert.Equal(t, want.pointer, e.Chain.End.Pointer, name) - held := bindings.At(e.Chain.End) - assert.NotNil(t, held, "%s ends on a schema held for it", name) - assert.Same(t, held, bindings.End(js), name) - } -} - // TestBuiltTargets covers which schemas built for the bindings' landings are // validated, and where: one a $ref's chain reaches, at the least such $ref and // named by its document, in the source or not, placed in the document writing diff --git a/compilers/openapi/internal/load/source_internal_test.go b/compilers/openapi/internal/load/source_internal_test.go index 32bfab89..7f1e41ef 100644 --- a/compilers/openapi/internal/load/source_internal_test.go +++ b/compilers/openapi/internal/load/source_internal_test.go @@ -2,24 +2,17 @@ package load import ( "encoding/json/jsontext" - "fmt" "io" "io/fs" - "net/http" - "os" "path/filepath" - "slices" - "strings" - "sync" "testing" - "github.com/google/go-cmp/cmp" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" - "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/oas" "github.com/dexpace/morphic/compilers/openapi/internal/overlay" "github.com/dexpace/morphic/engine/reader" "github.com/dexpace/morphic/ir" @@ -44,306 +37,6 @@ func backPath(path, ref string) string { return " " + path + ":\n get:\n responses:\n \"200\": {$ref: '" + ref + "'}\n" } -// response200 returns the 200 response the get operation at path names. -func response200(t *testing.T, doc *soa.OpenAPI, path string) *soa.ReferencedResponse { - t.Helper() - item, ok := doc.Paths.Get(path) - require.True(t, ok, path) - resp, ok := item.GetObject().Get().GetResponses().Get("200") - require.True(t, ok, path) - return resp -} - -// diagLines renders diags as "pointer code", sorted, which is what an order -// comparison and an expectation both need. -func diagLines(diags []ir.Diagnostic) []string { - out := make([]string, 0, len(diags)) - for _, d := range diags { - out = append(out, string(d.Provenance.Pointer)+" "+d.Code) - } - slices.Sort(out) - return out -} - -// TestHold_ABackReferenceReachesTheSourceAsHeld pins GitHub #759. A $ref from -// another document back into the source reaches the source's own declaration, -// not the file read again: the finding on it is the source's alone, an overlay -// that fixes it fixes it for the $ref too, and the file on disk is never what -// answers. -func TestHold_ABackReferenceReachesTheSourceAsHeld(t *testing.T) { - t.Parallel() - root := backRoot(backPath("/a", "back.yaml#/components/responses/Back")) - dir := externalDir(t, map[string]string{ - "back.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/components/responses/R'}\n", - // The file on disk is not the source compiled: a read of it would find - // R described, and lose the source's finding. - "root.yaml": strings.Replace(root, "content: {}", "description: on disk", 1), - }) - - t.Run("the finding is the source's own", func(t *testing.T) { - t.Parallel() - got, diags := loadExternal(t, dir, root, Options{}) - - assert.Empty(t, cmp.Diff([]string{"/components/responses/R openapi/validation/validation-required-field"}, diagLines(diags)), - "reported once, by the source's own validation, and not again at the $ref") - r, ok := got.Doc.Components.Responses.Get("R") - require.True(t, ok) - assert.Same(t, r.GetObject(), response200(t, got.Doc, "/a").GetObject(), - "the $ref reaches the source's own R") - }) - t.Run("an overlay fixes it for the $ref too", func(t *testing.T) { - t.Parallel() - opts := overlayOptions(" - target: $.components.responses.R\n update: {description: patched}\n") - got, diags := loadExternal(t, dir, root, opts) - - assert.Empty(t, diags) - assert.Equal(t, "patched", response200(t, got.Doc, "/a").GetObject().GetDescription()) - }) -} - -// TestHold_AFindingOnlyValidationMakesIsTheSourcesOwn pins the other channel a -// back reference could report through. A parameter's location is checked by -// validation, not drawn when the parameter is read, so it is the validation of -// what references reach that would report it again: a trail into the source -// ends in the source, which its own validation covers. -func TestHold_AFindingOnlyValidationMakesIsTheSourcesOwn(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{ - "back.yaml": "components:\n parameters:\n Back: {$ref: 'root.yaml#/components/parameters/Q'}\n", - }) - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - get: - parameters: [{$ref: 'back.yaml#/components/parameters/Back'}] - responses: {"200": {description: ok}} -components: - parameters: - Q: {name: q, in: sideways, schema: {type: string}} -` - _, diags := loadExternal(t, dir, root, Options{}) - - assert.Empty(t, cmp.Diff([]string{"/components/parameters/Q/in openapi/validation/validation-allowed-values"}, - diagLines(diags))) -} - -// TestHold_EverySpellingOfTheSourceReachesIt pins that a back reference reaches -// the source's own declaration however its path is spelled: one the resolver -// keys as the source's path or that path cleaned, and one it keys otherwise, -// which it opens, finds held, and reads no further. An alias, a declaration -// that is itself a $ref, is reached through to what it names. -func TestHold_EverySpellingOfTheSourceReachesIt(t *testing.T) { - t.Parallel() - dir := t.TempDir() - clean := filepath.Join(dir, "root.yaml") - for _, c := range []struct { - name, source, spelling string - }{ - {"its file name", clean, "root.yaml"}, - {"through the current directory", clean, "./root.yaml"}, - {"through its parent", clean, "../" + filepath.Base(dir) + "/root.yaml"}, - {"its absolute path", clean, clean}, - {"an absolute path not cleaned", clean, dir + "/./root.yaml"}, - // Read lexically, as a URI reference is, though no file system walks it - // (GitHub #780). - {"through a directory that does not exist", clean, dir + "/nope/../root.yaml"}, - {"its file name, from a source path not cleaned", dir + "/./root.yaml", "root.yaml"}, - } { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - name := strings.ReplaceAll(c.name, " ", "-") + ".yaml" - writeFile(t, dir, name, "components:\n responses:\n"+ - " Back: {$ref: '"+c.spelling+"#/components/responses/R'}\n"+ - " Alias: {$ref: '"+c.spelling+"#/components/responses/RA'}\n") - root := strings.Replace(backRoot(backPath("/a", name+"#/components/responses/Back")+ - backPath("/b", name+"#/components/responses/Alias")), - "components:\n responses:\n", "components:\n responses:\n RA: {$ref: '#/components/responses/R'}\n", 1) - got, diags, err := Load(t.Context(), 0, - compilers.Source{Path: c.source, Data: []byte(root)}, Options{Reader: reader.Disk{}}) - require.NoError(t, err) - require.NotNil(t, got, "%+v", diags) - - assert.Empty(t, cmp.Diff([]string{"/components/responses/R openapi/validation/validation-required-field"}, diagLines(diags))) - r, ok := got.Doc.Components.Responses.Get("R") - require.True(t, ok) - for _, path := range []string{"/a", "/b"} { - assert.Same(t, r.GetObject(), response200(t, got.Doc, path).GetObject(), - "%s reaches the source's own declaration, although no root.yaml is on disk", path) - } - }) - } -} - -// TestHold_ARelativeSourceIsReachedHoweverItIsSpelled pins GitHub #759 for a -// source named relative to the working directory, which a back reference can -// spell otherwise: by climbing out of its directory and back, or by an absolute -// path. Each reaches the source's own path item, whose references are resolved -// as the source's are, where a copy of it would hold them unresolved and the -// lowering would drop what they name. -// -// Not run in parallel: the source's path is relative to the working directory. -func TestHold_ARelativeSourceIsReachedHoweverItIsSpelled(t *testing.T) { - dir := t.TempDir() - api := filepath.Join(dir, "api") - require.NoError(t, os.MkdirAll(filepath.Join(dir, "common"), 0o750)) - require.NoError(t, os.MkdirAll(api, 0o750)) - t.Chdir(api) - for _, c := range []struct { - name, file, spelling string - }{ - {"climbing out of its directory", "../common/x.yaml", "../api/root.yaml"}, - {"from beside it", "beside.yaml", "root.yaml"}, - {"by its absolute path", "absolute.yaml", filepath.Join(api, "root.yaml")}, - {"by an absolute path not cleaned", "unclean.yaml", api + "/./root.yaml"}, - } { - t.Run(c.name, func(t *testing.T) { - writeFile(t, api, c.file, "paths:\n /a: {$ref: '"+c.spelling+"#/components/pathItems/P'}\n") - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: {$ref: '` + c.file + `#/paths/~1a'} -components: - pathItems: - P: - get: - parameters: [{$ref: '#/components/parameters/Q'}] - responses: {"200": {$ref: '#/components/responses/R'}} - parameters: - Q: {name: q, in: query, schema: {type: string}} - responses: - R: {description: ok} -` - got, diags, err := Load(t.Context(), 0, - compilers.Source{Path: "root.yaml", Data: []byte(root)}, Options{Reader: reader.Disk{}}) - require.NoError(t, err) - require.NotNil(t, got, "%+v", diags) - - assert.Empty(t, diags) - p, ok := got.Doc.Components.PathItems.Get("P") - require.True(t, ok) - a, ok := got.Doc.Paths.Get("/a") - require.True(t, ok) - require.Same(t, p.GetObject(), a.GetObject(), "the source's own path item") - get := a.GetObject().Get() - require.NotNil(t, get) - require.Len(t, get.GetParameters(), 1) - assert.NotNil(t, get.GetParameters()[0].GetObject(), "its parameter $ref is resolved") - resp, ok := get.GetResponses().Get("200") - require.True(t, ok) - assert.NotNil(t, resp.GetObject(), "and its response $ref") - }) - } -} - -// TestHold_ABackReferenceResolvesAsItDoesInEitherOrder pins that what a back -// reference reaches does not follow declaration order: the source's own -// declaration, whether an internal $ref to it was resolved before it or after. -func TestHold_ABackReferenceResolvesAsItDoesInEitherOrder(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{ - "back.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/components/responses/R'}\n", - }) - back := backPath("/back", "back.yaml#/components/responses/Back") - internal := backPath("/internal", "#/components/responses/R") - - reports := make([][]string, 0, 2) - for _, root := range []string{backRoot(back + internal), backRoot(internal + back)} { - got, diags := loadExternal(t, dir, root, Options{}) - reports = append(reports, diagLines(diags)) - assert.Same(t, response200(t, got.Doc, "/internal").GetObject(), response200(t, got.Doc, "/back").GetObject()) - } - assert.Empty(t, cmp.Diff(reports[0], reports[1])) -} - -// TestHold_AFindingTheSourceDoesNotMakeIsReportedOnce pins what the source's -// own findings leave to the $refs. A response under an extension is no object -// the source's validation reaches, so its finding is reported for the $refs -// reaching it, an internal one and a back reference, once, at the response -// itself, as the source's own findings are placed (GitHub #879). -func TestHold_AFindingTheSourceDoesNotMakeIsReportedOnce(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{ - "back.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/x-lib/R'}\n", - }) - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: -` + backPath("/b", "back.yaml#/components/responses/Back") + backPath("/a", "#/x-lib/R") + `x-lib: - R: {content: {}} -` - _, diags := loadExternal(t, dir, root, Options{}) - - assert.Empty(t, cmp.Diff([]string{"/x-lib/R openapi/validation/validation-required-field"}, - diagLines(diags))) -} - -// TestHold_APanicStopsTheWalkWhereItStands pins that a walk which panics holds -// what it held before, and that the panic does not escape: the resolution walk -// that follows reaches the same shape and reports it. -func TestHold_APanicStopsTheWalkWhereItStands(t *testing.T) { - t.Parallel() - doc := &soa.OpenAPI{} - root := &yaml.Node{Kind: yaml.DocumentNode} - ext := newExternal(t.Context(), doc, Options{}, newExternalReads(sourceDocument{path: "root.yaml", data: []byte("x"), root: root})) - first := fakeResolvable{ref: "#/first"} - items := func(yield func(soa.WalkItem) bool) { - if !yield(fakeWalkItem("first", first)) { - return - } - panic("boom") - } - - require.NotPanics(t, func() { ext.holdWalked(items) }) - - tree, ok := doc.GetCachedExternalDocument("root.yaml") - require.True(t, ok) - assert.Same(t, root, tree) - held, ok := doc.GetCachedReferencedObject("root.yaml#/first") - require.True(t, ok, "what the walk reached before the panic is held") - assert.Equal(t, first, held) -} - -// TestHold_APanicStillHoldsTheSpellingsRecorded pins that a walk which stops -// early still holds the source under each spelling recorded, and the objects -// recorded there that it reached, where a second resolution would otherwise -// find the spelling unheld and read the file. -func TestHold_APanicStillHoldsTheSpellingsRecorded(t *testing.T) { - t.Parallel() - doc := &soa.OpenAPI{} - root := &yaml.Node{Kind: yaml.DocumentNode} - read := newExternalReads(sourceDocument{path: "root.yaml", data: []byte("x"), root: root}) - read.spelled.note("spelled.yaml", "/first") - read.spelled.note("spelled.yaml", "/second") - ext := newExternal(t.Context(), doc, Options{}, read) - items := func(yield func(soa.WalkItem) bool) { - if !yield(fakeWalkItem("first", fakeResolvable{ref: "#/first"})) { - return - } - panic("boom") - } - - require.NotPanics(t, func() { ext.holdWalked(items) }) - - _, ok := doc.GetCachedExternalDocument("spelled.yaml") - assert.True(t, ok, "the spelling's document") - _, ok = doc.GetCachedReferencedObject("spelled.yaml#/first") - assert.True(t, ok, "the object the walk reached") - _, ok = doc.GetCachedReferencedObject("spelled.yaml#/second") - assert.False(t, ok, "and not the one it did not") -} - -// TestHold_ASourceWithNoKeyIsNotHeld pins that hold stores nothing for a source -// the resolver could not look up: one with no path. -func TestHold_ASourceWithNoKeyIsNotHeld(t *testing.T) { - t.Parallel() - doc := &soa.OpenAPI{} - ext := newExternal(t.Context(), doc, Options{}, newExternalReads(sourceDocument{root: &yaml.Node{}})) - ext.holdWalked(func(func(soa.WalkItem) bool) { t.Fatal("walked for a source held under no key") }) - _, ok := doc.GetCachedExternalDocument("") - assert.False(t, ok) -} - // TestSourceDocument_Keys pins the keys the source is held under. func TestSourceDocument_Keys(t *testing.T) { t.Parallel() @@ -393,46 +86,6 @@ func TestSourceDocument_Names(t *testing.T) { } } -// TestHold_ARelativeSourceResolvesItsComponentAsItDoesInEitherOrder pins that -// the source's own component is resolved as the source resolves it when a back -// reference spelling the source otherwise reaches it first. Resolved against -// that spelling, its $ref named the file beside it otherwise, so the file was -// read twice, and a finding in it was placed by whichever spelling came first. -// -// Not run in parallel: the source's path is relative to the working directory. -func TestHold_ARelativeSourceResolvesItsComponentAsItDoesInEitherOrder(t *testing.T) { - dir := t.TempDir() - api := filepath.Join(dir, "api") - require.NoError(t, os.MkdirAll(api, 0o750)) - writeFile(t, dir, "x.yaml", "components:\n responses:\n Back: {$ref: 'api/root.yaml#/components/responses/P'}\n") - writeFile(t, api, "other.yaml", "components:\n responses:\n Q:\n description: q\n"+ - " links: {L: {operationId: getA, operationRef: '#/paths/~1a/get'}}\n") - t.Chdir(api) - back := backPath("/a", "../x.yaml#/components/responses/Back") - internal := backPath("/z", "#/components/responses/P") - root := func(paths string) string { - return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + paths + - "components:\n responses:\n P: {$ref: './other.yaml#/components/responses/Q'}\n" - } - - for _, spec := range []string{root(back + internal), root(internal + back)} { - got, diags, err := Load(t.Context(), 0, - compilers.Source{Path: "root.yaml", Data: []byte(spec)}, Options{Reader: reader.Disk{}}) - require.NoError(t, err) - require.NotNil(t, got, "%+v", diags) - require.Len(t, diags, 1, "%+v", diags) - assert.Equal(t, jsontext.Pointer("/components/responses/Q/links/L"), diags[0].Provenance.Pointer) - other, ok := got.Others.At(diags[0].Provenance.Source) - require.True(t, ok, "placed in a document read beside the source: %+v", diags[0]) - assert.Equal(t, "other.yaml", other.Label, "named as the source names it") - labels := make([]string, 0, 2) - for _, o := range got.Others.List() { - labels = append(labels, o.Label) - } - assert.Equal(t, []string{"../x.yaml", "other.yaml"}, labels, "each file read once") - } -} - // heldResolution returns spec's model, built as the source at path, and the // resolution newResolution makes of it with external references allowed, which // has held the source where the resolver looks for it. @@ -479,273 +132,24 @@ func TestExternal_OpenHoldsTheSourceItIsAskedFor(t *testing.T) { assert.Same(t, r, held, "the source's own object, a response being no reference") } -// TestHold_EachSpellingADocumentUsesIsHeldAhead pins which $refs in a document -// hold the source under their spelling, read against the key the document was -// read by: one with a document part naming the source, here an absolute path -// not cleaned, which the resolver keys as written. A spelling the source is -// held under already, a pointer alone, a $ref that is no string, another file -// and a URL hold nothing more. -func TestHold_EachSpellingADocumentUsesIsHeldAhead(t *testing.T) { +// TestSourceTree_LocatorsAnswerAtTheLastNodeTheyReach pins that a pointer +// leading nowhere is placed at the last node it reached, and that a tree +// holding no node places every finding at the reference that reached it. +func TestSourceTree_LocatorsAnswerAtTheLastNodeTheyReach(t *testing.T) { t.Parallel() - dir := t.TempDir() - path := filepath.Join(dir, "root.yaml") - doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), path) - unclean := dir + "/./root.yaml" - tree, err := parseTree([]byte(`a: {$ref: '#/components/responses/R'} -b: {$ref: [root.yaml]} -c: {$ref: 'other.yaml#/x'} -d: {$ref: 'https://example.com/root.yaml#/x'} -e: [{$ref: '` + unclean + `#/components/responses/R'}, {$ref: '` + unclean + `'}, {$ref: '../root.yaml'}] -`)) - require.NoError(t, err) - - pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) - - assert.Equal(t, []string{unclean}, pass.reader.read.spelled.keys(), - "the spelling no key holds, and none of the source's own keys") - held, ok := doc.GetCachedReferencedObject(unclean + "#/components/responses/R") - require.True(t, ok) - r, ok := doc.Components.Responses.Get("R") - require.True(t, ok) - assert.Same(t, r, held, "the source's own object, under the spelling") - for _, key := range []string{filepath.Join(dir, "sub", "other.yaml"), "https://example.com/root.yaml"} { - _, ok := doc.GetCachedExternalDocument(key) - assert.False(t, ok, key) - } -} - -// TestHold_ASpellingTheSourceUsesForItselfIsHeldAhead pins that a $ref in the -// source naming the source's own file by a spelling no key holds, here an -// absolute path not cleaned, reaches the source's own declaration, as a $ref -// from another document spelling it so does. -func TestHold_ASpellingTheSourceUsesForItselfIsHeldAhead(t *testing.T) { - t.Parallel() - dir := t.TempDir() - got, diags := loadExternal(t, dir, backRoot(backPath("/a", dir+"/./root.yaml#/components/responses/R")), Options{}) - - assert.Empty(t, cmp.Diff([]string{"/components/responses/R openapi/validation/validation-required-field"}, diagLines(diags))) - r, ok := got.Doc.Components.Responses.Get("R") - require.True(t, ok) - assert.Same(t, r.GetObject(), response200(t, got.Doc, "/a").GetObject()) -} - -// TestHold_ASpellingHeldAlreadyIsNotHeldAgain pins that each document naming -// the source by a spelling and a pointer it is held under costs nothing more: -// holding it again would store the object again, and a new stand-in for a reference -// object, once for each such document. -func TestHold_ASpellingHeldAlreadyIsNotHeldAgain(t *testing.T) { - t.Parallel() - dir := t.TempDir() - unclean := dir + "/./root.yaml" - root := strings.Replace(backRoot(backPath("/a", "#/components/responses/RA")), - "components:\n responses:\n", "components:\n responses:\n RA: {$ref: '#/components/responses/R'}\n", 1) - doc, pass := heldResolution(t, root, filepath.Join(dir, "root.yaml")) - pass.reader.holdSite(unclean, "/components/responses/RA") - stand, ok := doc.GetCachedReferencedObject(unclean + "#/components/responses/RA") - require.True(t, ok) - - pass.reader.holdSite(unclean, "/components/responses/RA") - - again, ok := doc.GetCachedReferencedObject(unclean + "#/components/responses/RA") - require.True(t, ok) - assert.Same(t, stand, again) -} - -// TestHold_AKeyTheSourceIsHeldWholeUnderIsNotHeldAgain pins the same for the -// source's own keys, which hold every object already: a $ref spelling the -// cleaned path stores nothing over the stand-in held there. -func TestHold_AKeyTheSourceIsHeldWholeUnderIsNotHeldAgain(t *testing.T) { - t.Parallel() - dir := t.TempDir() - root := strings.Replace(backRoot(backPath("/a", "#/components/responses/RA")), - "components:\n responses:\n", "components:\n responses:\n RA: {$ref: '#/components/responses/R'}\n", 1) - doc, pass := heldResolution(t, root, dir+"/./root.yaml") - clean := filepath.Join(dir, "root.yaml") - stand, ok := doc.GetCachedReferencedObject(clean + "#/components/responses/RA") - require.True(t, ok, "a reference object is held under the cleaned path as a stand-in") - - pass.reader.holdSite(clean, "/components/responses/RA") - - again, ok := doc.GetCachedReferencedObject(clean + "#/components/responses/RA") - require.True(t, ok) - assert.Same(t, stand, again) -} - -// TestHold_SpellingsAskedForTogetherAreEachHeld pins that the source can be -// handed under several spellings at once, as a resolver reading references on -// more than one goroutine would ask, by a scan or by Open: the keys it records -// are guarded, so each spelling is held and none is lost to a concurrent write. -func TestHold_SpellingsAskedForTogetherAreEachHeld(t *testing.T) { - t.Parallel() - dir := t.TempDir() - doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) - spellings := make([]string, 0, 16) - for i := range 16 { - spellings = append(spellings, dir+strings.Repeat("/.", i+1)+"/root.yaml") - } - - var wg sync.WaitGroup - for i, spelled := range spellings { - wg.Go(func() { - if i%2 == 0 { - pass.reader.holdSite(spelled, "/components/responses/R") - return - } - pass.reader.holdOpened(spelled) - }) - } - wg.Wait() + root := decodeRoot(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n") + tree := newSourceTree(0, overlay.Origin{}, root) - for _, spelled := range spellings { - _, ok := doc.GetCachedReferencedObject(spelled + "#/components/responses/R") - assert.True(t, ok, spelled) + for _, locate := range []oas.Locator{tree.locatorIn(root), tree.reachedLocator(root)} { + got := locate("/info/nowhere/at/all") + assert.Equal(t, jsontext.Pointer("/info"), got.Pointer, "placed at /info, the last node the pointer reached") } -} - -// TestHold_ASecondResolutionHoldsTheSpellingsTheFirstFound pins that the source -// is held, for the document a second resolution rebuilds, under each spelling -// the first found: that resolution reads a prepared document's tree without -// preparing it again, so no $ref in it is read for a spelling then. -func TestHold_ASecondResolutionHoldsTheSpellingsTheFirstFound(t *testing.T) { - t.Parallel() - dir := t.TempDir() - spelled := dir + "/./root.yaml" - doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) - pass.reader.holdSite(spelled, "/components/responses/R") - - again, _ := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) - ext := newExternal(t.Context(), again, Options{}, pass.reader.read) - ext.hold(t.Context(), Siblings{}) - - _, ok := again.GetCachedReferencedObject(spelled + "#/components/responses/R") - assert.True(t, ok, "held under the spelling the first resolution found") - _, ok = doc.GetCachedReferencedObject(spelled + "#/components/responses/R") - assert.True(t, ok) -} - -// TestHold_AReferenceNamingTheSourceCopiesNoBytesOfIt pins the cost of a $ref -// that names the source's file. The resolver reads the whole document a $ref it -// finds no held object for names, copying the bytes it holds for it. The bytes -// held beside the source's tree are none of the source's, so no read copies the -// source, and none is left holding a copy. Only reference objects are held, so -// this is the cost a response $ref pays; a schema's is the compiler's own. -func TestHold_AReferenceNamingTheSourceCopiesNoBytesOfIt(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "root.yaml") - doc, pass := heldResolution(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ - backPath("/a", "root.yaml#/components/responses/B")+ - "components:\n responses:\n B: {description: ok}\n", path) - a := response200(t, doc, "/a") - - pass.visit(t.Context(), refSite{pointer: "/paths/~1a/get/responses/200"}, a, pass.opts) - - require.Empty(t, pass.diags(sitePlacer(pointerAt(0, overlay.Origin{}), Others{}))) - assert.Equal(t, "ok", a.GetObject().Description) - cached, ok := doc.GetCachedReferenceDocument(path) - require.True(t, ok) - assert.Empty(t, cached) -} - -// TestHold_AReferenceCycleThroughTheSourceEndsAsACycle pins that a chain of -// reference objects leaving the source and coming back to where it started -// ends as the cycle it is. The resolver follows a hop it finds resolved -// without tracking it, so the held object the chain started from looped it -// until the stack ran out. This is the resolution the walk starts, before -// anything settles it. -func TestHold_AReferenceCycleThroughTheSourceEndsAsACycle(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": "A: {$ref: 'root.yaml#/components/responses/W'}\n"}) - doc, pass := heldResolution(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n"+ - "components:\n responses:\n P: {$ref: 'other.yaml#/A'}\n W: {$ref: '#/components/responses/P'}\n", - filepath.Join(dir, "root.yaml")) - p, ok := doc.Components.Responses.Get("P") - require.True(t, ok) - - _, err := p.Resolve(t.Context(), pass.opts) + assert.Equal(t, ir.Provenance{Source: 0}, tree.locatorIn(&yaml.Node{Kind: yaml.DocumentNode})("/x"), "an empty tree is placed by source alone") - require.ErrorContains(t, err, "circular reference detected") -} - -// TestHold_ASourceComponentIsResolvedAgainstItsSelfInEitherOrder pins that the -// source's own component is resolved against the base its $self sets, as the -// source resolves it, whichever reaches it first: an internal $ref, or a $ref -// back into the source from another document. The resolver resolved what it -// had handed the back reference against the path it read the source by. -func TestHold_ASourceComponentIsResolvedAgainstItsSelfInEitherOrder(t *testing.T) { - t.Parallel() - srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte("components:\n responses:\n Q: {description: by self}\n")) - assert.NoError(t, err) - }) - dir := externalDir(t, map[string]string{ - "ext.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/components/responses/P'}\n", - "other.yaml": "components:\n responses:\n Q: {description: by path}\n", - }) - root := func(paths string) string { - return "openapi: 3.2.0\n$self: " + srv.URL + "/api/root.yaml\ninfo: {title: T, version: \"1\"}\npaths:\n" + - paths + "components:\n responses:\n P: {$ref: './other.yaml#/components/responses/Q'}\n" - } - // The source's own relative $refs resolve against $self, so ext.yaml is - // named by its path. - back := backPath("/a", filepath.Join(dir, "ext.yaml")+"#/components/responses/Back") - internal := backPath("/z", "#/components/responses/P") - - reports := make([][]string, 0, 2) - for _, spec := range []string{root(back + internal), root(internal + back)} { - got, diags := loadExternal(t, dir, spec, Options{}) - reports = append(reports, diagLines(diags)) - for _, path := range []string{"/a", "/z"} { - assert.Equal(t, "by self", response200(t, got.Doc, path).GetObject().GetDescription(), path) - } - } - assert.Empty(t, cmp.Diff(reports[0], reports[1]), "the same in either order") -} - -// TestHold_AnInternalRefResolvesThroughEveryAlias pins that holding the source -// leaves an internal $ref resolving as it does when no other document is read: -// through each alias the source declares, however many. Reached as a stand-in, -// each alias would cost a resumed resolution, and a chain longer than -// maxResolutionHops would stop unresolved. -func TestHold_AnInternalRefResolvesThroughEveryAlias(t *testing.T) { - t.Parallel() - var responses strings.Builder - n := maxResolutionHops + 8 - for i := range n { - fmt.Fprintf(&responses, " R%d: {$ref: '#/components/responses/R%d'}\n", i, i+1) - } - fmt.Fprintf(&responses, " R%d: {description: ok}\n", n) - root := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - backPath("/a", "#/components/responses/R0") + "components:\n responses:\n" + responses.String() - - got, diags := loadExternal(t, t.TempDir(), root, Options{}) - - assert.Empty(t, diags) - last, ok := got.Doc.Components.Responses.Get(fmt.Sprintf("R%d", n)) - require.True(t, ok) - assert.Same(t, last.GetObject(), response200(t, got.Doc, "/a").GetObject()) -} - -// TestStandIn pins the stand-in held for each kind of reference object: an -// empty object of the kind, which mend knows to stand for the object. A -// resolvable of no known kind has none. -func TestStandIn(t *testing.T) { - t.Parallel() - read := newExternalReads(sourceDocument{}) - for _, obj := range []resolvable{ - &soa.ReferencedPathItem{}, &soa.ReferencedParameter{}, &soa.ReferencedHeader{}, - &soa.ReferencedRequestBody{}, &soa.ReferencedResponse{}, &soa.ReferencedExample{}, - &soa.ReferencedLink{}, &soa.ReferencedCallback{}, &soa.ReferencedSecurityScheme{}, - } { - stand, ok := read.standIn(obj) - require.True(t, ok, "%T", obj) - assert.IsType(t, obj, stand) - assert.NotSame(t, obj, stand) - stood, ok := read.stoodFor(stand) - require.True(t, ok, "%T", obj) - assert.Same(t, obj, stood) - } - _, ok := read.standIn(fakeResolvable{}) - assert.False(t, ok, "a kind of no known stand-in is held as none") - _, ok = read.stoodFor(&soa.ReferencedResponse{}) - assert.False(t, ok, "an object made elsewhere stands for nothing") + assert.False(t, sourceTree{}.has(root), "a tree that holds nothing holds no node") + assert.True(t, tree.has(root.Content[0]), "and one holds the nodes it indexed") + foreign := sourceTree{}.reachedLocator(root)("/info") + assert.Equal(t, ir.Provenance{Source: foreign.Source, Position: foreign.Position}, foreign) + assert.Equal(t, 2, foreign.Position.Line) + assert.False(t, holds(ir.Diagnostic{Provenance: foreign}), "marked foreign") } diff --git a/compilers/openapi/internal/load/spellings_internal_test.go b/compilers/openapi/internal/load/spellings_internal_test.go deleted file mode 100644 index f497dfea..00000000 --- a/compilers/openapi/internal/load/spellings_internal_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package load - -import ( - "fmt" - "maps" - "path/filepath" - "slices" - "strings" - "testing" - - soa "github.com/speakeasy-api/openapi/openapi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/engine/reader" -) - -// responsesSource is a source with count component responses, R0 to R. -// Every fourth is a $ref to R0, a reference object, which is held under a -// spelling no key holds as a stand-in. -func responsesSource(count int) string { - var b strings.Builder - b.WriteString("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n responses:\n") - for i := range count { - if i%4 == 3 { - fmt.Fprintf(&b, " R%d: {$ref: '#/components/responses/R0'}\n", i) - continue - } - fmt.Fprintf(&b, " R%d: {description: ok}\n", i) - } - return b.String() -} - -// spellingOf returns the i-th spelling of the source at dir/root.yaml that no -// key holds: dir, then i+1 current-directory steps. Each names the file the -// source is, and each differs from the others, as an absolute path is kept as -// written. -func spellingOf(dir string, i int) string { - return dir + strings.Repeat("/.", i+1) + "/root.yaml" -} - -// backRef is a $ref spelled by spelling number spelling, naming response number -// response. -type backRef struct{ spelling, response int } - -// holdBackRefs holds a source of objects responses for each $ref in refs, as -// the scan of a document that writes them does, and returns the cache the -// resolver would look them up in, with the spellings used. -func holdBackRefs(t *testing.T, objects int, refs []backRef) (*soa.OpenAPI, []string) { - t.Helper() - dir := t.TempDir() - doc, pass := heldResolution(t, responsesSource(objects), filepath.Join(dir, "root.yaml")) - var text strings.Builder - spellings := map[int]string{} - for i, r := range refs { - spellings[r.spelling] = spellingOf(dir, r.spelling) - fmt.Fprintf(&text, "x%d: {$ref: '%s#/components/responses/R%d'}\n", i, spellings[r.spelling], r.response) - } - tree, err := parseTree([]byte(text.String())) - require.NoError(t, err) - - pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) - - return doc, slices.Collect(maps.Values(spellings)) -} - -// heldEntries counts the pairs of a spelling and a response the source's -// response is held under in doc's cache. -func heldEntries(doc *soa.OpenAPI, spellings []string, objects int) int { - n := 0 - for _, s := range spellings { - for j := range objects { - if _, ok := doc.GetCachedReferencedObject(fmt.Sprintf("%s#/components/responses/R%d", s, j)); ok { - n++ - } - } - } - return n -} - -// TestHold_WorkIsLinearInTheSpellings pins GitHub #772. What is stored for a -// spelling is the objects the $refs written with it name, not every object the -// source has: the resolver asks the cache for a spelling and a pointer a $ref -// writes, so the rest is never read. Counted, not timed: the entries stored -// are exactly the distinct pairs written, so doubling the $refs at most -// doubles them, where storing every object under every spelling multiplies the -// two counts. -func TestHold_WorkIsLinearInTheSpellings(t *testing.T) { - t.Parallel() - const objects = 32 - for name, shape := range map[string]func(n int) []backRef{ - "a spelling for each $ref, at a response each": func(n int) []backRef { - refs := make([]backRef, n) - for i := range refs { - refs[i] = backRef{spelling: i, response: i % objects} - } - return refs - }, - "one spelling at every response": func(n int) []backRef { - refs := make([]backRef, 0, n) - for i := range min(n, objects) { - refs = append(refs, backRef{spelling: 0, response: i}) - } - return refs - }, - "a spelling for each $ref, at one response": func(n int) []backRef { - refs := make([]backRef, n) - for i := range refs { - refs[i] = backRef{spelling: i, response: 0} - } - return refs - }, - "the same pair written again and again": func(n int) []backRef { - refs := make([]backRef, n) - for i := range refs { - refs[i] = backRef{spelling: i % 2, response: i % 2} - } - return refs - }, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - counted := func(n int) int { - refs := shape(n) - distinct := map[backRef]bool{} - for _, r := range refs { - distinct[r] = true - } - doc, spellings := holdBackRefs(t, objects, refs) - got := heldEntries(doc, spellings, objects) - require.Equal(t, len(distinct), got, "exactly the distinct pairs written, for %d $refs", n) - return got - } - - single, double := counted(16), counted(32) - - assert.LessOrEqual(t, double, 2*single, "doubling the $refs at most doubles what is stored") - }) - } -} - -// TestHold_OpenHoldsEveryObjectUpToTheCap pins what Open holds under a spelling -// no scanned $ref wrote: every object, for maxOpenedSpellings of them, and the -// document alone past that, where the resolver builds a copy. A name opened -// again is held already and spends nothing of the cap. -func TestHold_OpenHoldsEveryObjectUpToTheCap(t *testing.T) { - t.Parallel() - dir := t.TempDir() - doc, pass := heldResolution(t, responsesSource(4), filepath.Join(dir, "root.yaml")) - for i := range maxOpenedSpellings + 1 { - for _, name := range []string{spellingOf(dir, i), spellingOf(dir, 0)} { - f, err := pass.reader.Open(name) - require.NoError(t, err) - require.NoError(t, f.Close()) - } - } - - for i := range maxOpenedSpellings + 1 { - key := spellingOf(dir, i) - _, ok := doc.GetCachedExternalDocument(key) - require.True(t, ok, "the document is held under spelling %d", i) - _, ok = doc.GetCachedReferencedObject(key + "#/components/responses/R0") - assert.Equal(t, i < maxOpenedSpellings, ok, "spelling %d holds the objects: %t", i, i < maxOpenedSpellings) - } -} - -// TestHold_ASecondResolutionHoldsWhatTheFirstDid pins that the record carries -// each way the first resolution held the source under a spelling to the -// second: the objects a scanned $ref names and no more, every object under a -// spelling Open held whole, and the document alone under one past the cap. -func TestHold_ASecondResolutionHoldsWhatTheFirstDid(t *testing.T) { - t.Parallel() - dir := t.TempDir() - path := filepath.Join(dir, "root.yaml") - _, pass := heldResolution(t, responsesSource(4), path) - scanned := spellingOf(dir, 100) - tree, err := parseTree([]byte("x: {$ref: '" + scanned + "#/components/responses/R1'}\n")) - require.NoError(t, err) - pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) - schemaOnly := spellingOf(dir, 101) - tree, err = parseTree([]byte("y: {$ref: '" + schemaOnly + "#/components/schemas/S'}\n")) - require.NoError(t, err) - pass.reader.holdSpellings(tree, filepath.Join(dir, "sub", "doc.yaml")) - for i := range maxOpenedSpellings + 1 { - f, err := pass.reader.Open(spellingOf(dir, i)) - require.NoError(t, err) - require.NoError(t, f.Close()) - } - - again, _ := heldResolution(t, responsesSource(4), path) - ext := newExternal(t.Context(), again, Options{}, pass.reader.read) - ext.hold(t.Context(), Siblings{}) - - held := func(key, response string) bool { - _, ok := again.GetCachedReferencedObject(key + "#/components/responses/" + response) - return ok - } - assert.True(t, held(scanned, "R1"), "what the scanned $ref names") - assert.False(t, held(scanned, "R2"), "and nothing more") - _, ok := again.GetCachedExternalDocument(schemaOnly) - assert.True(t, ok, "the document under a spelling whose $ref names no held object") - assert.True(t, held(spellingOf(dir, 0), "R2"), "every object under a spelling Open held whole") - last := spellingOf(dir, maxOpenedSpellings) - _, ok = again.GetCachedExternalDocument(last) - assert.True(t, ok, "the document under the spelling past the cap") - assert.False(t, held(last, "R0"), "and no object") -} - -// TestHold_EachBackReferenceReachesTheObjectItNamesThroughItsOwnSpelling pins -// that holding by reference answers every lookup holding every object did: each -// $ref in the source, spelled by a different path to the source's file, which -// is on no disk here, resolves to the source's own parameter. Without the -// source held under its spelling the resolver reads a file that is not there. -func TestHold_EachBackReferenceReachesTheObjectItNamesThroughItsOwnSpelling(t *testing.T) { - t.Parallel() - const objects, refs = 8, 24 - dir := t.TempDir() - var spec strings.Builder - spec.WriteString("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n") - for i := range refs { - fmt.Fprintf(&spec, " /p%d:\n get:\n parameters:\n - $ref: '%s#/components/parameters/P%d'\n"+ - " responses: {\"200\": {description: ok}}\n", i, spellingOf(dir, i), i%objects) - } - spec.WriteString("components:\n parameters:\n") - for i := range objects { - fmt.Fprintf(&spec, " P%d: {name: p%d, in: query, schema: {type: string}}\n", i, i) - } - - got, diags, err := Load(t.Context(), 0, - compilers.Source{Path: filepath.Join(dir, "root.yaml"), Data: []byte(spec.String())}, Options{Reader: reader.Disk{}}) - - require.NoError(t, err) - require.NotNil(t, got, "%+v", diags) - assert.Empty(t, diags) - for i := range refs { - item, ok := got.Doc.Paths.Get(fmt.Sprintf("/p%d", i)) - require.True(t, ok) - params := item.GetObject().Get().GetParameters() - require.Len(t, params, 1) - named, ok := got.Doc.Components.Parameters.Get(fmt.Sprintf("P%d", i%objects)) - require.True(t, ok) - assert.Same(t, named.GetObject(), params[0].GetObject(), "$ref %d reaches the source's own parameter", i) - } -} - -// TestHold_AnAliasedRefIsHeldAsAPlainOneIs pins that a $ref written with a YAML -// alias, for its value or for its key, is read as the model reads it: its -// spelling of the source is held by reference like any other, so it reaches the -// source's own parameter and not a copy of it, which the resolver builds, -// validates and holds unresolved wherever the scan did not hold the object ahead. -func TestHold_AnAliasedRefIsHeldAsAPlainOneIs(t *testing.T) { - t.Parallel() - for _, c := range []struct{ name, anchor, ref string }{ - {"an alias as the value", "x-r: &r '%s#/components/parameters/P0'\n", "{$ref: *r}"}, - {"an alias as the key", "x-k: &k '$ref'\n", "{*k : '%s#/components/parameters/P0'}"}, - } { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - spelled := spellingOf(dir, 0) - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + strings.ReplaceAll(c.anchor, "%s", spelled) + - "paths:\n /a: {get: {parameters: [" + strings.ReplaceAll(c.ref, "%s", spelled) + "], responses: {\"200\": {description: ok}}}}\n" + - "components:\n parameters:\n P0: {name: p0, in: query, schema: {type: string}}\n" - - got, diags := loadExternal(t, dir, spec, Options{}) - - assert.Empty(t, diags) - item, ok := got.Doc.Paths.Get("/a") - require.True(t, ok) - named, ok := got.Doc.Components.Parameters.Get("P0") - require.True(t, ok) - params := item.GetObject().Get().GetParameters() - require.Len(t, params, 1) - assert.Same(t, named.GetObject(), params[0].GetObject(), "the source's own parameter, not a copy of it") - }) - } -} diff --git a/compilers/openapi/internal/load/unprepared_internal_test.go b/compilers/openapi/internal/load/unprepared_internal_test.go index 8ca56dde..51a28c96 100644 --- a/compilers/openapi/internal/load/unprepared_internal_test.go +++ b/compilers/openapi/internal/load/unprepared_internal_test.go @@ -1606,3 +1606,28 @@ func TestChain_EndsOnNothing(t *testing.T) { }) } } + +// TestStandIn pins the stand-in held for each kind of reference object: an +// empty object of the kind, which mend knows to stand for the object. A +// resolvable of no known kind has none. +func TestStandIn(t *testing.T) { + t.Parallel() + read := newExternalReads(sourceDocument{}) + for _, obj := range []resolvable{ + &soa.ReferencedPathItem{}, &soa.ReferencedParameter{}, &soa.ReferencedHeader{}, + &soa.ReferencedRequestBody{}, &soa.ReferencedResponse{}, &soa.ReferencedExample{}, + &soa.ReferencedLink{}, &soa.ReferencedCallback{}, &soa.ReferencedSecurityScheme{}, + } { + stand, ok := read.standIn(obj) + require.True(t, ok, "%T", obj) + assert.IsType(t, obj, stand) + assert.NotSame(t, obj, stand) + stood, ok := read.stoodFor(stand) + require.True(t, ok, "%T", obj) + assert.Same(t, obj, stood) + } + _, ok := read.standIn(fakeResolvable{}) + assert.False(t, ok, "a kind of no known stand-in is held as none") + _, ok = read.stoodFor(&soa.ReferencedResponse{}) + assert.False(t, ok, "an object made elsewhere stands for nothing") +} diff --git a/compilers/openapi/internal/load/validate_internal_test.go b/compilers/openapi/internal/load/validate_internal_test.go index d8dba64c..efa9a87d 100644 --- a/compilers/openapi/internal/load/validate_internal_test.go +++ b/compilers/openapi/internal/load/validate_internal_test.go @@ -145,28 +145,6 @@ func TestJudgeSource_AnOverlayOwnsWhatItWrote(t *testing.T) { "the missing version is the source's to answer for, the description the overlay's") } -// TestSourceTree_LocatorsAnswerAtTheLastNodeTheyReach pins that a pointer -// leading nowhere is placed at the last node it reached, and that a tree -// holding no node places every finding at the reference that reached it. -func TestSourceTree_LocatorsAnswerAtTheLastNodeTheyReach(t *testing.T) { - t.Parallel() - root := decodeRoot(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n") - tree := newSourceTree(0, overlay.Origin{}, root) - - for _, locate := range []oas.Locator{tree.locatorIn(root), tree.reachedLocator(root)} { - got := locate("/info/nowhere/at/all") - assert.Equal(t, jsontext.Pointer("/info"), got.Pointer, "placed at /info, the last node the pointer reached") - } - assert.Equal(t, ir.Provenance{Source: 0}, tree.locatorIn(&yaml.Node{Kind: yaml.DocumentNode})("/x"), "an empty tree is placed by source alone") - - assert.False(t, sourceTree{}.has(root), "a tree that holds nothing holds no node") - assert.True(t, tree.has(root.Content[0]), "and one holds the nodes it indexed") - foreign := sourceTree{}.reachedLocator(root)("/info") - assert.Equal(t, ir.Provenance{Source: foreign.Source, Position: foreign.Position}, foreign) - assert.Equal(t, 2, foreign.Position.Line) - assert.False(t, holds(ir.Diagnostic{Provenance: foreign}), "marked foreign") -} - // TestPlaceAt_PutsForeignFindingsAtTheReference pins where a finding about an // object a reference reaches is reported: one placed in a document stays where // it is, and one marked foreign goes to the reference with the place it lies diff --git a/compilers/openapi/internal/merge/admits_internal_test.go b/compilers/openapi/internal/merge/admits_internal_test.go index a4eb71a3..e803209f 100644 --- a/compilers/openapi/internal/merge/admits_internal_test.go +++ b/compilers/openapi/internal/merge/admits_internal_test.go @@ -5,7 +5,6 @@ import ( "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" ) @@ -209,50 +208,3 @@ func TestAdmits_ReadsAHugeExponentWithoutMaterializingIt(t *testing.T) { } assert.Less(t, time.Since(start), 5*time.Second) } - -func TestTypeJudge_NullIsJudgedByNullability(t *testing.T) { - t.Parallel() - null := ir.Value{Kind: ir.ValueNull} - j := typeJudge{Resolve: stubResolve(narrowingRegistry())} - nullable := func(k ir.PrimKind) ir.TypeRef { r := primRef(k); r.Nullable = true; return r } - - assert.True(t, j.admits(nullable(ir.PrimInteger), null)) - assert.False(t, j.admits(primRef(ir.PrimInteger), null)) - assert.False(t, j.admits(nullable(ir.PrimInteger), str("x"))) - assert.True(t, j.admits(primRef(ir.PrimInteger), num("1"))) - assert.True(t, j.admits(primRef(ir.PrimAny), null), "the top type admits null whatever the bit says") -} - -// TestFold_ExamplesAreJudgedAsOneKeyword pins that a broader declaration's -// examples are adopted all or nothing: one example the narrower type refuses -// refuses the keyword, and an example with no value says nothing a type could -// refuse. -func TestFold_ExamplesAreJudgedAsOneKeyword(t *testing.T) { - t.Parallel() - one, half := num("1"), num("1.5") - for _, tc := range []struct { - name string - examples []ir.Example - refused bool - }{ - {"no examples say nothing", nil, false}, - {"an example with no value says nothing", []ir.Example{{Value: &one}, {Summary: "no value"}}, false}, - {"one refused example refuses the keyword", []ir.Example{{Value: &one}, {Value: &half}}, true}, - } { - g, recorded := stubMerger(narrowingRegistry()) - broad := ir.Property{WireName: "p", Type: primRef(ir.PrimNumber), Examples: tc.examples, Provenance: ptrAt("/b")} - narrow := ir.Property{WireName: "p", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/a")} - - got := foldTwo(g, narrow, broad, written(`{"examples":[1]}`)) - - if !tc.refused { - assert.Equal(t, tc.examples, got.Examples, "%s: adopted whole", tc.name) - assert.Empty(t, *recorded, tc.name) - continue - } - assert.Nil(t, got.Examples, "%s: refused whole", tc.name) - require.Len(t, *recorded, 1, tc.name) - assert.Contains(t, (*recorded)[0].Message, "does not admit the examples", tc.name) - assert.Contains(t, got.Unmodeled, losingDeclarationKey+"/b", "%s: the writer is kept", tc.name) - } -} diff --git a/compilers/openapi/internal/merge/constraints_internal_test.go b/compilers/openapi/internal/merge/constraints_internal_test.go index 9dbe947a..740e3260 100644 --- a/compilers/openapi/internal/merge/constraints_internal_test.go +++ b/compilers/openapi/internal/merge/constraints_internal_test.go @@ -23,3 +23,56 @@ func TestMerger_MergeConstraintsKeepsBothSidesBounds(t *testing.T) { assert.Equal(t, int64(10), *got.MaxLength) assert.Equal(t, int64(2), *got.MinLength, "a bound only the second side declares survives the merge") } + +// TestBigValConflictDetail_ComparesByMagnitude pins the comparison every +// arbitrary-precision keyword goes through. Equal magnitudes spelled +// differently must not read as a conflict — 10 and 10.0 are one value, and +// reporting them would invent a disagreement the source never wrote — while +// differing magnitudes must, since the merge keeps one and drops the other. +// +// The keyword is a parameter, so the name in the message is the one the caller +// passed: the four bounds and multipleOf share this helper, and a hard-coded +// name would report every one of them as the same keyword. +func TestBigValConflictDetail_ComparesByMagnitude(t *testing.T) { + t.Parallel() + tests := []struct { + name string + keyword string + a, b *ir.BigVal + want string + }{ + {name: "only one side bounds", keyword: "minimum", a: bigVal("10")}, + {name: "neither side bounds", keyword: "minimum"}, + { + name: "the same magnitude, spelled differently", + keyword: "minimum", a: bigVal("10"), b: bigVal("10.0"), + }, + { + name: "differing magnitudes", keyword: "minimum", a: bigVal("10"), b: bigVal("20"), + want: "conflicting minimum (10 and 20)", + }, + { + name: "the exclusive bound reports under its own keyword", + keyword: "exclusiveMinimum", a: bigVal("10"), b: bigVal("20"), + want: "conflicting exclusiveMinimum (10 and 20)", + }, + { + name: "multipleOf shares the comparison", + keyword: "multipleOf", a: bigVal("3"), b: bigVal("5"), + want: "conflicting multipleOf (3 and 5)", + }, + { + name: "unparseable operands compare exactly", + keyword: "minimum", a: bigVal("nan"), b: bigVal("other"), + want: "conflicting minimum (nan and other)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + detail, ok := bigValConflictDetail(tc.keyword, tc.a, tc.b) + assert.Equal(t, tc.want != "", ok) + assert.Equal(t, tc.want, detail) + }) + } +} diff --git a/compilers/openapi/internal/merge/fold_cases_internal_test.go b/compilers/openapi/internal/merge/fold_cases_internal_test.go index 0f59a945..21fb5121 100644 --- a/compilers/openapi/internal/merge/fold_cases_internal_test.go +++ b/compilers/openapi/internal/merge/fold_cases_internal_test.go @@ -187,116 +187,6 @@ var ( writeOnlyVisibility = ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleCreate, ir.LifecycleUpdate}} ) -// TestMergeVisibility_IntersectsUnderAllOfSemantics pins the shapes an allOf -// redeclaration's Visibility pairing can take. An empty Only means -// unrestricted, not restricted-to-nothing (ir-design §5.2), so each side must -// be read as the set it admits before intersecting. -// -// The first three cases cannot catch a plain append, since concatenating an -// empty Only is a no-op. The five below them do: appending duplicates when both -// branches agree, unions where allOf intersects, and cannot spell the empty -// set. -func TestMergeVisibility_IntersectsUnderAllOfSemantics(t *testing.T) { - t.Parallel() - unrestricted := ir.Visibility{} - invisible := ir.Visibility{None: true} - - tests := []struct { - name string - dst, src ir.Visibility - want ir.Visibility - }{ - { - name: "neither branch restricts visibility", - dst: unrestricted, src: unrestricted, - want: unrestricted, - }, - { - // The issue's own reproduction: a first branch that leaves the - // field unrestricted must not shadow a later branch's readOnly. - name: "only the redeclaration restricts visibility", - dst: unrestricted, src: readOnlyVisibility, - want: readOnlyVisibility, - }, - { - name: "only the first declaration restricts visibility", - dst: readOnlyVisibility, src: unrestricted, - want: readOnlyVisibility, - }, - { - name: "both branches agree on the same restriction", - dst: readOnlyVisibility, src: readOnlyVisibility, - want: readOnlyVisibility, - }, - { - // readOnly admits {read, delete, query}; writeOnly admits - // {create, update} — disjoint sets whose intersection is empty, - // meaning the field can satisfy no lifecycle both branches - // admit. None is the IR's existing shape for "visible in no - // lifecycle" (TypeSpec @invisible), so the empty intersection is - // recorded exactly rather than read as unrestricted. - name: "disjoint restrictions intersect to invisible", - dst: readOnlyVisibility, src: writeOnlyVisibility, - want: invisible, - }, - { - // Neither side is unrestricted and neither subsumes the other, so - // the answer is a proper subset of both — the one shape the fast - // paths above cannot produce, and the only case that pins the - // intersection itself rather than which operand survives whole. - // EffectiveVisibility restricts to two fixed sets that are either - // identical or disjoint, so no OpenAPI document reaches this pairing - // today; it is the helper's contract a second visibility source - // would inherit. - name: "partially overlapping restrictions keep only the shared lifecycles", - dst: readOnlyVisibility, src: ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleQuery}}, - want: ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleQuery}}, - }, - { - name: "None on the first declaration dominates", - dst: invisible, src: readOnlyVisibility, - want: invisible, - }, - { - name: "None on the redeclaration dominates", - dst: readOnlyVisibility, src: invisible, - want: invisible, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, mergeVisibility(tc.dst, tc.src), - "mergeVisibility(dst, src) for %q", tc.name) - assert.Equal(t, tc.want, mergeVisibility(tc.src, tc.dst), - "mergeVisibility(src, dst) must agree — allOf branch order is not semantic, for %q", tc.name) - }) - } -} - -// TestMergeVisibility_ReorderedBranchesAgree pins the one way two branches can -// disagree that the table above cannot express: they admit lifecycles that -// overlap, but list them in different orders. Every case in the table names its -// operands in one order, so a merge that read the surviving order off dst alone -// would satisfy all of them and still answer two different slices here. -// -// The exact order the merge settles on is not pinned — a set has no preferred -// spelling — only that both branch orders reach the same one, which is what -// keeps the composition's spelling out of the IR. -func TestMergeVisibility_ReorderedBranchesAgree(t *testing.T) { - t.Parallel() - first := readOnlyVisibility - second := ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleQuery, ir.LifecycleRead}} - - forward := mergeVisibility(first, second) - reversed := mergeVisibility(second, first) - - assert.Equal(t, forward, reversed, "allOf branch order must not reach the merged Only") - assert.ElementsMatch(t, []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleQuery}, forward.Only, - "the merge is still the intersection, whichever order it is spelled in") - assert.False(t, forward.None, "a non-empty intersection is a restriction, not invisibility") -} - // TestFold_VisibilityAdoptedFromRedeclaration pins the issue's // own reproduction at the reconcile layer (GitHub #34): a property left // unrestricted by its first declaration must adopt a later branch's readOnly @@ -394,59 +284,6 @@ func TestFold_AConstraintDisagreementIsReported(t *testing.T) { "and so is the discarded one") } -// TestBigValConflictDetail_ComparesByMagnitude pins the comparison every -// arbitrary-precision keyword goes through. Equal magnitudes spelled -// differently must not read as a conflict — 10 and 10.0 are one value, and -// reporting them would invent a disagreement the source never wrote — while -// differing magnitudes must, since the merge keeps one and drops the other. -// -// The keyword is a parameter, so the name in the message is the one the caller -// passed: the four bounds and multipleOf share this helper, and a hard-coded -// name would report every one of them as the same keyword. -func TestBigValConflictDetail_ComparesByMagnitude(t *testing.T) { - t.Parallel() - tests := []struct { - name string - keyword string - a, b *ir.BigVal - want string - }{ - {name: "only one side bounds", keyword: "minimum", a: bigVal("10")}, - {name: "neither side bounds", keyword: "minimum"}, - { - name: "the same magnitude, spelled differently", - keyword: "minimum", a: bigVal("10"), b: bigVal("10.0"), - }, - { - name: "differing magnitudes", keyword: "minimum", a: bigVal("10"), b: bigVal("20"), - want: "conflicting minimum (10 and 20)", - }, - { - name: "the exclusive bound reports under its own keyword", - keyword: "exclusiveMinimum", a: bigVal("10"), b: bigVal("20"), - want: "conflicting exclusiveMinimum (10 and 20)", - }, - { - name: "multipleOf shares the comparison", - keyword: "multipleOf", a: bigVal("3"), b: bigVal("5"), - want: "conflicting multipleOf (3 and 5)", - }, - { - name: "unparseable operands compare exactly", - keyword: "minimum", a: bigVal("nan"), b: bigVal("other"), - want: "conflicting minimum (nan and other)", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - detail, ok := bigValConflictDetail(tc.keyword, tc.a, tc.b) - assert.Equal(t, tc.want != "", ok) - assert.Equal(t, tc.want, detail) - }) - } -} - // TestResolvePrimKind_EnumResolvesThroughItsValueType pins the enum case of the // resolution walk. An enum member can itself be a scalar of the kind being // redeclared, so it must answer with its value type rather than staying @@ -470,142 +307,6 @@ func TestResolvePrimKind_EnumResolvesThroughItsValueType(t *testing.T) { assert.Equal(t, ir.PrimString, kind) } -// TestClassify_ComparesResolvedKinds pins the last-resort comparison classify -// makes when neither side ends in a primitive kind. Two composites of the same -// kind are not provably contradictory; two of different kinds are disjoint. -func TestClassify_ComparesResolvedKinds(t *testing.T) { - t.Parallel() - g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/model": &ir.Model{ID: "t/model"}, - "t/other": &ir.Model{ID: "t/other"}, - "t/union": &ir.Union{ID: "t/union"}, - "t/opaque": &ir.Scalar{ID: "t/opaque"}, - }) - - assert.Equal(t, relUnrelated, judge(g).relate(ir.TypeRef{Target: "t/model"}, ir.TypeRef{Target: "t/other"}), - "two models are the same kind, so not provably contradictory") - assert.Equal(t, relDisjoint, judge(g).relate(ir.TypeRef{Target: "t/model"}, ir.TypeRef{Target: "t/union"}), - "a model and a union are different kinds") - assert.Equal(t, relDisjoint, judge(g).relate(ir.TypeRef{Target: "t/opaque"}, ir.TypeRef{Target: "t/union"}), - "and the comparison is reached when neither side is a primitive") -} - -// TestKeepLosingDeclaration_RecordsTheDiscardedDeclaration pins what the fix for -// GitHub #424 adds: the redeclaration whose type loses is kept verbatim beside -// the winner, so a consumer reading the document — not the diagnostic stream — -// still sees what the second declaration said. -// -// The reference is asserted whole rather than by its target alone: Nullable is -// half of what a redeclaration says about a field, and an entry that kept only -// the ID would lose it while still looking like a preservation. -func TestKeepLosingDeclaration_RecordsTheDiscardedDeclaration(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, - }) - dst := ir.Property{ - WireName: "id", - Type: ir.TypeRef{Target: "t/str"}, - Provenance: ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/0/properties/id"}, - } - src := ir.Property{ - WireName: "id", - Type: ir.TypeRef{Target: "t/int", Nullable: true}, - Provenance: ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/1/properties/id"}, - } - - dst = foldTwo(g, dst, src, written(`{"type":"integer","nullable":true}`)) - - require.Len(t, *recorded, 1, "the conflict is still diagnosed") - assert.Equal(t, ir.TypeID("t/str"), dst.Type.Target, "the first declaration still wins the shape") - - key := "openapi:conflicting-redeclaration/components/schemas/S/allOf/1/properties/id" - entry, ok := dst.Unmodeled[key] - require.True(t, ok, "the losing declaration is kept under a pointer-namespaced key; got %v", dst.Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, `{"type":"integer","nullable":true}`, string(entry.Value), - "the declaration is kept as the document wrote it, nullability included") - assert.Equal(t, ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/1/properties/id"}, - entry.Provenance, "the entry locates the losing declaration, not the merged property") -} - -// TestKeepLosingDeclaration_EveryLoserSurvivesItsSiblings pins the key's namespacing. -// A field three branches type three incompatible ways discards two -// declarations, and a fixed key would leave only whichever branch ran last — -// the same silent overwrite the entry exists to prevent. -func TestKeepLosingDeclaration_EveryLoserSurvivesItsSiblings(t *testing.T) { - t.Parallel() - reg := map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, - "t/bool": &ir.Primitive{ID: "t/bool", Prim: ir.PrimBool}, - } - g, _ := stubMerger(reg) - m := &ir.Model{} - idx, settle := indexOver(m, reg, g) - - idx.MergeProperty(ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}, Provenance: ptrAt("/a")}, unread(t)) - idx.MergeProperty(ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/int"}, Provenance: ptrAt("/b")}, written(`{"type":"integer"}`)) - idx.MergeProperty(ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/bool"}, Provenance: ptrAt("/c")}, written(`{"type":"boolean"}`)) - settle() - - require.Len(t, m.Properties, 1, "three declarations still reconcile to one property") - assert.Equal(t, ir.Unmodeled{ - "openapi:conflicting-redeclaration/b": { - Reason: ir.ReasonDegradedLowering, - Value: ir.RawValue(`{"type":"integer"}`), - Provenance: ir.Provenance{Pointer: "/b"}, - }, - "openapi:conflicting-redeclaration/c": { - Reason: ir.ReasonDegradedLowering, - Value: ir.RawValue(`{"type":"boolean"}`), - Provenance: ir.Provenance{Pointer: "/c"}, - }, - }, m.Properties[0].Unmodeled) -} - -// TestKeepLosingDeclaration_AgreeingDeclarationsKeepNothing pins the side the -// entry must not appear on: agreeing declarations discard nothing, so an entry -// would be noise. A constraint conflict is the other side — the merge keeps the -// first declaration's keyword and drops the redeclaration's, which is a loss -// like any other until the bounds are intersected instead (GitHub #10), and -// the entry records what the merge dropped rather than deciding what it should -// have kept. -func TestKeepLosingDeclaration_AgreeingDeclarationsKeepNothing(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - }) - ten, twenty := int64(10), int64(20) - agreeing := ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}} - - agreeing = foldTwo(g, agreeing, ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}, Provenance: ptrAt("/b")}, unread(t)) - - assert.Empty(t, *recorded, "agreeing declarations are not a conflict") - assert.Empty(t, agreeing.Unmodeled, "and nothing was discarded to keep") - - bounded := ir.Property{ - WireName: "code", Type: ir.TypeRef{Target: "t/str"}, - Constraints: &ir.Constraints{MaxLength: &ten}, - } - bounded = foldTwo(g, bounded, ir.Property{ - WireName: "code", Type: ir.TypeRef{Target: "t/str"}, - Constraints: &ir.Constraints{MaxLength: &twenty}, - Provenance: ptrAt("/b"), - }, written(`{"type":"string","maxLength":20}`)) - - require.Len(t, *recorded, 1, "the constraint conflict is still diagnosed") - assert.Equal(t, diag.ConflictingRedecl, (*recorded)[0].Code) - assert.Equal(t, ir.Unmodeled{ - "openapi:conflicting-redeclaration/b": { - Reason: ir.ReasonDegradedLowering, - Value: ir.RawValue(`{"type":"string","maxLength":20}`), - Provenance: ir.Provenance{Pointer: "/b"}, - }, - }, bounded.Unmodeled, "the keyword the merge dropped is kept with the declaration that wrote it") -} - // TestFold_AConflictingTypeTakesNothingWithIt pins that a // discarded declaration is discarded whole. The type conflict is diagnosed and // the loser's type kept under Unmodeled, but the fold below it used to run to @@ -674,36 +375,6 @@ func TestFold_NullabilityIntersectsWhenTargetsAgree(t *testing.T) { } } -// TestIntersectNull_TheMarkerFollowsTheBit pins what the intersection does with -// TypeRef.Inferred. The declared reading admits null only where both sides -// declare it, so the result's null rests on a heuristic wherever either side's -// did: it carries every marker either names, once each, and none once it admits -// no null, where a marker would qualify nothing. -func TestIntersectNull_TheMarkerFollowsTheBit(t *testing.T) { - t.Parallel() - ref := func(nullable bool, inferred string) ir.TypeRef { - return ir.TypeRef{Target: "t/str", Nullable: nullable, Inferred: inferred} - } - cases := []struct { - name string - dst, src, want ir.TypeRef - }{ - {"declared on both sides", ref(true, ""), ref(true, ""), ref(true, "")}, - {"inferred on the redeclaration", ref(true, ""), ref(true, "h"), ref(true, "h")}, - {"inferred on the first", ref(true, "h"), ref(true, ""), ref(true, "h")}, - {"one marker on both", ref(true, "h"), ref(true, "h"), ref(true, "h")}, - {"two markers, the first's first", ref(true, "a,b"), ref(true, "b,c"), ref(true, "a,b,c")}, - {"a side admitting none drops the marker", ref(true, "h"), ref(false, ""), ref(false, "")}, - {"so does the other order", ref(false, ""), ref(true, "h"), ref(false, "")}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, intersectNull(tc.dst, tc.src)) - }) - } -} - // TestFold_ADroppedTypeIsKeptEvenWhenItDoesNotConflict pins the // gap between what the merge drops and what it calls a conflict. The judgment // does not guess about two composites of one kind or an opaque scalar, but the @@ -1062,20 +733,6 @@ func TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail(t *testing.T) { } } -// TestTypeJudge_UnrelatedPrimitivesAreDisjoint pins the pairs the relation -// calls disjoint, and the enum against a bare kind of another family. -func TestTypeJudge_UnrelatedPrimitivesAreDisjoint(t *testing.T) { - t.Parallel() - reg := narrowingRegistry() - reg["t/enm"] = &ir.Enum{ID: "t/enm", ValueType: ir.PrimString} - g, _ := stubMerger(reg) - - assert.Equal(t, relDisjoint, judge(g).relate(primRef(ir.PrimUUID), primRef(ir.PrimDatetimeOffset))) - assert.Equal(t, relDisjoint, judge(g).relate(ir.TypeRef{Target: "t/enm"}, primRef(ir.PrimInteger))) - assert.Equal(t, relOverlap, judge(g).relate(primRef(ir.PrimInt32), primRef(ir.PrimUint32)), - "overlapping kinds are no conflict") -} - // TestFold_ANarrowingIsNoDrop pins that a bare narrowing settles on one target // in either order and is no drop, so the redeclaration is neither classified // nor reported nor kept. diff --git a/compilers/openapi/internal/merge/fold_internal_test.go b/compilers/openapi/internal/merge/fold_internal_test.go index 7c8cb882..1317e37c 100644 --- a/compilers/openapi/internal/merge/fold_internal_test.go +++ b/compilers/openapi/internal/merge/fold_internal_test.go @@ -402,34 +402,6 @@ func TestFold_ANarrowerTypeThatLosesIsAnOverlap(t *testing.T) { assert.True(t, f.lost[1]) } -// TestSameValue_ComparesJSONValues pins the value equality the fold and the -// enum judgment share: one JSON value spelled two ways is one, and a value -// with no JSON instance, which canonicalValue cannot spell, compares deeply. -func TestSameValue_ComparesJSONValues(t *testing.T) { - t.Parallel() - obj := func(fs ...ir.Field) ir.Value { return ir.Value{Kind: ir.ValueObject, Object: fs} } - sym := func(s string) ir.Value { return ir.Value{Kind: ir.ValueSymbol, Str: s} } - for _, tc := range []struct { - name string - a, b ir.Value - same bool - }{ - {"one number, two spellings", num("1"), num("1.0"), true}, - {"two numbers", num("1"), num("2"), false}, - {"one object, two member orders", obj(ir.Field{Name: "a", Value: num("1")}, ir.Field{Name: "b", Value: str("x")}), - obj(ir.Field{Name: "b", Value: str("x")}, ir.Field{Name: "a", Value: num("1e0")}), true}, - {"a number and a string", num("1"), str("1"), false}, - {"one symbol, compared deeply", sym("s"), sym("s"), true}, - {"two symbols, compared deeply", sym("s"), sym("t"), false}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.same, sameValue(tc.a, tc.b)) - assert.Equal(t, tc.same, sameValue(tc.b, tc.a)) - }) - } -} - // TestSameExamples_ComparesEachExampleWhole pins that two example lists agree // only where each example agrees whole: its value as one JSON value, and its // name, summary and description as written, a missing value included. @@ -443,3 +415,37 @@ func TestSameExamples_ComparesEachExampleWhole(t *testing.T) { assert.True(t, sameExamples([]ir.Example{{Name: "e"}}, []ir.Example{{Name: "e"}}), "no value on either") assert.False(t, sameExamples(base, nil), "a list against none") } + +// TestFold_ExamplesAreJudgedAsOneKeyword pins that a broader declaration's +// examples are adopted all or nothing: one example the narrower type refuses +// refuses the keyword, and an example with no value says nothing a type could +// refuse. +func TestFold_ExamplesAreJudgedAsOneKeyword(t *testing.T) { + t.Parallel() + one, half := num("1"), num("1.5") + for _, tc := range []struct { + name string + examples []ir.Example + refused bool + }{ + {"no examples say nothing", nil, false}, + {"an example with no value says nothing", []ir.Example{{Value: &one}, {Summary: "no value"}}, false}, + {"one refused example refuses the keyword", []ir.Example{{Value: &one}, {Value: &half}}, true}, + } { + g, recorded := stubMerger(narrowingRegistry()) + broad := ir.Property{WireName: "p", Type: primRef(ir.PrimNumber), Examples: tc.examples, Provenance: ptrAt("/b")} + narrow := ir.Property{WireName: "p", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/a")} + + got := foldTwo(g, narrow, broad, written(`{"examples":[1]}`)) + + if !tc.refused { + assert.Equal(t, tc.examples, got.Examples, "%s: adopted whole", tc.name) + assert.Empty(t, *recorded, tc.name) + continue + } + assert.Nil(t, got.Examples, "%s: refused whole", tc.name) + require.Len(t, *recorded, 1, tc.name) + assert.Contains(t, (*recorded)[0].Message, "does not admit the examples", tc.name) + assert.Contains(t, got.Unmodeled, losingDeclarationKey+"/b", "%s: the writer is kept", tc.name) + } +} diff --git a/compilers/openapi/internal/merge/losers_internal_test.go b/compilers/openapi/internal/merge/losers_internal_test.go new file mode 100644 index 00000000..9755cd4e --- /dev/null +++ b/compilers/openapi/internal/merge/losers_internal_test.go @@ -0,0 +1,127 @@ +package merge + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// TestKeepLosingDeclaration_RecordsTheDiscardedDeclaration pins what the fix for +// GitHub #424 adds: the redeclaration whose type loses is kept verbatim beside +// the winner, so a consumer reading the document — not the diagnostic stream — +// still sees what the second declaration said. +// +// The reference is asserted whole rather than by its target alone: Nullable is +// half of what a redeclaration says about a field, and an entry that kept only +// the ID would lose it while still looking like a preservation. +func TestKeepLosingDeclaration_RecordsTheDiscardedDeclaration(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, + }) + dst := ir.Property{ + WireName: "id", + Type: ir.TypeRef{Target: "t/str"}, + Provenance: ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/0/properties/id"}, + } + src := ir.Property{ + WireName: "id", + Type: ir.TypeRef{Target: "t/int", Nullable: true}, + Provenance: ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/1/properties/id"}, + } + + dst = foldTwo(g, dst, src, written(`{"type":"integer","nullable":true}`)) + + require.Len(t, *recorded, 1, "the conflict is still diagnosed") + assert.Equal(t, ir.TypeID("t/str"), dst.Type.Target, "the first declaration still wins the shape") + + key := "openapi:conflicting-redeclaration/components/schemas/S/allOf/1/properties/id" + entry, ok := dst.Unmodeled[key] + require.True(t, ok, "the losing declaration is kept under a pointer-namespaced key; got %v", dst.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `{"type":"integer","nullable":true}`, string(entry.Value), + "the declaration is kept as the document wrote it, nullability included") + assert.Equal(t, ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/1/properties/id"}, + entry.Provenance, "the entry locates the losing declaration, not the merged property") +} + +// TestKeepLosingDeclaration_EveryLoserSurvivesItsSiblings pins the key's namespacing. +// A field three branches type three incompatible ways discards two +// declarations, and a fixed key would leave only whichever branch ran last — +// the same silent overwrite the entry exists to prevent. +func TestKeepLosingDeclaration_EveryLoserSurvivesItsSiblings(t *testing.T) { + t.Parallel() + reg := map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, + "t/bool": &ir.Primitive{ID: "t/bool", Prim: ir.PrimBool}, + } + g, _ := stubMerger(reg) + m := &ir.Model{} + idx, settle := indexOver(m, reg, g) + + idx.MergeProperty(ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}, Provenance: ptrAt("/a")}, unread(t)) + idx.MergeProperty(ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/int"}, Provenance: ptrAt("/b")}, written(`{"type":"integer"}`)) + idx.MergeProperty(ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/bool"}, Provenance: ptrAt("/c")}, written(`{"type":"boolean"}`)) + settle() + + require.Len(t, m.Properties, 1, "three declarations still reconcile to one property") + assert.Equal(t, ir.Unmodeled{ + "openapi:conflicting-redeclaration/b": { + Reason: ir.ReasonDegradedLowering, + Value: ir.RawValue(`{"type":"integer"}`), + Provenance: ir.Provenance{Pointer: "/b"}, + }, + "openapi:conflicting-redeclaration/c": { + Reason: ir.ReasonDegradedLowering, + Value: ir.RawValue(`{"type":"boolean"}`), + Provenance: ir.Provenance{Pointer: "/c"}, + }, + }, m.Properties[0].Unmodeled) +} + +// TestKeepLosingDeclaration_AgreeingDeclarationsKeepNothing pins the side the +// entry must not appear on: agreeing declarations discard nothing, so an entry +// would be noise. A constraint conflict is the other side — the merge keeps the +// first declaration's keyword and drops the redeclaration's, which is a loss +// like any other until the bounds are intersected instead (GitHub #10), and +// the entry records what the merge dropped rather than deciding what it should +// have kept. +func TestKeepLosingDeclaration_AgreeingDeclarationsKeepNothing(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + }) + ten, twenty := int64(10), int64(20) + agreeing := ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}} + + agreeing = foldTwo(g, agreeing, ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}, Provenance: ptrAt("/b")}, unread(t)) + + assert.Empty(t, *recorded, "agreeing declarations are not a conflict") + assert.Empty(t, agreeing.Unmodeled, "and nothing was discarded to keep") + + bounded := ir.Property{ + WireName: "code", Type: ir.TypeRef{Target: "t/str"}, + Constraints: &ir.Constraints{MaxLength: &ten}, + } + bounded = foldTwo(g, bounded, ir.Property{ + WireName: "code", Type: ir.TypeRef{Target: "t/str"}, + Constraints: &ir.Constraints{MaxLength: &twenty}, + Provenance: ptrAt("/b"), + }, written(`{"type":"string","maxLength":20}`)) + + require.Len(t, *recorded, 1, "the constraint conflict is still diagnosed") + assert.Equal(t, diag.ConflictingRedecl, (*recorded)[0].Code) + assert.Equal(t, ir.Unmodeled{ + "openapi:conflicting-redeclaration/b": { + Reason: ir.ReasonDegradedLowering, + Value: ir.RawValue(`{"type":"string","maxLength":20}`), + Provenance: ir.Provenance{Pointer: "/b"}, + }, + }, bounded.Unmodeled, "the keyword the merge dropped is kept with the declaration that wrote it") +} diff --git a/compilers/openapi/internal/merge/merge_internal_test.go b/compilers/openapi/internal/merge/merge_internal_test.go index f07b28f4..ee70e92c 100644 --- a/compilers/openapi/internal/merge/merge_internal_test.go +++ b/compilers/openapi/internal/merge/merge_internal_test.go @@ -69,29 +69,6 @@ func indexOver(m *ir.Model, reg map[ir.TypeID]ir.TypeDef, g *Merger) (idx *PropI } } -// TestTypeJudge_ComparesReferentsNotReferences pins that the judgment reads -// the nodes two references name, not the references. -func TestTypeJudge_ComparesReferentsNotReferences(t *testing.T) { - t.Parallel() - g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/a": &ir.Primitive{ID: "t/a", Prim: ir.PrimString}, - "t/b": &ir.Primitive{ID: "t/b", Prim: ir.PrimString}, - "t/i": &ir.Primitive{ID: "t/i", Prim: ir.PrimInt32}, - "t/any": &ir.Any{ID: "t/any"}, - "t/m": &ir.Model{ID: "t/m"}, - }) - ref := func(id ir.TypeID) ir.TypeRef { return ir.TypeRef{Target: id} } - - assert.Equal(t, relSame, judge(g).relate(ref("t/a"), ref("t/b")), - "two distinct nodes of one primitive kind are compared by kind") - assert.Equal(t, relDisjoint, judge(g).relate(ref("t/a"), ref("t/i")), - "string and integer cannot both be satisfied") - assert.Equal(t, relUnrelated, judge(g).relate(ref("t/any"), ref("t/i")), - "a degraded node stands for a declaration the judgment cannot see") - assert.Equal(t, relDisjoint, judge(g).relate(ref("t/m"), ref("t/a")), - "a structural type and a primitive are different kinds") -} - // TestMerger_ReconcileReportsDisagreementAndKeepsAWinner pins the documented // policy: an unsatisfiable redeclaration is surfaced rather than silently // resolved, and the merge still yields one property. diff --git a/compilers/openapi/internal/merge/nullability_internal_test.go b/compilers/openapi/internal/merge/nullability_internal_test.go new file mode 100644 index 00000000..bc245b63 --- /dev/null +++ b/compilers/openapi/internal/merge/nullability_internal_test.go @@ -0,0 +1,39 @@ +package merge + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/ir" +) + +// TestIntersectNull_TheMarkerFollowsTheBit pins what the intersection does with +// TypeRef.Inferred. The declared reading admits null only where both sides +// declare it, so the result's null rests on a heuristic wherever either side's +// did: it carries every marker either names, once each, and none once it admits +// no null, where a marker would qualify nothing. +func TestIntersectNull_TheMarkerFollowsTheBit(t *testing.T) { + t.Parallel() + ref := func(nullable bool, inferred string) ir.TypeRef { + return ir.TypeRef{Target: "t/str", Nullable: nullable, Inferred: inferred} + } + cases := []struct { + name string + dst, src, want ir.TypeRef + }{ + {"declared on both sides", ref(true, ""), ref(true, ""), ref(true, "")}, + {"inferred on the redeclaration", ref(true, ""), ref(true, "h"), ref(true, "h")}, + {"inferred on the first", ref(true, "h"), ref(true, ""), ref(true, "h")}, + {"one marker on both", ref(true, "h"), ref(true, "h"), ref(true, "h")}, + {"two markers, the first's first", ref(true, "a,b"), ref(true, "b,c"), ref(true, "a,b,c")}, + {"a side admitting none drops the marker", ref(true, "h"), ref(false, ""), ref(false, "")}, + {"so does the other order", ref(false, ""), ref(true, "h"), ref(false, "")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, intersectNull(tc.dst, tc.src)) + }) + } +} diff --git a/compilers/openapi/internal/merge/typejudge_internal_test.go b/compilers/openapi/internal/merge/typejudge_internal_test.go index bf737d65..5771c532 100644 --- a/compilers/openapi/internal/merge/typejudge_internal_test.go +++ b/compilers/openapi/internal/merge/typejudge_internal_test.go @@ -233,3 +233,101 @@ func TestRelation_DroppedAndFlipped(t *testing.T) { assert.Equal(t, relBNarrower, relANarrower.flipped()) assert.Equal(t, relANarrower, relBNarrower.flipped()) } + +func TestTypeJudge_NullIsJudgedByNullability(t *testing.T) { + t.Parallel() + null := ir.Value{Kind: ir.ValueNull} + j := typeJudge{Resolve: stubResolve(narrowingRegistry())} + nullable := func(k ir.PrimKind) ir.TypeRef { r := primRef(k); r.Nullable = true; return r } + + assert.True(t, j.admits(nullable(ir.PrimInteger), null)) + assert.False(t, j.admits(primRef(ir.PrimInteger), null)) + assert.False(t, j.admits(nullable(ir.PrimInteger), str("x"))) + assert.True(t, j.admits(primRef(ir.PrimInteger), num("1"))) + assert.True(t, j.admits(primRef(ir.PrimAny), null), "the top type admits null whatever the bit says") +} + +// TestClassify_ComparesResolvedKinds pins the last-resort comparison classify +// makes when neither side ends in a primitive kind. Two composites of the same +// kind are not provably contradictory; two of different kinds are disjoint. +func TestClassify_ComparesResolvedKinds(t *testing.T) { + t.Parallel() + g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/model": &ir.Model{ID: "t/model"}, + "t/other": &ir.Model{ID: "t/other"}, + "t/union": &ir.Union{ID: "t/union"}, + "t/opaque": &ir.Scalar{ID: "t/opaque"}, + }) + + assert.Equal(t, relUnrelated, judge(g).relate(ir.TypeRef{Target: "t/model"}, ir.TypeRef{Target: "t/other"}), + "two models are the same kind, so not provably contradictory") + assert.Equal(t, relDisjoint, judge(g).relate(ir.TypeRef{Target: "t/model"}, ir.TypeRef{Target: "t/union"}), + "a model and a union are different kinds") + assert.Equal(t, relDisjoint, judge(g).relate(ir.TypeRef{Target: "t/opaque"}, ir.TypeRef{Target: "t/union"}), + "and the comparison is reached when neither side is a primitive") +} + +// TestTypeJudge_UnrelatedPrimitivesAreDisjoint pins the pairs the relation +// calls disjoint, and the enum against a bare kind of another family. +func TestTypeJudge_UnrelatedPrimitivesAreDisjoint(t *testing.T) { + t.Parallel() + reg := narrowingRegistry() + reg["t/enm"] = &ir.Enum{ID: "t/enm", ValueType: ir.PrimString} + g, _ := stubMerger(reg) + + assert.Equal(t, relDisjoint, judge(g).relate(primRef(ir.PrimUUID), primRef(ir.PrimDatetimeOffset))) + assert.Equal(t, relDisjoint, judge(g).relate(ir.TypeRef{Target: "t/enm"}, primRef(ir.PrimInteger))) + assert.Equal(t, relOverlap, judge(g).relate(primRef(ir.PrimInt32), primRef(ir.PrimUint32)), + "overlapping kinds are no conflict") +} + +// TestSameValue_ComparesJSONValues pins the value equality the fold and the +// enum judgment share: one JSON value spelled two ways is one, and a value +// with no JSON instance, which canonicalValue cannot spell, compares deeply. +func TestSameValue_ComparesJSONValues(t *testing.T) { + t.Parallel() + obj := func(fs ...ir.Field) ir.Value { return ir.Value{Kind: ir.ValueObject, Object: fs} } + sym := func(s string) ir.Value { return ir.Value{Kind: ir.ValueSymbol, Str: s} } + for _, tc := range []struct { + name string + a, b ir.Value + same bool + }{ + {"one number, two spellings", num("1"), num("1.0"), true}, + {"two numbers", num("1"), num("2"), false}, + {"one object, two member orders", obj(ir.Field{Name: "a", Value: num("1")}, ir.Field{Name: "b", Value: str("x")}), + obj(ir.Field{Name: "b", Value: str("x")}, ir.Field{Name: "a", Value: num("1e0")}), true}, + {"a number and a string", num("1"), str("1"), false}, + {"one symbol, compared deeply", sym("s"), sym("s"), true}, + {"two symbols, compared deeply", sym("s"), sym("t"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.same, sameValue(tc.a, tc.b)) + assert.Equal(t, tc.same, sameValue(tc.b, tc.a)) + }) + } +} + +// TestTypeJudge_ComparesReferentsNotReferences pins that the judgment reads +// the nodes two references name, not the references. +func TestTypeJudge_ComparesReferentsNotReferences(t *testing.T) { + t.Parallel() + g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/a": &ir.Primitive{ID: "t/a", Prim: ir.PrimString}, + "t/b": &ir.Primitive{ID: "t/b", Prim: ir.PrimString}, + "t/i": &ir.Primitive{ID: "t/i", Prim: ir.PrimInt32}, + "t/any": &ir.Any{ID: "t/any"}, + "t/m": &ir.Model{ID: "t/m"}, + }) + ref := func(id ir.TypeID) ir.TypeRef { return ir.TypeRef{Target: id} } + + assert.Equal(t, relSame, judge(g).relate(ref("t/a"), ref("t/b")), + "two distinct nodes of one primitive kind are compared by kind") + assert.Equal(t, relDisjoint, judge(g).relate(ref("t/a"), ref("t/i")), + "string and integer cannot both be satisfied") + assert.Equal(t, relUnrelated, judge(g).relate(ref("t/any"), ref("t/i")), + "a degraded node stands for a declaration the judgment cannot see") + assert.Equal(t, relDisjoint, judge(g).relate(ref("t/m"), ref("t/a")), + "a structural type and a primitive are different kinds") +} diff --git a/compilers/openapi/internal/merge/visibility_internal_test.go b/compilers/openapi/internal/merge/visibility_internal_test.go new file mode 100644 index 00000000..079e104e --- /dev/null +++ b/compilers/openapi/internal/merge/visibility_internal_test.go @@ -0,0 +1,119 @@ +package merge + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/ir" +) + +// TestMergeVisibility_IntersectsUnderAllOfSemantics pins the shapes an allOf +// redeclaration's Visibility pairing can take. An empty Only means +// unrestricted, not restricted-to-nothing (ir-design §5.2), so each side must +// be read as the set it admits before intersecting. +// +// The first three cases cannot catch a plain append, since concatenating an +// empty Only is a no-op. The five below them do: appending duplicates when both +// branches agree, unions where allOf intersects, and cannot spell the empty +// set. +func TestMergeVisibility_IntersectsUnderAllOfSemantics(t *testing.T) { + t.Parallel() + unrestricted := ir.Visibility{} + invisible := ir.Visibility{None: true} + + tests := []struct { + name string + dst, src ir.Visibility + want ir.Visibility + }{ + { + name: "neither branch restricts visibility", + dst: unrestricted, src: unrestricted, + want: unrestricted, + }, + { + // The issue's own reproduction: a first branch that leaves the + // field unrestricted must not shadow a later branch's readOnly. + name: "only the redeclaration restricts visibility", + dst: unrestricted, src: readOnlyVisibility, + want: readOnlyVisibility, + }, + { + name: "only the first declaration restricts visibility", + dst: readOnlyVisibility, src: unrestricted, + want: readOnlyVisibility, + }, + { + name: "both branches agree on the same restriction", + dst: readOnlyVisibility, src: readOnlyVisibility, + want: readOnlyVisibility, + }, + { + // readOnly admits {read, delete, query}; writeOnly admits + // {create, update} — disjoint sets whose intersection is empty, + // meaning the field can satisfy no lifecycle both branches + // admit. None is the IR's existing shape for "visible in no + // lifecycle" (TypeSpec @invisible), so the empty intersection is + // recorded exactly rather than read as unrestricted. + name: "disjoint restrictions intersect to invisible", + dst: readOnlyVisibility, src: writeOnlyVisibility, + want: invisible, + }, + { + // Neither side is unrestricted and neither subsumes the other, so + // the answer is a proper subset of both — the one shape the fast + // paths above cannot produce, and the only case that pins the + // intersection itself rather than which operand survives whole. + // EffectiveVisibility restricts to two fixed sets that are either + // identical or disjoint, so no OpenAPI document reaches this pairing + // today; it is the helper's contract a second visibility source + // would inherit. + name: "partially overlapping restrictions keep only the shared lifecycles", + dst: readOnlyVisibility, src: ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleQuery}}, + want: ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleQuery}}, + }, + { + name: "None on the first declaration dominates", + dst: invisible, src: readOnlyVisibility, + want: invisible, + }, + { + name: "None on the redeclaration dominates", + dst: readOnlyVisibility, src: invisible, + want: invisible, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, mergeVisibility(tc.dst, tc.src), + "mergeVisibility(dst, src) for %q", tc.name) + assert.Equal(t, tc.want, mergeVisibility(tc.src, tc.dst), + "mergeVisibility(src, dst) must agree — allOf branch order is not semantic, for %q", tc.name) + }) + } +} + +// TestMergeVisibility_ReorderedBranchesAgree pins the one way two branches can +// disagree that the table above cannot express: they admit lifecycles that +// overlap, but list them in different orders. Every case in the table names its +// operands in one order, so a merge that read the surviving order off dst alone +// would satisfy all of them and still answer two different slices here. +// +// The exact order the merge settles on is not pinned — a set has no preferred +// spelling — only that both branch orders reach the same one, which is what +// keeps the composition's spelling out of the IR. +func TestMergeVisibility_ReorderedBranchesAgree(t *testing.T) { + t.Parallel() + first := readOnlyVisibility + second := ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleQuery, ir.LifecycleRead}} + + forward := mergeVisibility(first, second) + reversed := mergeVisibility(second, first) + + assert.Equal(t, forward, reversed, "allOf branch order must not reach the merged Only") + assert.ElementsMatch(t, []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleQuery}, forward.Only, + "the merge is still the intersection, whichever order it is spelled in") + assert.False(t, forward.None, "a non-empty intersection is a restriction, not invisibility") +} diff --git a/compilers/openapi/internal/naming/naming_internal_test.go b/compilers/openapi/internal/naming/naming_internal_test.go index 9b9769bd..863e9c76 100644 --- a/compilers/openapi/internal/naming/naming_internal_test.go +++ b/compilers/openapi/internal/naming/naming_internal_test.go @@ -28,24 +28,6 @@ func loaded(t *testing.T, spec string) *bind.Table { return doc.Bindings.Table() } -// TestOwnerOf_NamesOnlyAMediaTypeAContentMapHolds pins the guards on a media -// type's owner. The grammar reads a media type only as an entry of a content -// map, whose holder is a request body, a response, a parameter or a header, -// so neither guard is reached through At; each stands for a grammar that -// grows a media type anywhere else, which must name it nothing rather than -// read past the pointer's start. -func TestOwnerOf_NamesOnlyAMediaTypeAContentMapHolds(t *testing.T) { - t.Parallel() - n := New(nil) - hint, ok := n.ownerOf(bind.Source, []string{"schema"}, []bind.Kind{bind.KindMediaType, bind.KindSchema}, 0) - assert.False(t, ok, "a media type no content map holds") - assert.Empty(t, hint) - - hint, ok = n.contentOwner(bind.Source, "/x", bind.KindOther) - assert.False(t, ok, "content held by no object the grammar names") - assert.Empty(t, hint) -} - // TestNode_ReadsNothingTheTableDoesNotHold pins that node reads only a // document the table holds, and nothing a namer with no table is asked for. func TestNode_ReadsNothingTheTableDoesNotHold(t *testing.T) { diff --git a/compilers/openapi/internal/naming/owners_internal_test.go b/compilers/openapi/internal/naming/owners_internal_test.go new file mode 100644 index 00000000..b80b2b0f --- /dev/null +++ b/compilers/openapi/internal/naming/owners_internal_test.go @@ -0,0 +1,27 @@ +package naming + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/compilers/openapi/internal/bind" +) + +// TestOwnerOf_NamesOnlyAMediaTypeAContentMapHolds pins the guards on a media +// type's owner. The grammar reads a media type only as an entry of a content +// map, whose holder is a request body, a response, a parameter or a header, +// so neither guard is reached through At; each stands for a grammar that +// grows a media type anywhere else, which must name it nothing rather than +// read past the pointer's start. +func TestOwnerOf_NamesOnlyAMediaTypeAContentMapHolds(t *testing.T) { + t.Parallel() + n := New(nil) + hint, ok := n.ownerOf(bind.Source, []string{"schema"}, []bind.Kind{bind.KindMediaType, bind.KindSchema}, 0) + assert.False(t, ok, "a media type no content map holds") + assert.Empty(t, hint) + + hint, ok = n.contentOwner(bind.Source, "/x", bind.KindOther) + assert.False(t, ok, "content held by no object the grammar names") + assert.Empty(t, hint) +} diff --git a/compilers/openapi/internal/oas/code_internal_test.go b/compilers/openapi/internal/oas/code_internal_test.go new file mode 100644 index 00000000..fa566fe8 --- /dev/null +++ b/compilers/openapi/internal/oas/code_internal_test.go @@ -0,0 +1,14 @@ +package oas + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestTemplateFault_NamesWhatIsWrong pins the wording of the template check. +func TestTemplateFault_NamesWhatIsWrong(t *testing.T) { + assert.Empty(t, templateFault("https://{a}/{b}")) + assert.Contains(t, templateFault("{a}/{a}"), `"a"`) + assert.Contains(t, templateFault("{a"), "no }") +} diff --git a/compilers/openapi/internal/oas/engine_internal_test.go b/compilers/openapi/internal/oas/engine_internal_test.go index 63b320c2..4f43e050 100644 --- a/compilers/openapi/internal/oas/engine_internal_test.go +++ b/compilers/openapi/internal/oas/engine_internal_test.go @@ -117,19 +117,3 @@ func TestCiteFor_PicksTheLatestCitationTheMinorHasReached(t *testing.T) { assert.Equal(t, "c", citeFor([]Cite{{Minor: V32, Sentence: "c"}}, V30).Sentence, "none reached: the first") assert.Equal(t, Cite{}, citeFor(nil, V30)) } - -// TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed pins the format that -// asks for nothing. -func TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed(t *testing.T) { - assert.True(t, validFormat(FormatNone, "anything at all")) - assert.False(t, validFormat(ComponentName, "a b")) - assert.Equal(t, "1 entry", plural(1)) - assert.Equal(t, "2 entries", plural(2)) -} - -// TestTemplateFault_NamesWhatIsWrong pins the wording of the template check. -func TestTemplateFault_NamesWhatIsWrong(t *testing.T) { - assert.Empty(t, templateFault("https://{a}/{b}")) - assert.Contains(t, templateFault("{a}/{a}"), `"a"`) - assert.Contains(t, templateFault("{a"), "no }") -} diff --git a/compilers/openapi/internal/oas/format_internal_test.go b/compilers/openapi/internal/oas/format_internal_test.go new file mode 100644 index 00000000..41768a14 --- /dev/null +++ b/compilers/openapi/internal/oas/format_internal_test.go @@ -0,0 +1,16 @@ +package oas + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed pins the format that +// asks for nothing. +func TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed(t *testing.T) { + assert.True(t, validFormat(FormatNone, "anything at all")) + assert.False(t, validFormat(ComponentName, "a b")) + assert.Equal(t, "1 entry", plural(1)) + assert.Equal(t, "2 entries", plural(2)) +} diff --git a/compilers/openapi/internal/resolve/locate_internal_test.go b/compilers/openapi/internal/resolve/locate_internal_test.go index d2a01be9..f5838d2a 100644 --- a/compilers/openapi/internal/resolve/locate_internal_test.go +++ b/compilers/openapi/internal/resolve/locate_internal_test.go @@ -14,13 +14,8 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/navigation" - "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) -// elsewhere is an Ends that reads every node it is asked about as a reference -// ending in another document, so any node a walk steps past shows. -func elsewhere(any) (End, bool) { return End{Document: "elsewhere", Path: "x.yaml"}, true } - // rawMappingDoc is a document whose pointers leave the model for raw YAML: // the root's under an extension, and a schema's. const rawMappingDoc = `openapi: 3.1.0 @@ -86,67 +81,6 @@ func builtAt(t *testing.T, doc *soa.OpenAPI, pointer jsontext.Pointer) *yaml.Nod } } -// TestScopeReached_PassesAReferenceTheWalkLeavesTheModelFrom pins a walk that -// leaves the model at a reference, for raw YAML its target holds: the -// reference is passed when the target holds the next token, as the resolver -// steps past it into that YAML, and not when it holds nothing there. Holds, -// asked of the target's mapping, answers in place of the library: as the -// library's read of that mapping does, and what it answers is what is passed. -func TestScopeReached_PassesAReferenceTheWalkLeavesTheModelFrom(t *testing.T) { - t.Parallel() - doc, _, err := soa.Unmarshal(t.Context(), strings.NewReader(`openapi: 3.1.0 -info: {title: T, version: "1"} -paths: {} -components: - responses: - R: {$ref: '#/components/responses/S'} - S: {description: s, x-foo: {a: 1}} -`)) - require.NoError(t, err) - _, err = doc.ResolveAllReferences(t.Context(), soa.ResolveAllOptions{OpenAPILocation: "spec.yaml"}) - require.NoError(t, err) - elsewhere := func(node any) (End, bool) { - if r, ok := node.(*soa.ReferencedResponse); ok && r.IsReference() { - return End{Document: "elsewhere", Path: "x.yaml"}, true - } - return End{}, false - } - library := func(raw *yaml.Node, token string) bool { - _, err := jsonpointer.GetTarget(raw, jsonpointer.JSONPointer("/"+jsonpointer.EscapeString(token))) - return err == nil - } - for _, holds := range []func(*yaml.Node, string) bool{nil, library} { - sc := Scope{Doc: doc, Ends: elsewhere, Holds: holds} - assert.True(t, atPointer(sc, "/components/responses/R/x-foo/a").Foreign, "S holds x-foo") - assert.True(t, atPointer(sc, "/components/responses/R/x-foo").Foreign) - assert.False(t, atPointer(sc, "/components/responses/R/x-nope").Foreign, "S holds no x-nope") - assert.False(t, atPointer(sc, "/components/responses/S/x-foo").Foreign, "S is no reference") - } - - never := Scope{Doc: doc, Ends: elsewhere, Holds: func(*yaml.Node, string) bool { return false }} - assert.False(t, atPointer(never, "/components/responses/R/x-foo/a").Foreign, "Holds, not the library, answers") - always := Scope{Doc: doc, Ends: elsewhere, Holds: func(*yaml.Node, string) bool { return true }} - assert.True(t, atPointer(always, "/components/responses/R/x-nope").Foreign, "Holds, not the library, answers") - assert.False(t, atPointer(always, "/components/responses/S/x-foo").Foreign, "only a reference asks it") -} - -// TestScopeReached_StepsPastAnIndexTheLibraryRetries pins the walk past an -// index the library retries, reading it as a key with the rest below it and as -// an index once that fails: what it steps past is still noted a step at a -// time. -func TestScopeReached_StepsPastAnIndexTheLibraryRetries(t *testing.T) { - t.Parallel() - var built yaml.Node - require.NoError(t, yaml.Unmarshal([]byte("{'0': {j: 5}}"), &built)) - js := openapitest.SchemaFromYAML(t.Context(), t, "type: string\n") - doc := &indexedDoc{built: built.Content[0], schema: js} - for _, ends := range []func(any) (End, bool){nil, elsewhere} { - sc := Scope{Doc: doc, Ends: ends} - assert.Same(t, js, wholeModelAt(doc, "/0/k"), "sanity: the index answers k, which the mapping's 0 does not hold") - assert.Equal(t, ends != nil, atPointer(sc, "/0/k").Foreign, "the walk noted the document it stepped past") - } -} - // indexedDoc is a document that is no model and that the library reads by // index as well as by the mapping it was built from: that mapping's 0 holds j, // and its index 0 holds the schema under k. @@ -174,79 +108,3 @@ func walkTo(doc *soa.OpenAPI, pointer jsontext.Pointer) any { } return node } - -// TestScopeReached_PassesAReferenceUnderADefaultResponse pins that the walk goes on -// past an operation's responses, which the model holds by value: a default -// response that is a reference is passed, as one under a status code is. -func TestScopeReached_PassesAReferenceUnderADefaultResponse(t *testing.T) { - t.Parallel() - responses := func(node any) (End, bool) { - if _, ok := node.(*soa.ReferencedResponse); ok { - return End{Document: "elsewhere", Path: "x.yaml"}, true - } - return End{}, false - } - sc := Scope{Doc: unmarshalWalkDoc(t), Ends: responses} - for _, code := range []string{"default", "200"} { - at := atPointer(sc, jsontext.Pointer("/paths/~1a/get/responses/"+code+"/description")) - assert.True(t, at.Foreign, "the %s response is passed", code) - assert.Equal(t, "x.yaml", at.Holder, code) - } -} - -// TestScopeReached_StopsAtASchema pins where the walk stops reading references: at -// the first schema, since the resolver reads a schema's own $ref as a keyword, -// so nothing past it is asked whether it is one. -func TestScopeReached_StopsAtASchema(t *testing.T) { - t.Parallel() - asked := 0 - sc := Scope{Doc: unmarshalWalkDoc(t), Ends: func(any) (End, bool) { asked++; return End{}, false }} - - assert.False(t, atPointer(sc, "/components/schemas/S/properties/p/properties/q").Foreign) - assert.Equal(t, 3, asked, "the document, its components and its schemas, then the schema stops it") -} - -// TestScopeReached_StopsAtRawYAML pins the other place the walk stops: at a value -// the model holds as raw YAML, which holds no reference the resolver resolved, -// so a pointer into an extension costs one step rather than a key scan for -// each token past it. -func TestScopeReached_StopsAtRawYAML(t *testing.T) { - t.Parallel() - asked := 0 - sc := Scope{Doc: unmarshalWalkDoc(t), Ends: func(any) (End, bool) { asked++; return End{}, false }} - - assert.False(t, atPointer(sc, "/x-lib/a/b/c").Foreign) - assert.Equal(t, 1, asked, "the document, then the extension's value stops it") - - rawAsReference := func(node any) (End, bool) { - _, raw := node.(*yaml.Node) - return End{Document: "elsewhere", Path: "x.yaml"}, raw - } - at := atPointer(Scope{Doc: unmarshalWalkDoc(t), Ends: rawAsReference}, "/extensions/x-lib/a/b") - assert.False(t, at.Foreign, "raw YAML a field holds is passed by no walk, though Ends would read it as a reference") -} - -// TestScopeReached_PassesOnlyWhatTheWalkStepsPast pins which nodes a walk passes: -// those it steps past to the next token. One where the walk finds nothing more -// is not passed, nor is the node the pointer ends at. -func TestScopeReached_PassesOnlyWhatTheWalkStepsPast(t *testing.T) { - t.Parallel() - sc := Scope{Doc: unmarshalWalkDoc(t), Ends: elsewhere} - - assert.False(t, atPointer(sc, "/nope/x").Foreign, "the walk finds nothing past the document") - assert.False(t, atPointer(sc, "").Foreign, "the pointer ends at the document") - past := atPointer(sc, "/paths/~1a") - assert.True(t, past.Foreign, "the walk steps past the document and its paths") - assert.Equal(t, "x.yaml", past.Holder) -} - -// TestScopeReached_WithoutEndsPassesNothing pins a scope with no Ends: nothing can -// be read as a reference, so every pointer names the source's own content. -func TestScopeReached_WithoutEndsPassesNothing(t *testing.T) { - t.Parallel() - sc := Scope{Doc: unmarshalWalkDoc(t), Foreign: true, Holder: "x.yaml"} - - got := atPointer(sc, "/paths/~1a/get") - assert.False(t, got.Foreign) - assert.Empty(t, got.Holder) -} diff --git a/compilers/openapi/internal/resolve/resolve_internal_test.go b/compilers/openapi/internal/resolve/resolve_internal_test.go index e1c54383..6c5a758d 100644 --- a/compilers/openapi/internal/resolve/resolve_internal_test.go +++ b/compilers/openapi/internal/resolve/resolve_internal_test.go @@ -6,9 +6,13 @@ import ( "strings" "testing" + "github.com/speakeasy-api/openapi/jsonpointer" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) // walkDoc is a document whose positions a walk reaches past a path item and an @@ -89,3 +93,144 @@ func TestScopeReached_AnEndInTheSourcesFileIsTheSources(t *testing.T) { none := Scope{}.reached(End{Document: new(int), Path: "dir/root.yaml", Pointer: "/x"}) assert.True(t, none.Foreign, "a scope with no source path names no source file") } + +// TestScopeReached_PassesAReferenceTheWalkLeavesTheModelFrom pins a walk that +// leaves the model at a reference, for raw YAML its target holds: the +// reference is passed when the target holds the next token, as the resolver +// steps past it into that YAML, and not when it holds nothing there. Holds, +// asked of the target's mapping, answers in place of the library: as the +// library's read of that mapping does, and what it answers is what is passed. +func TestScopeReached_PassesAReferenceTheWalkLeavesTheModelFrom(t *testing.T) { + t.Parallel() + doc, _, err := soa.Unmarshal(t.Context(), strings.NewReader(`openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + responses: + R: {$ref: '#/components/responses/S'} + S: {description: s, x-foo: {a: 1}} +`)) + require.NoError(t, err) + _, err = doc.ResolveAllReferences(t.Context(), soa.ResolveAllOptions{OpenAPILocation: "spec.yaml"}) + require.NoError(t, err) + elsewhere := func(node any) (End, bool) { + if r, ok := node.(*soa.ReferencedResponse); ok && r.IsReference() { + return End{Document: "elsewhere", Path: "x.yaml"}, true + } + return End{}, false + } + library := func(raw *yaml.Node, token string) bool { + _, err := jsonpointer.GetTarget(raw, jsonpointer.JSONPointer("/"+jsonpointer.EscapeString(token))) + return err == nil + } + for _, holds := range []func(*yaml.Node, string) bool{nil, library} { + sc := Scope{Doc: doc, Ends: elsewhere, Holds: holds} + assert.True(t, atPointer(sc, "/components/responses/R/x-foo/a").Foreign, "S holds x-foo") + assert.True(t, atPointer(sc, "/components/responses/R/x-foo").Foreign) + assert.False(t, atPointer(sc, "/components/responses/R/x-nope").Foreign, "S holds no x-nope") + assert.False(t, atPointer(sc, "/components/responses/S/x-foo").Foreign, "S is no reference") + } + + never := Scope{Doc: doc, Ends: elsewhere, Holds: func(*yaml.Node, string) bool { return false }} + assert.False(t, atPointer(never, "/components/responses/R/x-foo/a").Foreign, "Holds, not the library, answers") + always := Scope{Doc: doc, Ends: elsewhere, Holds: func(*yaml.Node, string) bool { return true }} + assert.True(t, atPointer(always, "/components/responses/R/x-nope").Foreign, "Holds, not the library, answers") + assert.False(t, atPointer(always, "/components/responses/S/x-foo").Foreign, "only a reference asks it") +} + +// TestScopeReached_StepsPastAnIndexTheLibraryRetries pins the walk past an +// index the library retries, reading it as a key with the rest below it and as +// an index once that fails: what it steps past is still noted a step at a +// time. +func TestScopeReached_StepsPastAnIndexTheLibraryRetries(t *testing.T) { + t.Parallel() + var built yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("{'0': {j: 5}}"), &built)) + js := openapitest.SchemaFromYAML(t.Context(), t, "type: string\n") + doc := &indexedDoc{built: built.Content[0], schema: js} + for _, ends := range []func(any) (End, bool){nil, elsewhere} { + sc := Scope{Doc: doc, Ends: ends} + assert.Same(t, js, wholeModelAt(doc, "/0/k"), "sanity: the index answers k, which the mapping's 0 does not hold") + assert.Equal(t, ends != nil, atPointer(sc, "/0/k").Foreign, "the walk noted the document it stepped past") + } +} + +// TestScopeReached_PassesAReferenceUnderADefaultResponse pins that the walk goes on +// past an operation's responses, which the model holds by value: a default +// response that is a reference is passed, as one under a status code is. +func TestScopeReached_PassesAReferenceUnderADefaultResponse(t *testing.T) { + t.Parallel() + responses := func(node any) (End, bool) { + if _, ok := node.(*soa.ReferencedResponse); ok { + return End{Document: "elsewhere", Path: "x.yaml"}, true + } + return End{}, false + } + sc := Scope{Doc: unmarshalWalkDoc(t), Ends: responses} + for _, code := range []string{"default", "200"} { + at := atPointer(sc, jsontext.Pointer("/paths/~1a/get/responses/"+code+"/description")) + assert.True(t, at.Foreign, "the %s response is passed", code) + assert.Equal(t, "x.yaml", at.Holder, code) + } +} + +// TestScopeReached_StopsAtASchema pins where the walk stops reading references: at +// the first schema, since the resolver reads a schema's own $ref as a keyword, +// so nothing past it is asked whether it is one. +func TestScopeReached_StopsAtASchema(t *testing.T) { + t.Parallel() + asked := 0 + sc := Scope{Doc: unmarshalWalkDoc(t), Ends: func(any) (End, bool) { asked++; return End{}, false }} + + assert.False(t, atPointer(sc, "/components/schemas/S/properties/p/properties/q").Foreign) + assert.Equal(t, 3, asked, "the document, its components and its schemas, then the schema stops it") +} + +// TestScopeReached_StopsAtRawYAML pins the other place the walk stops: at a value +// the model holds as raw YAML, which holds no reference the resolver resolved, +// so a pointer into an extension costs one step rather than a key scan for +// each token past it. +func TestScopeReached_StopsAtRawYAML(t *testing.T) { + t.Parallel() + asked := 0 + sc := Scope{Doc: unmarshalWalkDoc(t), Ends: func(any) (End, bool) { asked++; return End{}, false }} + + assert.False(t, atPointer(sc, "/x-lib/a/b/c").Foreign) + assert.Equal(t, 1, asked, "the document, then the extension's value stops it") + + rawAsReference := func(node any) (End, bool) { + _, raw := node.(*yaml.Node) + return End{Document: "elsewhere", Path: "x.yaml"}, raw + } + at := atPointer(Scope{Doc: unmarshalWalkDoc(t), Ends: rawAsReference}, "/extensions/x-lib/a/b") + assert.False(t, at.Foreign, "raw YAML a field holds is passed by no walk, though Ends would read it as a reference") +} + +// TestScopeReached_PassesOnlyWhatTheWalkStepsPast pins which nodes a walk passes: +// those it steps past to the next token. One where the walk finds nothing more +// is not passed, nor is the node the pointer ends at. +func TestScopeReached_PassesOnlyWhatTheWalkStepsPast(t *testing.T) { + t.Parallel() + sc := Scope{Doc: unmarshalWalkDoc(t), Ends: elsewhere} + + assert.False(t, atPointer(sc, "/nope/x").Foreign, "the walk finds nothing past the document") + assert.False(t, atPointer(sc, "").Foreign, "the pointer ends at the document") + past := atPointer(sc, "/paths/~1a") + assert.True(t, past.Foreign, "the walk steps past the document and its paths") + assert.Equal(t, "x.yaml", past.Holder) +} + +// TestScopeReached_WithoutEndsPassesNothing pins a scope with no Ends: nothing can +// be read as a reference, so every pointer names the source's own content. +func TestScopeReached_WithoutEndsPassesNothing(t *testing.T) { + t.Parallel() + sc := Scope{Doc: unmarshalWalkDoc(t), Foreign: true, Holder: "x.yaml"} + + got := atPointer(sc, "/paths/~1a/get") + assert.False(t, got.Foreign) + assert.Empty(t, got.Holder) +} + +// elsewhere is an Ends that reads every node it is asked about as a reference +// ending in another document, so any node a walk steps past shows. +func elsewhere(any) (End, bool) { return End{Document: "elsewhere", Path: "x.yaml"}, true } diff --git a/compilers/openapi/internal/scan/amplification_internal_test.go b/compilers/openapi/internal/scan/amplification_internal_test.go index ca398254..348d6791 100644 --- a/compilers/openapi/internal/scan/amplification_internal_test.go +++ b/compilers/openapi/internal/scan/amplification_internal_test.go @@ -1,6 +1,7 @@ package scan import ( + "encoding/json/jsontext" "fmt" "math" "os" @@ -9,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" @@ -552,3 +553,68 @@ func TestYAMLUnmarshal_IntoANodeSpendsNoAliasBudget(t *testing.T) { require.Error(t, err, "the same bytes expanded into a Go value trip the guard") assert.Contains(t, err.Error(), "excessive aliasing") } + +// TestAliasAmplification_NamesTheLeastInnermostAmplifier pins the site of an +// alias bomb: the smallest structure already past the allowance, l4 of laughs' +// eight levels, not the bomb or the level holding it; and of two bombs, the one +// with the least pointer, in either order. +func TestAliasAmplification_NamesTheLeastInnermostAmplifier(t *testing.T) { + t.Parallel() + for order, data := range bothOrders([]string{laughs("b"), laughs("a")}) { + t.Run(order, func(t *testing.T) { + t.Parallel() + d := onlyRefusal(t, data) + assert.Equal(t, diag.AliasAmplification, d.Code) + assert.Equal(t, jsontext.Pointer("/a/l4"), d.Provenance.Pointer) + }) + } +} + +// TestAliasAmplification_NamesTheLeastSiteUnderABudget pins the two budgeted +// branches, in either order: two lists each adding more than the budget allows +// but staying inside the ratio, named for the budget; and, under the same +// budget, two bombs past the ratio, named for their shape. +func TestAliasAmplification_NamesTheLeastSiteUnderABudget(t *testing.T) { + t.Parallel() + tests := []struct { + name string + members []string + code string + want jsontext.Pointer + }{ + {"past the budget", []string{fanned("b"), fanned("a")}, diag.BudgetExceeded, "/a/u"}, + {"past the ratio", []string{laughs("b"), laughs("a")}, diag.AliasAmplification, "/a/l4"}, + } + for _, tc := range tests { + for order, data := range bothOrders(tc.members) { + t.Run(tc.name+"/"+order, func(t *testing.T) { + t.Parallel() + root := indexOf(t, data).Root() + d, refused := aliasAmplification(InTree(0, Pointers(root)), root, rawNodes(root), 100) + require.True(t, refused) + assert.Equal(t, tc.code, d.Code) + assert.Equal(t, tc.want, d.Provenance.Pointer) + }) + } + } +} + +// fanned is an anchor of ten scalars under name, aliased twenty times in a +// list beside it. +func fanned(name string) string { + return fmt.Sprintf("%s: {x: &%s [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], u: [%s]}\n", name, name, + strings.TrimSuffix(strings.Repeat("*"+name+", ", 20), ", ")) +} + +// laughs is a billion-laughs bomb named prefix: eight anchors, the first a list +// of ten scalars and each after it a list of ten aliases to the one before. +func laughs(prefix string) string { + const levels = 8 + var b strings.Builder + fmt.Fprintf(&b, "%s:\n l0: &%s0 [x, x, x, x, x, x, x, x, x, x]\n", prefix, prefix) + for i := 1; i < levels; i++ { + aliases := strings.TrimSuffix(strings.Repeat(fmt.Sprintf("*%s%d, ", prefix, i-1), 10), ", ") + fmt.Fprintf(&b, " l%d: &%s%d [%s]\n", i, prefix, i, aliases) + } + return b.String() +} diff --git a/compilers/openapi/internal/scan/site_internal_test.go b/compilers/openapi/internal/scan/site_internal_test.go index c1c3ea5a..580b35b5 100644 --- a/compilers/openapi/internal/scan/site_internal_test.go +++ b/compilers/openapi/internal/scan/site_internal_test.go @@ -273,68 +273,3 @@ func TestAliases_NamesTheLeastRecursiveAlias(t *testing.T) { }) } } - -// laughs is a billion-laughs bomb named prefix: eight anchors, the first a list -// of ten scalars and each after it a list of ten aliases to the one before. -func laughs(prefix string) string { - const levels = 8 - var b strings.Builder - fmt.Fprintf(&b, "%s:\n l0: &%s0 [x, x, x, x, x, x, x, x, x, x]\n", prefix, prefix) - for i := 1; i < levels; i++ { - aliases := strings.TrimSuffix(strings.Repeat(fmt.Sprintf("*%s%d, ", prefix, i-1), 10), ", ") - fmt.Fprintf(&b, " l%d: &%s%d [%s]\n", i, prefix, i, aliases) - } - return b.String() -} - -// TestAliasAmplification_NamesTheLeastInnermostAmplifier pins the site of an -// alias bomb: the smallest structure already past the allowance, l4 of laughs' -// eight levels, not the bomb or the level holding it; and of two bombs, the one -// with the least pointer, in either order. -func TestAliasAmplification_NamesTheLeastInnermostAmplifier(t *testing.T) { - t.Parallel() - for order, data := range bothOrders([]string{laughs("b"), laughs("a")}) { - t.Run(order, func(t *testing.T) { - t.Parallel() - d := onlyRefusal(t, data) - assert.Equal(t, diag.AliasAmplification, d.Code) - assert.Equal(t, jsontext.Pointer("/a/l4"), d.Provenance.Pointer) - }) - } -} - -// fanned is an anchor of ten scalars under name, aliased twenty times in a -// list beside it. -func fanned(name string) string { - return fmt.Sprintf("%s: {x: &%s [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], u: [%s]}\n", name, name, - strings.TrimSuffix(strings.Repeat("*"+name+", ", 20), ", ")) -} - -// TestAliasAmplification_NamesTheLeastSiteUnderABudget pins the two budgeted -// branches, in either order: two lists each adding more than the budget allows -// but staying inside the ratio, named for the budget; and, under the same -// budget, two bombs past the ratio, named for their shape. -func TestAliasAmplification_NamesTheLeastSiteUnderABudget(t *testing.T) { - t.Parallel() - tests := []struct { - name string - members []string - code string - want jsontext.Pointer - }{ - {"past the budget", []string{fanned("b"), fanned("a")}, diag.BudgetExceeded, "/a/u"}, - {"past the ratio", []string{laughs("b"), laughs("a")}, diag.AliasAmplification, "/a/l4"}, - } - for _, tc := range tests { - for order, data := range bothOrders(tc.members) { - t.Run(tc.name+"/"+order, func(t *testing.T) { - t.Parallel() - root := indexOf(t, data).Root() - d, refused := aliasAmplification(InTree(0, Pointers(root)), root, rawNodes(root), 100) - require.True(t, refused) - assert.Equal(t, tc.code, d.Code) - assert.Equal(t, tc.want, d.Provenance.Pointer) - }) - } - } -} diff --git a/pass/validate_graphql_test.go b/pass/validate_graphql_test.go index a43a8420..4c052ce0 100644 --- a/pass/validate_graphql_test.go +++ b/pass/validate_graphql_test.go @@ -162,3 +162,40 @@ func TestValidate_ArgsCheckFailsOpenWhenWalkTruncated(t *testing.T) { "past the cap the binding went unseen; unreachable-by-truncation is not evidence of illegality") assert.Contains(t, past, "ir/walk-truncated", "and the truncation must be reported instead") } + +// TestCollectTypeIDs_DeepValueTreeIsTruncated drives what this pass does with a +// truncated walk: a value tree nested past the shared cap must not be silently +// under-checked, so the flag comes back out of the collector and Validate turns +// it into a diagnostic rather than claiming the document is referentially closed. +// The bound itself is ir.MaxWalkDepth's to hold; this is the reporting half. +func TestCollectTypeIDs_DeepValueTreeIsTruncated(t *testing.T) { + t.Parallel() + v := ir.Value{Kind: ir.ValueNull} + for range ir.MaxWalkDepth { + v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} + } + m := &ir.Model{ID: "t/m", Examples: []ir.Example{{Value: &v}}} + doc := &ir.Document{Types: ir.TypeRegistry{m.ID: m}} + + _, truncated := pass.CollectTypeIDs(doc, "doc") + assert.True(t, truncated, "a tree nested past the cap must report truncation") + + diags := danglingRefs(doc) + require.NotEmpty(t, diags) + assert.Equal(t, "ir/walk-truncated", diags[0].Code) + assert.Equal(t, ir.SeverityError, diags[0].Severity) +} + +// TestCollectTypeIDs_UnmodeledBytesYieldNoReference states the result the byte +// payloads must produce: a preserved JSON blob is opaque data, so bytes that +// happen to spell a type ID are not a reference. +func TestCollectTypeIDs_UnmodeledBytesYieldNoReference(t *testing.T) { + t.Parallel() + doc := &ir.Document{Unmodeled: ir.Unmodeled{"openapi:x-thing": { + Reason: ir.ReasonVendorExtension, + Value: ir.RawValue(`"t/ghost/in-bytes"`), + }}} + sites, truncated := pass.CollectTypeIDs(doc, "doc") + assert.False(t, truncated) + assert.Empty(t, sites) +} diff --git a/pass/validate_refs_test.go b/pass/validate_refs_test.go index 6e88d393..3ce8fba1 100644 --- a/pass/validate_refs_test.go +++ b/pass/validate_refs_test.go @@ -830,43 +830,6 @@ func TestValidate_PerOperationAuthOverride(t *testing.T) { assert.Contains(t, codes(diags), "ir/dangling-auth-ref") } -// TestCollectTypeIDs_DeepValueTreeIsTruncated drives what this pass does with a -// truncated walk: a value tree nested past the shared cap must not be silently -// under-checked, so the flag comes back out of the collector and Validate turns -// it into a diagnostic rather than claiming the document is referentially closed. -// The bound itself is ir.MaxWalkDepth's to hold; this is the reporting half. -func TestCollectTypeIDs_DeepValueTreeIsTruncated(t *testing.T) { - t.Parallel() - v := ir.Value{Kind: ir.ValueNull} - for range ir.MaxWalkDepth { - v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} - } - m := &ir.Model{ID: "t/m", Examples: []ir.Example{{Value: &v}}} - doc := &ir.Document{Types: ir.TypeRegistry{m.ID: m}} - - _, truncated := pass.CollectTypeIDs(doc, "doc") - assert.True(t, truncated, "a tree nested past the cap must report truncation") - - diags := danglingRefs(doc) - require.NotEmpty(t, diags) - assert.Equal(t, "ir/walk-truncated", diags[0].Code) - assert.Equal(t, ir.SeverityError, diags[0].Severity) -} - -// TestCollectTypeIDs_UnmodeledBytesYieldNoReference states the result the byte -// payloads must produce: a preserved JSON blob is opaque data, so bytes that -// happen to spell a type ID are not a reference. -func TestCollectTypeIDs_UnmodeledBytesYieldNoReference(t *testing.T) { - t.Parallel() - doc := &ir.Document{Unmodeled: ir.Unmodeled{"openapi:x-thing": { - Reason: ir.ReasonVendorExtension, - Value: ir.RawValue(`"t/ghost/in-bytes"`), - }}} - sites, truncated := pass.CollectTypeIDs(doc, "doc") - assert.False(t, truncated) - assert.Empty(t, sites) -} - // TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations holds the one direction // a declaration-derived registry can be wrong in. The operation the shallow // reference names is declared, but past the walk cap, so a registry built from From 1b635005c987edb17b4286a0c1c85cd91a83822a Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 02:58:06 +0300 Subject: [PATCH 06/18] test: split the largest test files by topic Eight test files ran past 1,500 lines, schema's allof_test.go to 3,079, so a reader looking for one construct's tests scrolled past every other's. Each splits into topic files that keep its name as their prefix and so sort beside it: allof into nesting, required, residue, conflicts and narrowing; discriminator into beside_union, mapping and allof; union into oneof and combinators; operation's pathitemref into unmounted, collisions, references and fields, and params into querystring, schema and identity; merge's fold cases into fold_docs, fold_visibility, fold_narrowing and fold_types; and load's unprepared into external and reads, and reached's resolve tests into reached_resolve. Every test file in the repository is now under 1,250 lines. A test goes to the topic its name places it in, and a helper only the tests of one topic use goes with them. Helpers several files use now sit in their package's helpers file, which merge gains for its visibility fixtures, whose comment no longer calls the visibility cases "below". Every declaration is otherwise byte-identical, and the packages' tests and subtests are the same before and after. --- .../internal/load/helpers_internal_test.go | 51 + .../internal/load/reached_internal_test.go | 1181 ------- .../load/reached_resolve_internal_test.go | 1187 +++++++ .../load/unprepared_external_internal_test.go | 630 ++++ .../internal/load/unprepared_internal_test.go | 792 ----- .../load/unprepared_reads_internal_test.go | 164 + .../merge/fold_cases_internal_test.go | 900 ------ .../internal/merge/fold_docs_internal_test.go | 133 + .../merge/fold_narrowing_internal_test.go | 269 ++ .../merge/fold_types_internal_test.go | 461 +++ .../merge/fold_visibility_internal_test.go | 77 + .../internal/merge/helpers_internal_test.go | 13 + .../internal/operation/helpers_test.go | 11 + .../operation/params_identity_test.go | 565 ++++ .../operation/params_querystring_test.go | 186 ++ .../internal/operation/params_schema_test.go | 576 ++++ .../openapi/internal/operation/params_test.go | 1300 -------- .../operation/pathitemref_collisions_test.go | 206 ++ .../operation/pathitemref_fields_test.go | 231 ++ .../operation/pathitemref_references_test.go | 810 +++++ .../internal/operation/pathitemref_test.go | 1340 -------- .../operation/pathitemref_unmounted_test.go | 155 + .../internal/schema/allof_conflicts_test.go | 912 ++++++ .../internal/schema/allof_narrowing_test.go | 637 ++++ .../internal/schema/allof_nesting_test.go | 742 +++++ .../internal/schema/allof_required_test.go | 295 ++ .../internal/schema/allof_residue_test.go | 294 ++ .../openapi/internal/schema/allof_test.go | 2858 +---------------- .../schema/discriminator_allof_test.go | 479 +++ .../schema/discriminator_beside_union_test.go | 534 +++ .../schema/discriminator_mapping_test.go | 705 ++++ .../internal/schema/discriminator_test.go | 1690 ---------- .../openapi/internal/schema/helpers_test.go | 29 + .../internal/schema/union_combinators_test.go | 325 ++ .../internal/schema/union_oneof_test.go | 569 ++++ .../openapi/internal/schema/union_test.go | 863 ----- 36 files changed, 11265 insertions(+), 10905 deletions(-) create mode 100644 compilers/openapi/internal/load/reached_resolve_internal_test.go create mode 100644 compilers/openapi/internal/load/unprepared_external_internal_test.go create mode 100644 compilers/openapi/internal/load/unprepared_reads_internal_test.go create mode 100644 compilers/openapi/internal/merge/fold_docs_internal_test.go create mode 100644 compilers/openapi/internal/merge/fold_narrowing_internal_test.go create mode 100644 compilers/openapi/internal/merge/fold_types_internal_test.go create mode 100644 compilers/openapi/internal/merge/fold_visibility_internal_test.go create mode 100644 compilers/openapi/internal/merge/helpers_internal_test.go create mode 100644 compilers/openapi/internal/operation/params_identity_test.go create mode 100644 compilers/openapi/internal/operation/params_querystring_test.go create mode 100644 compilers/openapi/internal/operation/params_schema_test.go create mode 100644 compilers/openapi/internal/operation/pathitemref_collisions_test.go create mode 100644 compilers/openapi/internal/operation/pathitemref_fields_test.go create mode 100644 compilers/openapi/internal/operation/pathitemref_references_test.go create mode 100644 compilers/openapi/internal/operation/pathitemref_unmounted_test.go create mode 100644 compilers/openapi/internal/schema/allof_conflicts_test.go create mode 100644 compilers/openapi/internal/schema/allof_narrowing_test.go create mode 100644 compilers/openapi/internal/schema/allof_nesting_test.go create mode 100644 compilers/openapi/internal/schema/allof_required_test.go create mode 100644 compilers/openapi/internal/schema/allof_residue_test.go create mode 100644 compilers/openapi/internal/schema/discriminator_allof_test.go create mode 100644 compilers/openapi/internal/schema/discriminator_beside_union_test.go create mode 100644 compilers/openapi/internal/schema/discriminator_mapping_test.go create mode 100644 compilers/openapi/internal/schema/union_combinators_test.go create mode 100644 compilers/openapi/internal/schema/union_oneof_test.go diff --git a/compilers/openapi/internal/load/helpers_internal_test.go b/compilers/openapi/internal/load/helpers_internal_test.go index dc5ab2dc..690437b4 100644 --- a/compilers/openapi/internal/load/helpers_internal_test.go +++ b/compilers/openapi/internal/load/helpers_internal_test.go @@ -2,6 +2,7 @@ package load import ( "slices" + "strings" "testing" soa "github.com/speakeasy-api/openapi/openapi" @@ -10,6 +11,7 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/overlay" "github.com/dexpace/morphic/engine/reader" "github.com/dexpace/morphic/ir" ) @@ -100,3 +102,52 @@ func response200(t *testing.T, doc *soa.OpenAPI, path string) *soa.ReferencedRes require.True(t, ok, path) return resp } + +// markerPosition returns the line and column, both counted from 1, at which +// marker starts in spec. The marker must occur once, so a fixture edited to +// repeat it fails here rather than naming the wrong node. +func markerPosition(t *testing.T, spec, marker string) (line, col int) { + t.Helper() + require.Equal(t, 1, strings.Count(spec, marker), "the marker %q must occur once in the fixture", marker) + at := strings.Index(spec, marker) + return 1 + strings.Count(spec[:at], "\n"), at - strings.LastIndex(spec[:at], "\n") +} + +// resolveSpecWith is resolveSpec at root.yaml with a caller-supplied rebuild, for +// the tests that need to observe or fail whether it is called. +func resolveSpecWith(t *testing.T, spec string, rebuild func() (*soa.OpenAPI, error)) (*soa.OpenAPI, []ir.Diagnostic, error) { + t.Helper() + resolved, _, _, pass, missed, err := resolveExternal(t.Context(), modelOf(t, spec), + Siblings{}, sourceDocument{path: "root.yaml"}, Options{Reader: reader.Disk{}}, rebuild) + return resolved, passDiags(pointerAt(0, overlay.Origin{}), pass, missed), err +} + +// respelled returns url with its scheme spelled scheme, which net/url respells +// back to "http" in the request built from it. +func respelled(url, scheme string) string { + return scheme + "://" + strings.TrimPrefix(url, "http://") +} + +// respellings is the set of URL spellings net/url respells relative to how +// http.NewRequest's caller wrote them (GitHub #538): the resolver keys a +// document by the reference's absolute URL as written, while the reader stores +// it under req.URL.String(). +// +// A literal space in the path is absent: url.Parse and http.NewRequest +// normalize it and its %20 escape to one request key, so the two spellings +// never produce different keys. An empty port is covered by TestPreparedFor, +// since a test server cannot listen on the default port. +var respellings = []struct { + name string + spell func(canonical string) string +}{ + {"upper-case scheme", func(c string) string { + return "HTTP://" + strings.TrimPrefix(c, "http://") + }}, + {"mixed-case scheme", func(c string) string { + return "Http://" + strings.TrimPrefix(c, "http://") + }}, + {"escaped userinfo", func(c string) string { + return strings.Replace(c, "http://", "http://us%65r@", 1) + }}, +} diff --git a/compilers/openapi/internal/load/reached_internal_test.go b/compilers/openapi/internal/load/reached_internal_test.go index fbf8c7a2..697d6410 100644 --- a/compilers/openapi/internal/load/reached_internal_test.go +++ b/compilers/openapi/internal/load/reached_internal_test.go @@ -5,15 +5,10 @@ import ( "errors" "fmt" "math" - "net/http" - "net/http/httptest" "os" "path/filepath" - "slices" - "strings" "testing" - "github.com/google/go-cmp/cmp" "github.com/speakeasy-api/openapi/marshaller" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" @@ -322,1182 +317,6 @@ func TestCheckReached_APanicReadingTheWalkIsReported(t *testing.T) { assert.Equal(t, jsontext.Pointer(""), diags[0].Provenance.Pointer) } -// reachRootFixture and reachOtherFixture are GitHub #545's repro. root.yaml -// reaches every kind of object other.yaml's findings live in: -// a path item (twice, /x and /y, to prove the once behaviour incidentally), an -// operation's own inline parameter, a reused parameter component (twice, to -// prove it again), a response, and a schema. /unreached and the numeric -// literal in P's schema are never reached by anything root.yaml writes. -const reachRootFixture = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /x: {$ref: "./other.yaml#/paths/~1x"} - /y: {$ref: "./other.yaml#/paths/~1x"} - /z: - get: - operationId: getZ - parameters: - - $ref: "./other.yaml#/components/parameters/P" - - $ref: "./other.yaml#/components/parameters/P" - responses: - "200": {$ref: "./other.yaml#/components/responses/R"} -components: - schemas: - S: {$ref: "./other.yaml#/components/schemas/Bad"} -` - -const reachOtherFixture = `openapi: 3.1.0 -info: {title: O, version: "1"} -paths: - /x: - get: - operationId: getX - security: - - nosuch: [] - parameters: - - {name: q, schema: {type: string}} - - {$ref: "#/components/parameters/P"} - responses: {"200": {description: ok}} - /unreached: - get: - operationId: getU - parameters: - - {name: u, in: nowhere, schema: {type: string}} - responses: {"200": {description: ok}} -components: - parameters: - P: {name: p, in: sideways, schema: {type: string, minimum: .5}} - responses: - R: - description: ok - content: - application/json: - schema: {type: string, minLength: -1} - schemas: - Bad: - type: object - minProperties: -3 - deprecated: "yes" -` - -// reachWantDiags is the exact diagnostic list -// TestResolve_ValidatesWhatAnExternalReferenceReaches expects: the findings, -// each placed in other.yaml's entry at the node it is about, in the order of -// the sites that reached them, then the repeat /z's list writes by naming P -// twice. -func reachWantDiags() []ir.Diagnostic { - at := func(code, message, pointer string, line, column int) ir.Diagnostic { - return ir.Diagnostic{ - Severity: ir.SeverityError, Code: diag.Validation + "/" + code, Message: message, - Provenance: ir.Provenance{Source: 1, Pointer: jsontext.Pointer(pointer), - Position: ir.Position{Line: line, Column: column}}, - } - } - return []ir.Diagnostic{ - at("validation-type-mismatch", - `Schema Object field "minProperties" must be a non-negative integer in OpenAPI 3.1 (Schema Object)`, - "/components/schemas/Bad/minProperties", 31, 22), - at("validation-type-mismatch", `Schema Object field "deprecated" must be a boolean in OpenAPI 3.1 (Schema Object)`, - "/components/schemas/Bad/deprecated", 32, 19), - at("validation-required-field", `Parameter Object field "in" is REQUIRED in OpenAPI 3.1 (Parameter Object)`, - "/paths/~1x/get/parameters/0", 10, 11), - at("validation-allowed-values", - `Parameter Object field "in" must be one of "query", "header", "path", "cookie" in OpenAPI 3.1 `+ - `(Parameter Object)`, "/components/parameters/P/in", 21, 22), - at("validation-type-mismatch", - `Schema Object field "minLength" must be a non-negative integer in OpenAPI 3.1 (Schema Object)`, - "/components/responses/R/content/application~1json/schema/minLength", 27, 45), - { - Severity: ir.SeverityError, - Code: diag.DuplicateParameter, - Message: `parameter "p" in "sideways" repeats the entry at /paths/~1z/get/parameters/0`, - Provenance: ir.Provenance{Source: 0, Pointer: "/paths/~1z/get/parameters/1"}, - }, - } -} - -// TestResolve_ValidatesWhatAnExternalReferenceReaches is GitHub #545's repro: -// compiled as the source, other.yaml raises more findings than root.yaml -// reaches, and only what is reached is checked at all. The numeric literal P's -// schema writes (minimum: .5) is a number, so it draws none, and nothing is -// reported from /unreached, which nothing in root.yaml names. -func TestResolve_ValidatesWhatAnExternalReferenceReaches(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": reachOtherFixture}) - - _, diags := loadExternal(t, dir, reachRootFixture, Options{}) - - if d := cmp.Diff(reachWantDiags(), diags); d != "" { - t.Errorf("diagnostics did not match (-want +got):\n%s", d) - } - for _, d := range diags { - assert.NotEqual(t, ir.Position{Line: 17, Column: 25}, d.Provenance.Position, - "/unreached's own finding must not appear: %+v", d) - assert.NotEqual(t, ir.Position{Line: 21, Column: 40}, d.Provenance.Position, - "the numeric literal must not appear: %+v", d) - } -} - -// TestResolve_WhatTheSourcesValidationNeverCheckedIsValidated pins what a $ref -// naming the source's file reaches that the source's own check never read: -// raw YAML, such as an extension's value. It is checked as an object -// in another document is, whether the $ref is the source's own or comes back -// into it from another document (GitHub #759), and its finding is placed at the -// node it is about (GitHub #879). A node of the grammar a schema $ref names, or -// raw YAML that is no schema, is not read as a schema, so it is reported -// unresolved, once (GitHub #769), and nothing in it is validated as one. -func TestResolve_WhatTheSourcesValidationNeverCheckedIsValidated(t *testing.T) { - t.Parallel() - const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" - for _, c := range []struct { - name string - files map[string]string - root string - want []string - }{ - {"a schema under an extension", nil, header + `paths: {} -components: - schemas: - A: {$ref: 'root.yaml#/x-defs/S'} -x-defs: - S: {type: string, minLength: -1} -`, []string{"/x-defs/S/minLength openapi/validation/validation-type-mismatch"}}, - { - "a response under an extension, from another document", - map[string]string{"other.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/x-responses/R'}\n"}, - header + `paths: - /a: - get: - responses: - "200": {$ref: 'other.yaml#/components/responses/Back'} -x-responses: - R: - description: ok - links: - L: {operationId: a, operationRef: '#/paths/~1a/get'} -`, []string{"/x-responses/R/links/L openapi/validation/validation-mutually-exclusive-fields"}, - }, - {"a response named as a schema", nil, header + `paths: {} -components: - schemas: - S: {$ref: 'root.yaml#/components/responses/R'} - responses: - R: {description: ok, minLength: -1} -`, []string{"/components/schemas/S openapi/unresolved-ref"}}, - {"raw YAML holding no schema", nil, header + `paths: {} -components: - schemas: - A: {$ref: '#/x-lib/S'} - B: {$ref: '#/x-lib/N'} -x-lib: - S: just a string - N: null -`, []string{"/components/schemas/A openapi/unresolved-ref", "/components/schemas/B openapi/unresolved-ref"}}, - } { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - _, diags := loadExternal(t, externalDir(t, c.files), c.root, Options{}) - - assert.Empty(t, cmp.Diff(c.want, diagLines(diags))) - }) - } -} - -// TestResolve_AComponentTheYAMLWritesAsNoSchemaIsTheParsers pins that a -// component the YAML writes as no schema, here a string, holds the schema the -// parser built there, as the lowering reads it: a $ref to it names it, and the -// rule table's finding that it is no schema is the one report, where raw YAML -// holding no schema is reported unresolved (GitHub #769). -func TestResolve_AComponentTheYAMLWritesAsNoSchemaIsTheParsers(t *testing.T) { - t.Parallel() - _, diags := loadExternal(t, t.TempDir(), `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: {} -components: - schemas: - A: {$ref: '#/components/schemas/S'} - S: just a string -`, Options{}) - - assert.Empty(t, cmp.Diff([]string{"/components/schemas/S openapi/validation/validation-type-mismatch"}, - diagLines(diags)), "the table's finding, and no unresolved $ref") -} - -// TestResolve_ANodeReadAsAnotherKindReportsNothingTheSourceDid pins what a node -// a $ref reads as another kind holds: what the source declares inside it, here -// a header's schema, whose finding the source's own validation reported. That -// finding is reported once, by the source, and not again for the $ref (GitHub -// #759). -func TestResolve_ANodeReadAsAnotherKindReportsNothingTheSourceDid(t *testing.T) { - t.Parallel() - _, diags := loadExternal(t, t.TempDir(), `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - get: - parameters: [{$ref: 'root.yaml#/components/headers/H'}] - responses: {"200": {description: ok}} -components: - headers: - H: {name: h, in: query, schema: {type: string, minLength: -1}} -`, Options{}) - - assert.Empty(t, cmp.Diff([]string{ - "/components/headers/H/schema/minLength openapi/validation/validation-type-mismatch", - }, diagLines(diags)), "the source's own finding, once, as the header it declares: the $ref's reading as a parameter adds none") -} - -// TestResolve_EveryKindOfObjectIsValidated reaches each kind of object a $ref -// names that the other tests do not, a header, request body, callback, -// example, link and security scheme, each with a defect only its own table -// entry finds. Each finding is placed in other.yaml's entry at the node it is -// about, under the rule and severity the table gives it. -func TestResolve_EveryKindOfObjectIsValidated(t *testing.T) { - t.Parallel() - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - get: - operationId: a - requestBody: {$ref: './other.yaml#/components/requestBodies/B'} - callbacks: {cb: {$ref: './other.yaml#/components/callbacks/C'}} - responses: - "200": - description: ok - headers: {X: {$ref: './other.yaml#/components/headers/H'}} - links: {l: {$ref: './other.yaml#/components/links/L'}} - content: {application/json: {examples: {e: {$ref: './other.yaml#/components/examples/E'}}}} -components: - securitySchemes: - s: {$ref: './other.yaml#/components/securitySchemes/S'} -` - dir := externalDir(t, map[string]string{"other.yaml": `components: - headers: - H: {style: form, schema: {type: string}} - requestBodies: - B: {content: {application/json: {schema: {minLength: -1}}}} - callbacks: - C: {'{$bogus': {get: {responses: {"200": {description: ok}}}}} - examples: - E: {value: 1, externalValue: 'https://x.test/e'} - links: - L: {operationId: a, operationRef: '#/paths/~1a/get'} - securitySchemes: - S: {type: bogus} -`}) - - _, diags := loadExternal(t, dir, root, Options{}) - - got := make([]string, 0, len(diags)) - for _, d := range diags { - got = append(got, fmt.Sprintf("%d %s %s %s", d.Provenance.Source, d.Provenance.Pointer, d.Severity, d.Code)) - } - want := []string{ - "1 /components/securitySchemes/S/type error openapi/validation/validation-allowed-values", - "1 /components/callbacks/C/{$bogus error openapi/validation/validation-invalid-format", - "1 /components/requestBodies/B/content/application~1json/schema/minLength error " + - "openapi/validation/validation-type-mismatch", - "1 /components/examples/E error openapi/validation/validation-mutually-exclusive-fields", - "1 /components/headers/H/style error openapi/validation/validation-allowed-values", - "1 /components/links/L error openapi/validation/validation-mutually-exclusive-fields", - } - if d := cmp.Diff(want, got); d != "" { - t.Errorf("findings (-want +got):\n%s", d) - } -} - -// TestResolve_AnObjectReachedTwiceIsReportedOnce covers the ways one node can -// be reached more than once: two $refs to one component, a container reached -// beside something inside it, at the lesser $ref and at the greater, and one -// node read as two kinds. Each finding is placed at its node in other.yaml, -// whichever $ref is declared first. Inside a container, an object is validated -// twice, and charged twice, and its finding reported once. -func TestResolve_AnObjectReachedTwiceIsReportedOnce(t *testing.T) { - t.Parallel() - compile := func(t *testing.T, other, root string, opts Options) []ir.Diagnostic { - t.Helper() - _, diags := loadExternal(t, externalDir(t, map[string]string{"other.yaml": other}), root, opts) - return diags - } - - t.Run("two refs to one component report once, at the node", func(t *testing.T) { - t.Parallel() - other := `openapi: 3.1.0 -info: {title: O, version: "1"} -paths: {} -components: - parameters: - P: {name: p, in: sideways, schema: {type: string}} -` - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /b: - get: {operationId: getB, parameters: [{$ref: "./other.yaml#/components/parameters/P"}], responses: {"200": {description: ok}}} - /a: - get: {operationId: getA, parameters: [{$ref: "./other.yaml#/components/parameters/P"}], responses: {"200": {description: ok}}} -` - diags := compile(t, other, root, Options{}) - - require.Len(t, diags, 1, "%+v", diags) - assert.Equal(t, ir.Provenance{Source: 1, Pointer: "/components/parameters/P/in", - Position: ir.Position{Line: 6, Column: 22}}, diags[0].Provenance) - }) - - other := `openapi: 3.1.0 -info: {title: O, version: "1"} -paths: - /x: - get: - operationId: getX - parameters: - - name: q - in: sideways - schema: {type: string} - responses: {"200": {description: ok}} -` - containedFirst := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /b: - get: - operationId: getB - parameters: - - $ref: "./other.yaml#/paths/~1x/get/parameters/0" - responses: {"200": {description: ok}} - /a: {$ref: "./other.yaml#/paths/~1x"} -` - - t.Run("a container at the lesser $ref and what it holds report once", func(t *testing.T) { - t.Parallel() - diags := compile(t, other, containedFirst, Options{}) - - require.Len(t, diags, 1, "the parameter the container holds draws it again, and that copy is dropped: %+v", diags) - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) - }) - - t.Run("what a container holds is charged again", func(t *testing.T) { - t.Parallel() - diags := compile(t, nestedOtherFixture(), containedFirst, Options{MaxSourceNodes: 150, MaxAliasSurplus: 1}) - - require.Len(t, diags, 2, "the container fits this budget and its parameter, charged again, "+ - "does not: %+v", diags) - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) - assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) - assert.Equal(t, jsontext.Pointer("/paths/~1b/get/parameters/0"), diags[1].Provenance.Pointer) - assert.Equal(t, diag.BudgetExceeded, diags[1].Code) - }) - - t.Run("what a container spans but does not read is validated through its own $ref", func(t *testing.T) { - t.Parallel() - other := `paths: - /x: - x-shared: {name: q, in: sideways, schema: {type: string}} - get: {responses: {"200": {description: ok}}} -` - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /b: {get: {parameters: [{$ref: "./other.yaml#/paths/~1x/x-shared"}], responses: {"200": {description: ok}}}} - /a: {$ref: "./other.yaml#/paths/~1x"} -` - diags := compile(t, other, root, Options{}) - - require.Len(t, diags, 1, "the path item at /a holds the parameter but never reads it: %+v", diags) - assert.Equal(t, jsontext.Pointer("/paths/~1x/x-shared/in"), diags[0].Provenance.Pointer) - assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) - }) - - t.Run("one node read as two kinds is validated as each", func(t *testing.T) { - t.Parallel() - other := "components:\n responses:\n R: {description: ok, type: 42, " + - "content: {application/json: {schema: {minLength: -2}}}}\n" - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: {get: {responses: {"200": {$ref: "./other.yaml#/components/responses/R"}}}} -components: - schemas: - S: {$ref: "./other.yaml#/components/responses/R"} -` - diags := compile(t, other, root, Options{}) - - sites := map[jsontext.Pointer][]string{} - for _, d := range diags { - sites[d.Provenance.Pointer] = append(sites[d.Provenance.Pointer], d.Message) - } - const minLength = "/components/responses/R/content/application~1json/schema/minLength" - require.Len(t, sites[minLength], 1, "read as a response: %+v", diags) - assert.Contains(t, sites[minLength][0], `Schema Object field "minLength"`) - assert.NotEmpty(t, sites["/components/responses/R/type"], "read as a schema, type: 42 is a finding: %+v", diags) - }) - - t.Run("a contained object at the lesser $ref and its container report once", func(t *testing.T) { - t.Parallel() - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /b: {$ref: "./other.yaml#/paths/~1x"} - /a: - get: - operationId: getA - parameters: - - $ref: "./other.yaml#/paths/~1x/get/parameters/0" - responses: {"200": {description: ok}} -` - diags := compile(t, other, root, Options{}) - - require.Len(t, diags, 1, "the container draws it again, and that copy is dropped: %+v", diags) - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) - }) -} - -// TestResolve_ReachedSitesDoNotDependOnDeclarationOrder compiles one source as -// written and with its paths and parameters reversed, and requires the same -// reports, each at its node in other.yaml whichever $ref reached it first. The -// shapes: a component two paths reach, a container at the lesser $ref and one -// at the greater, and an internal $ref whose chain leaves the source, which -// sorts before the external $ref it chains to. -func TestResolve_ReachedSitesDoNotDependOnDeclarationOrder(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": `paths: - /x: - get: - parameters: [{name: q, in: under, schema: {type: string}}] - responses: {"200": {description: ok}} - /y: - get: - parameters: [{name: r, in: over, schema: {type: string}}] - responses: {"200": {description: ok}} -components: - parameters: - P: {name: p, in: sideways, schema: {type: string}} - P2: {in: aslant, schema: {type: string}} -`}) - paths := []string{ - "/b: {get: {parameters: [{$ref: './other.yaml#/components/parameters/P'}], responses: {'200': {description: ok}}}}", - "/a: {get: {parameters: [{$ref: './other.yaml#/components/parameters/P'}], responses: {'200': {description: ok}}}}", - "/d: {$ref: './other.yaml#/paths/~1x'}", - "/c: {get: {parameters: [{$ref: './other.yaml#/paths/~1x/get/parameters/0'}], responses: {'200': {description: ok}}}}", - "/f: {get: {parameters: [{$ref: './other.yaml#/paths/~1y/get/parameters/0'}], responses: {'200': {description: ok}}}}", - "/e: {$ref: './other.yaml#/paths/~1y'}", - } - parameters := []string{ - "R: {$ref: './other.yaml#/components/parameters/P2'}", - "Q: {$ref: '#/components/parameters/R'}", - } - compile := func(paths, parameters []string) []string { - src := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n " + strings.Join(paths, "\n ") + - "\ncomponents:\n parameters:\n " + strings.Join(parameters, "\n ") + "\n" - _, diags := loadExternal(t, dir, src, Options{}) - out := make([]string, 0, len(diags)) - for _, d := range diags { - out = append(out, fmt.Sprintf("%s %s %s", d.Code, d.Provenance.Pointer, d.Message)) - } - slices.Sort(out) - return out - } - - asWritten := compile(paths, parameters) - slices.Reverse(paths) - slices.Reverse(parameters) - reversed := compile(paths, parameters) - - if d := cmp.Diff(asWritten, reversed); d != "" { - t.Errorf("reports depend on declaration order (-as written +reversed):\n%s", d) - } - sites := make([]string, 0, len(asWritten)) - for _, line := range asWritten { - code, rest, _ := strings.Cut(line, " ") - site, _, _ := strings.Cut(rest, " ") - sites = append(sites, code+" "+site) - } - assert.Equal(t, []string{ - "openapi/validation/validation-allowed-values /components/parameters/P/in", // P: /a and /b reach it - "openapi/validation/validation-allowed-values /components/parameters/P2/in", // P2: Q chains to R's target - "openapi/validation/validation-allowed-values /paths/~1x/get/parameters/0/in", // q: held by /d, reached at /c - "openapi/validation/validation-allowed-values /paths/~1y/get/parameters/0/in", // r: /e holds what /f reaches - "openapi/validation/validation-required-field /components/parameters/P2", // P2 writes no name - }, sites) -} - -// TestResolve_AChainIsChargedToTheDocumentItEndsIn reaches one object of -// b.yaml directly and a schema inside it through a.yaml. Both are b.yaml's -// nodes, so both are charged to b.yaml, and together they cross a budget -// neither crosses alone. Charged to the first document each $ref names, the -// schema would go to a.yaml and nothing would cross. -func TestResolve_AChainIsChargedToTheDocumentItEndsIn(t *testing.T) { - t.Parallel() - root := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: {get: {parameters: [{name: q, in: query, schema: {$ref: "./a.yaml#/components/schemas/X"}}], responses: {"200": {description: ok}}}} - /b: {get: {parameters: [{$ref: "./b.yaml#/components/parameters/Y"}], responses: {"200": {description: ok}}}} -` - dir := externalDir(t, map[string]string{ - "a.yaml": "components:\n schemas:\n X: {$ref: \"./b.yaml#/components/parameters/Y/schema\"}\n", - "b.yaml": "components:\n parameters:\n Y: {name: p, in: sideways, schema: " + - "{type: object, properties: {p1: {type: string}, p2: {type: string}, p3: {type: string}, p4: {type: string}, p5: {type: string}, p6: {type: string}, p7: {type: string}, p8: {type: string}, p9: {type: string}, p10: {type: string}}}}\n", - }) - bPath := filepath.Join(dir, "b.yaml") - - _, diags := loadExternal(t, dir, root, Options{MaxSourceNodes: 60, MaxAliasSurplus: 1}) - - require.Len(t, diags, 1, "%+v", diags) - assert.Equal(t, diag.BudgetExceeded, diags[0].Code) - assert.Equal(t, jsontext.Pointer("/paths/~1b/get/parameters/0"), diags[0].Provenance.Pointer) - assert.Contains(t, diags[0].Message, "reach in "+bPath+" span more than the 61 nodes") - - _, diags = loadExternal(t, dir, root, Options{MaxSourceNodes: 120, MaxAliasSurplus: 1}) - require.Len(t, diags, 1, "a budget both fit admits both: %+v", diags) - assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) -} - -// v32OtherFixture and v32RootFixture are a 3.2 root reaching two external -// schemas, one writing a keyword only 3.2 defines (Pet's defaultMapping) and -// one with a defect (Broken: {type: 42}). -const v32OtherFixture = `openapi: 3.2.0 -info: {title: T, version: "1"} -paths: {} -components: - schemas: - Pet: - type: object - properties: {petType: {type: string}} - required: [petType] - discriminator: - propertyName: petType - defaultMapping: '#/components/schemas/Dog' - mapping: {cat: '#/components/schemas/Cat'} - Dog: {allOf: [{$ref: '#/components/schemas/Pet'}]} - Cat: {allOf: [{$ref: '#/components/schemas/Pet'}]} - Broken: {type: 42} -` - -const v32RootFixture = `openapi: 3.2.0 -info: {title: T, version: "1"} -paths: {} -components: - schemas: - S: {$ref: "./other.yaml#/components/schemas/Pet"} - B: {$ref: "./other.yaml#/components/schemas/Broken"} -` - -// TestResolve_AReachedSchemaIsHeldToTheSourcesMinor: Pet's defaultMapping is a -// keyword 3.2 defines, so S, reached from a 3.2 source, reports nothing, while -// the type: 42 of Broken, which B reaches, is reported beside it. The second case reaches the same keyword -// nested in a path item rather than as the object itself. -func TestResolve_AReachedSchemaIsHeldToTheSourcesMinor(t *testing.T) { - t.Parallel() - - t.Run("a schema reached directly", func(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": v32OtherFixture}) - - _, diags := loadExternal(t, dir, v32RootFixture, Options{}) - - for _, d := range diags { - assert.Equal(t, jsontext.Pointer("/components/schemas/Broken/type"), d.Provenance.Pointer, - "S's defaultMapping is a 3.2 keyword and reports nothing: %+v", d) - } - assert.NotEmpty(t, diags, "Broken's type: 42 is a defect") - }) - - t.Run("the same keyword nested inside a reached path item", func(t *testing.T) { - t.Parallel() - other := `openapi: 3.2.0 -info: {title: O, version: "1"} -paths: - /x: - get: - operationId: getX - parameters: - - name: q - in: query - schema: - type: object - properties: {petType: {type: string}} - required: [petType] - discriminator: - propertyName: petType - defaultMapping: '#/components/schemas/Dog' - responses: {"200": {description: ok}} -components: - schemas: - Dog: {type: object} -` - root := `openapi: 3.2.0 -info: {title: T, version: "1"} -paths: - /x: {$ref: "./other.yaml#/paths/~1x"} -` - dir := externalDir(t, map[string]string{"other.yaml": other}) - - _, diags := loadExternal(t, dir, root, Options{}) - - assert.Empty(t, diags, "the nested defaultMapping is a 3.2 keyword too: %+v", diags) - }) -} - -// TestResolve_AReached30SchemaIsCheckedAsTheSourcesAre compiles one 3.0 schema -// in the source, reached directly, and reached inside a parameter, and each -// draws the same two findings, as 3.0 words them: its type list, which 3.0 -// does not admit (3.0.4 §Schema Object: type "Value MUST be a string"), and its -// negative minLength. examples is not a 3.0 keyword, and draws none. -func TestResolve_AReached30SchemaIsCheckedAsTheSourcesAre(t *testing.T) { - t.Parallel() - const header = "openapi: 3.0.3\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n" - const body = "{type: [string, 'null'], examples: [a], minLength: -1}" - for name, c := range map[string]struct{ root, other string }{ - "in the source": {root: header + " schemas:\n S: " + body + "\n"}, - "reached directly": { - root: header + " schemas:\n S: {$ref: './other.yaml#/components/schemas/T'}\n", - other: "components:\n schemas:\n T: " + body + "\n", - }, - "reached inside a parameter": { - root: header + " parameters:\n P: {$ref: './other.yaml#/components/parameters/Q'}\n", - other: "components:\n parameters:\n Q: {name: q, in: query, schema: " + body + "}\n", - }, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": c.other}) - - _, diags := loadExternal(t, dir, c.root, Options{}) - - require.Len(t, diags, 2, "%+v", diags) - for _, d := range diags { - assert.Equal(t, diag.Validation+"/validation-type-mismatch", d.Code) - } - assert.Contains(t, diags[0].Message, `Schema Object field "minLength" must be a non-negative integer in OpenAPI 3.0`) - assert.Contains(t, diags[1].Message, `Schema Object field "type" must be a string in OpenAPI 3.0`) - }) - } -} - -// nestedOtherFixture is an external document whose path item holds a parameter -// with an invalid in: sideways and a schema of twenty properties. Reached once -// through the parameter and once through the path item around it, those nodes -// are charged twice, which crosses a budget a single charge does not. Small, a -// separate response with a defect of its own, is reached after that, to pin -// that a document over its budget is not validated further. -func nestedOtherFixture() string { - var b strings.Builder - b.WriteString("openapi: 3.1.0\ninfo: {title: O, version: \"1\"}\npaths:\n" + - " /x:\n get:\n operationId: getX\n parameters:\n" + - " - name: q\n in: sideways\n schema:\n" + - " type: object\n properties:\n") - for i := 1; i <= 20; i++ { - fmt.Fprintf(&b, " p%d: {type: string}\n", i) - } - b.WriteString(" responses:\n \"200\": {description: ok}\n" + - "components:\n responses:\n Small: {description: ok, content: {application/json: {schema: {minLength: -1}}}}\n") - return b.String() -} - -// nestedRootPaths reach nestedOtherFixture's parameter from /a, the path item -// around it from /b, and Small from /c. They are declared last first: in that -// order a walk reaches the path item before the parameter it holds. -var nestedRootPaths = []string{ - ` /c: - get: - operationId: getC - responses: - "200": {$ref: "./other.yaml#/components/responses/Small"} -`, - " /b: {$ref: \"./other.yaml#/paths/~1x\"}\n", - ` /a: - get: - operationId: getA - parameters: - - $ref: "./other.yaml#/paths/~1x/get/parameters/0" - responses: {"200": {description: ok}} -`, -} - -// nestedRoot returns a source declaring paths in the order given. -func nestedRoot(paths []string) string { - return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + strings.Join(paths, "") -} - -// TestResolve_ReachedValidationIsBudgeted runs nestedRootPaths under three -// budgets. At 151 nodes (150 + 1) the parameter's finding is reported once, -// reached from /a, the path item at /b, which charges those nodes again, is refused -// as budget-exceeded, and Small at /c is not validated, in either declaration -// order. The three total 206 nodes, so 206 (205 + 1) admits all of them where -// MaxSourceNodes alone, 205, would not: that boundary is what shows the alias -// budget's share is counted. An unbounded budget admits them all too. -func TestResolve_ReachedValidationIsBudgeted(t *testing.T) { - t.Parallel() - nestedRootFixture := nestedRoot(nestedRootPaths) - other := nestedOtherFixture() - dir := externalDir(t, map[string]string{"other.yaml": other}) - otherPath := filepath.Join(dir, "other.yaml") - - t.Run("a tight budget refuses the container after the overlap", func(t *testing.T) { - t.Parallel() - _, diags := loadExternal(t, dir, nestedRootFixture, Options{MaxSourceNodes: 150, MaxAliasSurplus: 1}) - - require.Len(t, diags, 2, "exactly two: Small's document is already over budget, so neither "+ - "its finding nor a second budget-exceeded is reported: %+v", diags) - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) - assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) - - assert.Equal(t, diag.BudgetExceeded, diags[1].Code) - assert.Equal(t, ir.SeverityError, diags[1].Severity) - assert.Equal(t, jsontext.Pointer("/paths/~1b"), diags[1].Provenance.Pointer) - assert.Equal(t, fmt.Sprintf( - "the objects references reach in %s span more than the 151 nodes a document is validated over; "+ - "its validation stops at this one", otherPath), - diags[1].Message) - - reversed := slices.Clone(nestedRootPaths) - slices.Reverse(reversed) - _, again := loadExternal(t, dir, nestedRoot(reversed), Options{MaxSourceNodes: 150, MaxAliasSurplus: 1}) - if d := cmp.Diff(diags, again); d != "" { - t.Errorf("the budget is crossed at another object when the paths are reversed (-as written +reversed):\n%s", d) - } - }) - - for name, opts := range map[string]Options{ - "a budget the three fit admits them all": {MaxSourceNodes: 205, MaxAliasSurplus: 1}, - "an unbounded budget admits them all": {}, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - _, diags := loadExternal(t, dir, nestedRootFixture, opts) - - require.Len(t, diags, 2, "%+v", diags) - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) - assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) - assert.Equal(t, jsontext.Pointer("/components/responses/Small/content/application~1json/schema/minLength"), - diags[1].Provenance.Pointer) - assert.Equal(t, diag.Validation+"/validation-type-mismatch", diags[1].Code) - }) - } -} - -// respelledOther is an external document with an anchored get whose parameter -// is itself invalid, a put with an invalid schema, and an /unreached path. -const respelledOther = `openapi: 3.1.0 -info: {title: O, version: "1"} -paths: - /x: - get: &g - operationId: EXTGET - parameters: - - {name: q, in: sideways, schema: {type: string}} - responses: {"200": {description: ok}} - put: - operationId: EXTPUT - parameters: - - {name: p, schema: {type: string, minLength: -1}} - responses: - "200": {description: ok} - "404": &r {description: EXTANCHOREDRESP} - /unreached: - get: - operationId: getU - parameters: - - {name: u, in: nowhere, schema: {type: string}} - responses: {"200": {description: ok}} -` - -// respelledRoot is a source whose two paths reference other.yaml's anchored -// path item over HTTP, spelling its URL with scheme. -func respelledRoot(scheme, host string) string { - url := scheme + "://" + host + "/other.yaml#/paths/~1x" - return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - " /x: {$ref: \"" + url + "\"}\n /y: {$ref: \"" + url + "\"}\n" -} - -// TestResolve_ValidatesOnceAfterARebuild: an upper-cased scheme forces GitHub -// #538's rebuild, since the resolver's own parse of a mis-keyed document skips -// the anchored get. /x's 8:25 finding, inside that get, exists only in the -// recovered document, and validateReached, run once on the document resolve -// returns, must find it exactly once. other.yaml is served over HTTP so the -// spelling is the one difference between the runs, which must report the same -// diagnostics: each finding once, placed in other.yaml at its node. -func TestResolve_ValidatesOnceAfterARebuild(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(respelledOther)) - assert.NoError(t, err) - })) - t.Cleanup(srv.Close) - host := strings.TrimPrefix(srv.URL, "http://") - - run := func(scheme string) []ir.Diagnostic { - src := compilers.Source{Path: "root.yaml", Data: []byte(respelledRoot(scheme, host))} - _, diags, err := Load(t.Context(), 0, src, Options{Reader: reader.Disk{}}) - require.NoError(t, err) - return diags - } - - notRebuilt := run("http") - rebuilt := run("HTTP") - - respelled := cmp.Transformer("respelled", func(msg string) string { - return strings.ReplaceAll(msg, "HTTP://", "http://") - }) - if d := cmp.Diff(notRebuilt, rebuilt, respelled); d != "" { - t.Errorf("the diagnostics must be identical whether or not a rebuild ran (-not-rebuilt +rebuilt):\n%s", d) - } - made := map[string]bool{} - recovered := 0 - for _, d := range rebuilt { - assert.Equal(t, 1, d.Provenance.Source, "placed in other.yaml, where its node is: %+v", d) - key := fmt.Sprint(d.Code, d.Provenance, d.Message) - assert.False(t, made[key], "made once: %+v", d) - made[key] = true - assert.False(t, strings.HasPrefix(string(d.Provenance.Pointer), "/paths/~1unreached"), - "/unreached must not be validated: %+v", d) - if d.Provenance.Position == (ir.Position{Line: 8, Column: 25}) { - recovered++ - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), d.Provenance.Pointer) - } - } - assert.Equal(t, 1, recovered, "the recovered get's own finding, exactly once: %+v", rebuilt) -} - -// markerPosition returns the line and column, both counted from 1, at which -// marker starts in spec. The marker must occur once, so a fixture edited to -// repeat it fails here rather than naming the wrong node. -func markerPosition(t *testing.T, spec, marker string) (line, col int) { - t.Helper() - require.Equal(t, 1, strings.Count(spec, marker), "the marker %q must occur once in the fixture", marker) - at := strings.Index(spec, marker) - return 1 + strings.Count(spec[:at], "\n"), at - strings.LastIndex(spec[:at], "\n") -} - -// inSourceFinding is one finding on a node of the source: the pointer it is -// placed at, its code and what it says, and at, the snippet of the fixture the -// node starts at, whose line and column it carries beside the pointer. -// "{line}" in text stands for that line. -type inSourceFinding struct { - pointer, code, text, at string -} - -// render spells the finding as renderDiags spells a diagnostic. -func (f inSourceFinding) render(t *testing.T, spec string) string { - t.Helper() - line, col := markerPosition(t, spec, f.at) - text := strings.ReplaceAll(f.text, "{line}", fmt.Sprint(line)) - return fmt.Sprintf("%s %d:%d %s %s", f.pointer, line, col, f.code, text) -} - -// renderDiags spells each diagnostic as its pointer, position, code and -// message, sorted. -func renderDiags(diags []ir.Diagnostic) []string { - out := make([]string, 0, len(diags)) - for _, d := range diags { - at := d.Provenance.Position - out = append(out, fmt.Sprintf("%s %d:%d %s %s", d.Provenance.Pointer, at.Line, at.Column, d.Code, d.Message)) - } - slices.Sort(out) - return out -} - -// reachedInSourceFixture is GitHub #872's reproducer: three security schemes -// and a parameter, each written under an x-* key and reached by a $ref, each -// missing what its own kind requires. -const reachedInSourceFixture = `openapi: 3.1.0 -info: {title: T, version: "1"} -security: - - key: [] - - oidc: [] - - oauth: [] -paths: - /x: - get: - parameters: - - $ref: '#/x-params/q' - responses: {"200": {description: ok}} -x-schemes: - key: {type: apiKey, in: header} - oidc: {type: openIdConnect} - oauth: {type: oauth2} -x-params: - q: {name: q, schema: {type: string}} -components: - securitySchemes: - key: {$ref: '#/x-schemes/key'} - oidc: {$ref: '#/x-schemes/oidc'} - oauth: {$ref: '#/x-schemes/oauth'} -` - -// TestResolve_WhatAReferenceReachesInTheSourceIsValidated pins that an object a -// $ref reaches in the source is validated, whether or not external references -// are read (GitHub #872): one under an x-* key, which the source's model never -// declares, whether reached by a $ref, a discriminator mapping entry, or a $ref -// inside raw YAML. Each finding is placed as the source's own are, at the -// pointer and position of the node it is about, not at the $ref (GitHub -// #879), resolution's and validation's alike. An object the model declares is -// reported once, by the source; a valid one reports nothing. -func TestResolve_WhatAReferenceReachesInTheSourceIsValidated(t *testing.T) { - t.Parallel() - const ( - required = "openapi/validation/validation-required-field" - allowed = "openapi/validation/validation-allowed-values" - invalid = "openapi/validation/validation-type-mismatch" - mismatch = "openapi/validation/validation-type-mismatch" - minimum = `Schema Object field "minLength" must be a non-negative integer in OpenAPI 3.1 (Schema Object)` - inValues = `Parameter Object field "in" must be one of "query", "header", "path", "cookie" in OpenAPI 3.1 (Parameter Object)` - noIn = `Parameter Object field "in" is REQUIRED in OpenAPI 3.1 (Parameter Object)` - noName = `Security Scheme Object field "name" is REQUIRED when type=apiKey in OpenAPI 3.1 (Security Scheme Object)` - noURL = `Security Scheme Object field "openIdConnectUrl" is REQUIRED when type=openIdConnect in OpenAPI 3.1 (Security Scheme Object)` - noFlows = `Security Scheme Object field "flows" is REQUIRED when type=oauth2 in OpenAPI 3.1 (Security Scheme Object)` - ) - const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" - byOperation := header + `paths: - /x: - get: - parameters: - - $ref: '%s' - responses: {"200": {description: ok}} -` - for _, c := range []struct { - name string - spec string - want []inSourceFinding - }{ - {"GitHub #872's reproducer", reachedInSourceFixture, []inSourceFinding{ - {"/x-params/q", required, noIn, "{name: q"}, - {"/x-schemes/key", required, noName, "{type: apiKey"}, - {"/x-schemes/oidc", required, noURL, "{type: openIdConnect}"}, - {"/x-schemes/oauth", required, noFlows, "{type: oauth2}"}, - }}, - { - "a schema only a discriminator mapping entry reaches", - mappingSpec(petMapping("x: '#/x-lib/Bad'"), "x-lib:\n Bad: {type: string, minLength: -1}\n"), - []inSourceFinding{{"/x-lib/Bad/minLength", invalid, minimum, "-1"}}, - }, - { - "a schema a $ref inside raw YAML reaches", - mappingSpec(" S: {$ref: '#/x-lib/A'}\n", - "x-lib:\n A: {type: object, properties: {b: {$ref: '#/x-lib/B'}}}\n B: {type: string, minLength: -1}\n"), - []inSourceFinding{{"/x-lib/B/minLength", invalid, minimum, "-1"}}, - }, - { - "a type mismatch is told once", - mappingSpec(" S: {$ref: '#/x-lib/A'}\n", "x-lib:\n A: {type: string, minLength: abc}\n"), - []inSourceFinding{{"/x-lib/A/minLength", mismatch, minimum, "abc"}}, - }, - { - "a declared component is reported by the source alone", - fmt.Sprintf(byOperation, "#/components/parameters/P") + - "components:\n parameters:\n P: {name: p, in: sideways, schema: {type: string}}\n", - []inSourceFinding{{"/components/parameters/P/in", allowed, inValues, "sideways"}}, - }, - { - "a valid object under an extension reports nothing", - fmt.Sprintf(byOperation, "#/x-params/q") + - "x-params:\n q: {name: q, in: query, schema: {type: string}}\n", - nil, - }, - } { - for _, external := range []bool{false, true} { - t.Run(fmt.Sprintf("%s, external references %t", c.name, external), func(t *testing.T) { - t.Parallel() - want := make([]string, 0, len(c.want)) - for _, f := range c.want { - want = append(want, f.render(t, c.spec)) - } - slices.Sort(want) - - _, diags := loadTargets(t, "spec.yaml", c.spec, external) - - assert.Empty(t, cmp.Diff(want, renderDiags(diags))) - }) - } - } -} - -// TestResolve_InSourceReachedSitesDoNotDependOnDeclarationOrder pins that an -// object in the source reached from several places is reported once, at the -// node the finding is about rather than at any of them, whatever order they -// and the object are declared in. The places are $refs, discriminator mapping -// entries, and a $ref inside raw YAML; each case is compiled as written and -// then reversed, with the object moved from after the components to before -// them. -func TestResolve_InSourceReachedSitesDoNotDependOnDeclarationOrder(t *testing.T) { - t.Parallel() - const bad = " Bad: {type: string, minLength: -1}\n" - const lib = "x-lib:\n" + bad - ref := func(name string) string { return " " + name + ": {$ref: '#/x-lib/Bad'}\n" } - holder := func(name, mapping string) string { - return " " + name + ": {type: object, discriminator: {propertyName: k, mapping: {" + mapping + "}}}\n" - } - for _, c := range []struct { - name string - lib string - entries []string - }{ - {"two $refs and an entry, the least a $ref", lib, - []string{ref("A"), ref("B"), holder("Pet", "x: '#/x-lib/Bad'")}}, - {"two $refs and an entry, the least an entry", lib, - []string{holder("Alpha", "x: '#/x-lib/Bad'"), ref("Beta"), ref("Gamma")}}, - {"two entries of one mapping and a $ref", lib, - []string{holder("Alpha", "y: '#/x-lib/Bad', x: '#/x-lib/Bad'"), ref("Beta")}}, - {"a $ref and a $ref inside raw YAML", "x-lib:\n A: {type: object, properties: {b: {$ref: '#/x-lib/Bad'}}}\n" + bad, - []string{" S: {$ref: '#/x-lib/A'}\n", ref("T")}}, - } { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - compile := func(entries []string, libFirst bool) []string { - const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n" - components := "components:\n schemas:\n" + strings.Join(entries, "") - spec := header + components + c.lib - if libFirst { - spec = header + c.lib + components - } - _, diags := loadTargets(t, "spec.yaml", spec, false) - return diagLines(diags) - } - want := []string{"/x-lib/Bad/minLength openapi/validation/validation-type-mismatch"} - - backwards := slices.Clone(c.entries) - slices.Reverse(backwards) - - asWritten := compile(c.entries, false) - reversed := compile(backwards, true) - - assert.Empty(t, cmp.Diff(want, asWritten), "as written") - assert.Empty(t, cmp.Diff(want, reversed), "reversed") - }) - } -} - -// TestResolve_AnObjectTheSourceDeclaresIsNotChargedToTheBudget pins that what a -// $ref reaches in the source is charged to the validation budget only when the -// source's own validation did not cover it. A parameter and the schema inside -// it, both reached, fit a budget of the source's own size when the model -// declares them, as it has validated them already; written under an x-* key, -// the same two overlap, are charged twice, and cross it. -func TestResolve_AnObjectTheSourceDeclaresIsNotChargedToTheBudget(t *testing.T) { - t.Parallel() - var props []string - for i := 1; i <= 20; i++ { - props = append(props, fmt.Sprintf("p%d: {type: string}", i)) - } - param := "{name: q, in: query, schema: {type: object, properties: {" + strings.Join(props, ", ") + "}}}" - const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" - const paths = "paths:\n /a:\n get:\n parameters:\n - $ref: '%s'\n responses: {\"200\": {description: ok}}\n" - declared := header + fmt.Sprintf(paths, "#/components/parameters/P") + - "components:\n schemas:\n S: {$ref: '#/components/parameters/P/schema'}\n parameters:\n P: " + param + "\n" - undeclared := header + fmt.Sprintf(paths, "#/x-lib/P") + - "components:\n schemas:\n S: {$ref: '#/x-lib/P/schema'}\nx-lib:\n P: " + param + "\n" - // load compiles spec under a budget of its own size, and returns the budget - // a document's validation is held to: that size and the alias surplus. - load := func(t *testing.T, spec string) (diags []ir.Diagnostic, limit int) { - t.Helper() - root, _, err := decodeStream([]byte(spec)) - require.NoError(t, err) - nodes := nodeCount(root) - opts := Options{MaxSourceNodes: nodes, MaxAliasSurplus: 1} - got, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(spec)}, opts) - require.NoError(t, err) - require.NotNil(t, got, "%+v", diags) - return diags, nodes + 1 - } - - t.Run("a declared parameter and its schema", func(t *testing.T) { - t.Parallel() - diags, _ := load(t, declared) - - assert.Empty(t, diags) - }) - t.Run("the same two under an extension", func(t *testing.T) { - t.Parallel() - diags, limit := load(t, undeclared) - - require.Len(t, diags, 1, "%+v", diags) - assert.Equal(t, diag.BudgetExceeded, diags[0].Code) - assert.Equal(t, ir.SeverityError, diags[0].Severity) - assert.Equal(t, jsontext.Pointer("/paths/~1a/get/parameters/0"), diags[0].Provenance.Pointer) - assert.Contains(t, diags[0].Message, fmt.Sprintf("span more than the %d nodes", limit)) - }) -} - -// TestResolve_AReachedObjectIsHeldToTheLaterVersionTable pins that an object -// a $ref reaches in the source outside its model draws what the same object -// written inline draws in an older document: a warning for each construct a -// later minor added, at the pointer it is written at, even beneath a path -// item's method or a callback's expression, and an additionalOperations' -// operations checked. An object -// reached both itself and inside another reached object draws each warning -// once. A 3.2 document draws none; another document's object is held to the -// source's version (GitHub #839), each warning placed at its pointer there. -func TestResolve_AReachedObjectIsHeldToTheLaterVersionTable(t *testing.T) { - t.Parallel() - const later = " openapi/later-version-construct" - pet := "x-lib:\n Pet: {type: object, discriminator: {propertyName: k, defaultMapping: '#/components/schemas/Cat'}}\n" - cases := []struct { - name, version, body string - want []string - }{ - {"a schema keyword", "3.1.0", - "paths: {}\ncomponents:\n schemas:\n Ref: {$ref: '#/x-lib/Pet'}\n Cat: {type: object}\n" + pet, - []string{"/x-lib/Pet/discriminator/defaultMapping" + later}}, - {"a response field", "3.1.0", - "paths: {}\ncomponents:\n responses:\n R: {$ref: '#/x-responses/R'}\nx-responses:\n R: {description: ok, summary: S}\n", - []string{"/x-responses/R/summary" + later}}, - {"a security scheme field", "3.1.0", - "paths: {}\ncomponents:\n securitySchemes:\n s: {$ref: '#/x-schemes/s'}\nx-schemes:\n" + - " s: {type: http, scheme: basic, deprecated: true}\n", - []string{"/x-schemes/s/deprecated" + later}}, - {"an example field", "3.1.0", - "paths:\n /a:\n get:\n responses:\n \"200\":\n description: ok\n" + - " content: {application/json: {examples: {e: {$ref: '#/x-examples/E'}}}}\n" + - "x-examples:\n E: {dataValue: 1}\n", - []string{"/x-examples/E/dataValue" + later}}, - {"a path item's additionalOperations, its operations checked", "3.1.0", - "paths:\n /a: {$ref: '#/x-paths/A'}\nx-paths:\n A:\n additionalOperations:\n" + - " PURGE: {parameters: [{name: q, in: sideways, schema: {type: string}}], " + - "responses: {\"200\": {description: ok}}}\n", - []string{"/x-paths/A/additionalOperations" + later, - "/x-paths/A/additionalOperations/PURGE/parameters/0/in openapi/validation/validation-allowed-values"}}, - {"an operation of a path item, under its method", "3.1.0", - "paths:\n /a: {$ref: '#/x-paths/A'}\nx-paths:\n A:\n" + - " get: {responses: {\"200\": {description: ok, summary: S}}}\n", - []string{"/x-paths/A/get/responses/200/summary" + later}}, - {"an operation of a callback, under its expression", "3.1.0", - "paths:\n /a:\n get:\n callbacks: {cb: {$ref: '#/x-cb/C'}}\n" + - " responses: {\"200\": {description: ok}}\nx-cb:\n C:\n" + - " '{$request.body#/u}': {post: {responses: {\"200\": {description: ok, summary: S}}}}\n", - []string{"/x-cb/C/{$request.body#~1u}/post/responses/200/summary" + later}}, - {"an object reached inside another reached one, warned once", "3.1.0", - "paths: {}\ncomponents:\n schemas:\n Ref: {$ref: '#/x-lib/Pet'}\n" + - " Inner: {$ref: '#/x-lib/Pet/properties/kind'}\n Cat: {type: object}\n" + - "x-lib:\n Pet: {type: object, properties: {kind: {type: object, discriminator: " + - "{propertyName: k, defaultMapping: '#/components/schemas/Cat'}}}}\n", - []string{"/x-lib/Pet/properties/kind/discriminator/defaultMapping" + later}}, - {"a 3.2 document, which added them", "3.2.0", - "paths: {}\ncomponents:\n schemas:\n Ref: {$ref: '#/x-lib/Pet'}\n Cat: {type: object}\n" + - " responses:\n R: {$ref: '#/x-responses/R'}\n" + pet + "x-responses:\n R: {description: ok, summary: S}\n", - []string{}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - spec := "openapi: " + c.version + "\ninfo: {title: T, version: \"1\"}\n" + c.body - _, diags := loadTargets(t, "spec.yaml", spec, false) - assert.Empty(t, cmp.Diff(c.want, diagLines(diags)), "%+v", diags) - }) - } - - t.Run("another document's object is held to the source's version", func(t *testing.T) { - t.Parallel() - dir := externalDir(t, map[string]string{"other.yaml": pet}) - _, diags := loadExternal(t, dir, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n"+ - "components:\n schemas:\n Ref: {$ref: 'other.yaml#/x-lib/Pet'}\n Cat: {type: object}\n", Options{}) - assert.Empty(t, cmp.Diff([]string{"/x-lib/Pet/discriminator/defaultMapping" + later}, diagLines(diags)), "%+v", diags) - require.Len(t, diags, 1) - assert.Equal(t, 1, diags[0].Provenance.Source, "placed in other.yaml's entry") - }) -} - // TestLoad_ExternalRefResolutionErrors pins how a finding inside a document an // external reference names is reported: under its own rule and severity, in // that document's Document.Sources entry, at the node it is about (GitHub #537, diff --git a/compilers/openapi/internal/load/reached_resolve_internal_test.go b/compilers/openapi/internal/load/reached_resolve_internal_test.go new file mode 100644 index 00000000..984d8bfa --- /dev/null +++ b/compilers/openapi/internal/load/reached_resolve_internal_test.go @@ -0,0 +1,1187 @@ +package load + +import ( + "encoding/json/jsontext" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/engine/reader" + "github.com/dexpace/morphic/ir" +) + +// reachRootFixture and reachOtherFixture are GitHub #545's repro. root.yaml +// reaches every kind of object other.yaml's findings live in: +// a path item (twice, /x and /y, to prove the once behaviour incidentally), an +// operation's own inline parameter, a reused parameter component (twice, to +// prove it again), a response, and a schema. /unreached and the numeric +// literal in P's schema are never reached by anything root.yaml writes. +const reachRootFixture = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: {$ref: "./other.yaml#/paths/~1x"} + /y: {$ref: "./other.yaml#/paths/~1x"} + /z: + get: + operationId: getZ + parameters: + - $ref: "./other.yaml#/components/parameters/P" + - $ref: "./other.yaml#/components/parameters/P" + responses: + "200": {$ref: "./other.yaml#/components/responses/R"} +components: + schemas: + S: {$ref: "./other.yaml#/components/schemas/Bad"} +` + +const reachOtherFixture = `openapi: 3.1.0 +info: {title: O, version: "1"} +paths: + /x: + get: + operationId: getX + security: + - nosuch: [] + parameters: + - {name: q, schema: {type: string}} + - {$ref: "#/components/parameters/P"} + responses: {"200": {description: ok}} + /unreached: + get: + operationId: getU + parameters: + - {name: u, in: nowhere, schema: {type: string}} + responses: {"200": {description: ok}} +components: + parameters: + P: {name: p, in: sideways, schema: {type: string, minimum: .5}} + responses: + R: + description: ok + content: + application/json: + schema: {type: string, minLength: -1} + schemas: + Bad: + type: object + minProperties: -3 + deprecated: "yes" +` + +// reachWantDiags is the exact diagnostic list +// TestResolve_ValidatesWhatAnExternalReferenceReaches expects: the findings, +// each placed in other.yaml's entry at the node it is about, in the order of +// the sites that reached them, then the repeat /z's list writes by naming P +// twice. +func reachWantDiags() []ir.Diagnostic { + at := func(code, message, pointer string, line, column int) ir.Diagnostic { + return ir.Diagnostic{ + Severity: ir.SeverityError, Code: diag.Validation + "/" + code, Message: message, + Provenance: ir.Provenance{Source: 1, Pointer: jsontext.Pointer(pointer), + Position: ir.Position{Line: line, Column: column}}, + } + } + return []ir.Diagnostic{ + at("validation-type-mismatch", + `Schema Object field "minProperties" must be a non-negative integer in OpenAPI 3.1 (Schema Object)`, + "/components/schemas/Bad/minProperties", 31, 22), + at("validation-type-mismatch", `Schema Object field "deprecated" must be a boolean in OpenAPI 3.1 (Schema Object)`, + "/components/schemas/Bad/deprecated", 32, 19), + at("validation-required-field", `Parameter Object field "in" is REQUIRED in OpenAPI 3.1 (Parameter Object)`, + "/paths/~1x/get/parameters/0", 10, 11), + at("validation-allowed-values", + `Parameter Object field "in" must be one of "query", "header", "path", "cookie" in OpenAPI 3.1 `+ + `(Parameter Object)`, "/components/parameters/P/in", 21, 22), + at("validation-type-mismatch", + `Schema Object field "minLength" must be a non-negative integer in OpenAPI 3.1 (Schema Object)`, + "/components/responses/R/content/application~1json/schema/minLength", 27, 45), + { + Severity: ir.SeverityError, + Code: diag.DuplicateParameter, + Message: `parameter "p" in "sideways" repeats the entry at /paths/~1z/get/parameters/0`, + Provenance: ir.Provenance{Source: 0, Pointer: "/paths/~1z/get/parameters/1"}, + }, + } +} + +// TestResolve_ValidatesWhatAnExternalReferenceReaches is GitHub #545's repro: +// compiled as the source, other.yaml raises more findings than root.yaml +// reaches, and only what is reached is checked at all. The numeric literal P's +// schema writes (minimum: .5) is a number, so it draws none, and nothing is +// reported from /unreached, which nothing in root.yaml names. +func TestResolve_ValidatesWhatAnExternalReferenceReaches(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": reachOtherFixture}) + + _, diags := loadExternal(t, dir, reachRootFixture, Options{}) + + if d := cmp.Diff(reachWantDiags(), diags); d != "" { + t.Errorf("diagnostics did not match (-want +got):\n%s", d) + } + for _, d := range diags { + assert.NotEqual(t, ir.Position{Line: 17, Column: 25}, d.Provenance.Position, + "/unreached's own finding must not appear: %+v", d) + assert.NotEqual(t, ir.Position{Line: 21, Column: 40}, d.Provenance.Position, + "the numeric literal must not appear: %+v", d) + } +} + +// TestResolve_WhatTheSourcesValidationNeverCheckedIsValidated pins what a $ref +// naming the source's file reaches that the source's own check never read: +// raw YAML, such as an extension's value. It is checked as an object +// in another document is, whether the $ref is the source's own or comes back +// into it from another document (GitHub #759), and its finding is placed at the +// node it is about (GitHub #879). A node of the grammar a schema $ref names, or +// raw YAML that is no schema, is not read as a schema, so it is reported +// unresolved, once (GitHub #769), and nothing in it is validated as one. +func TestResolve_WhatTheSourcesValidationNeverCheckedIsValidated(t *testing.T) { + t.Parallel() + const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + for _, c := range []struct { + name string + files map[string]string + root string + want []string + }{ + {"a schema under an extension", nil, header + `paths: {} +components: + schemas: + A: {$ref: 'root.yaml#/x-defs/S'} +x-defs: + S: {type: string, minLength: -1} +`, []string{"/x-defs/S/minLength openapi/validation/validation-type-mismatch"}}, + { + "a response under an extension, from another document", + map[string]string{"other.yaml": "components:\n responses:\n Back: {$ref: 'root.yaml#/x-responses/R'}\n"}, + header + `paths: + /a: + get: + responses: + "200": {$ref: 'other.yaml#/components/responses/Back'} +x-responses: + R: + description: ok + links: + L: {operationId: a, operationRef: '#/paths/~1a/get'} +`, []string{"/x-responses/R/links/L openapi/validation/validation-mutually-exclusive-fields"}, + }, + {"a response named as a schema", nil, header + `paths: {} +components: + schemas: + S: {$ref: 'root.yaml#/components/responses/R'} + responses: + R: {description: ok, minLength: -1} +`, []string{"/components/schemas/S openapi/unresolved-ref"}}, + {"raw YAML holding no schema", nil, header + `paths: {} +components: + schemas: + A: {$ref: '#/x-lib/S'} + B: {$ref: '#/x-lib/N'} +x-lib: + S: just a string + N: null +`, []string{"/components/schemas/A openapi/unresolved-ref", "/components/schemas/B openapi/unresolved-ref"}}, + } { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + _, diags := loadExternal(t, externalDir(t, c.files), c.root, Options{}) + + assert.Empty(t, cmp.Diff(c.want, diagLines(diags))) + }) + } +} + +// TestResolve_AComponentTheYAMLWritesAsNoSchemaIsTheParsers pins that a +// component the YAML writes as no schema, here a string, holds the schema the +// parser built there, as the lowering reads it: a $ref to it names it, and the +// rule table's finding that it is no schema is the one report, where raw YAML +// holding no schema is reported unresolved (GitHub #769). +func TestResolve_AComponentTheYAMLWritesAsNoSchemaIsTheParsers(t *testing.T) { + t.Parallel() + _, diags := loadExternal(t, t.TempDir(), `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + A: {$ref: '#/components/schemas/S'} + S: just a string +`, Options{}) + + assert.Empty(t, cmp.Diff([]string{"/components/schemas/S openapi/validation/validation-type-mismatch"}, + diagLines(diags)), "the table's finding, and no unresolved $ref") +} + +// TestResolve_ANodeReadAsAnotherKindReportsNothingTheSourceDid pins what a node +// a $ref reads as another kind holds: what the source declares inside it, here +// a header's schema, whose finding the source's own validation reported. That +// finding is reported once, by the source, and not again for the $ref (GitHub +// #759). +func TestResolve_ANodeReadAsAnotherKindReportsNothingTheSourceDid(t *testing.T) { + t.Parallel() + _, diags := loadExternal(t, t.TempDir(), `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + parameters: [{$ref: 'root.yaml#/components/headers/H'}] + responses: {"200": {description: ok}} +components: + headers: + H: {name: h, in: query, schema: {type: string, minLength: -1}} +`, Options{}) + + assert.Empty(t, cmp.Diff([]string{ + "/components/headers/H/schema/minLength openapi/validation/validation-type-mismatch", + }, diagLines(diags)), "the source's own finding, once, as the header it declares: the $ref's reading as a parameter adds none") +} + +// TestResolve_EveryKindOfObjectIsValidated reaches each kind of object a $ref +// names that the other tests do not, a header, request body, callback, +// example, link and security scheme, each with a defect only its own table +// entry finds. Each finding is placed in other.yaml's entry at the node it is +// about, under the rule and severity the table gives it. +func TestResolve_EveryKindOfObjectIsValidated(t *testing.T) { + t.Parallel() + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + requestBody: {$ref: './other.yaml#/components/requestBodies/B'} + callbacks: {cb: {$ref: './other.yaml#/components/callbacks/C'}} + responses: + "200": + description: ok + headers: {X: {$ref: './other.yaml#/components/headers/H'}} + links: {l: {$ref: './other.yaml#/components/links/L'}} + content: {application/json: {examples: {e: {$ref: './other.yaml#/components/examples/E'}}}} +components: + securitySchemes: + s: {$ref: './other.yaml#/components/securitySchemes/S'} +` + dir := externalDir(t, map[string]string{"other.yaml": `components: + headers: + H: {style: form, schema: {type: string}} + requestBodies: + B: {content: {application/json: {schema: {minLength: -1}}}} + callbacks: + C: {'{$bogus': {get: {responses: {"200": {description: ok}}}}} + examples: + E: {value: 1, externalValue: 'https://x.test/e'} + links: + L: {operationId: a, operationRef: '#/paths/~1a/get'} + securitySchemes: + S: {type: bogus} +`}) + + _, diags := loadExternal(t, dir, root, Options{}) + + got := make([]string, 0, len(diags)) + for _, d := range diags { + got = append(got, fmt.Sprintf("%d %s %s %s", d.Provenance.Source, d.Provenance.Pointer, d.Severity, d.Code)) + } + want := []string{ + "1 /components/securitySchemes/S/type error openapi/validation/validation-allowed-values", + "1 /components/callbacks/C/{$bogus error openapi/validation/validation-invalid-format", + "1 /components/requestBodies/B/content/application~1json/schema/minLength error " + + "openapi/validation/validation-type-mismatch", + "1 /components/examples/E error openapi/validation/validation-mutually-exclusive-fields", + "1 /components/headers/H/style error openapi/validation/validation-allowed-values", + "1 /components/links/L error openapi/validation/validation-mutually-exclusive-fields", + } + if d := cmp.Diff(want, got); d != "" { + t.Errorf("findings (-want +got):\n%s", d) + } +} + +// TestResolve_AnObjectReachedTwiceIsReportedOnce covers the ways one node can +// be reached more than once: two $refs to one component, a container reached +// beside something inside it, at the lesser $ref and at the greater, and one +// node read as two kinds. Each finding is placed at its node in other.yaml, +// whichever $ref is declared first. Inside a container, an object is validated +// twice, and charged twice, and its finding reported once. +func TestResolve_AnObjectReachedTwiceIsReportedOnce(t *testing.T) { + t.Parallel() + compile := func(t *testing.T, other, root string, opts Options) []ir.Diagnostic { + t.Helper() + _, diags := loadExternal(t, externalDir(t, map[string]string{"other.yaml": other}), root, opts) + return diags + } + + t.Run("two refs to one component report once, at the node", func(t *testing.T) { + t.Parallel() + other := `openapi: 3.1.0 +info: {title: O, version: "1"} +paths: {} +components: + parameters: + P: {name: p, in: sideways, schema: {type: string}} +` + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /b: + get: {operationId: getB, parameters: [{$ref: "./other.yaml#/components/parameters/P"}], responses: {"200": {description: ok}}} + /a: + get: {operationId: getA, parameters: [{$ref: "./other.yaml#/components/parameters/P"}], responses: {"200": {description: ok}}} +` + diags := compile(t, other, root, Options{}) + + require.Len(t, diags, 1, "%+v", diags) + assert.Equal(t, ir.Provenance{Source: 1, Pointer: "/components/parameters/P/in", + Position: ir.Position{Line: 6, Column: 22}}, diags[0].Provenance) + }) + + other := `openapi: 3.1.0 +info: {title: O, version: "1"} +paths: + /x: + get: + operationId: getX + parameters: + - name: q + in: sideways + schema: {type: string} + responses: {"200": {description: ok}} +` + containedFirst := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /b: + get: + operationId: getB + parameters: + - $ref: "./other.yaml#/paths/~1x/get/parameters/0" + responses: {"200": {description: ok}} + /a: {$ref: "./other.yaml#/paths/~1x"} +` + + t.Run("a container at the lesser $ref and what it holds report once", func(t *testing.T) { + t.Parallel() + diags := compile(t, other, containedFirst, Options{}) + + require.Len(t, diags, 1, "the parameter the container holds draws it again, and that copy is dropped: %+v", diags) + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) + }) + + t.Run("what a container holds is charged again", func(t *testing.T) { + t.Parallel() + diags := compile(t, nestedOtherFixture(), containedFirst, Options{MaxSourceNodes: 150, MaxAliasSurplus: 1}) + + require.Len(t, diags, 2, "the container fits this budget and its parameter, charged again, "+ + "does not: %+v", diags) + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) + assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) + assert.Equal(t, jsontext.Pointer("/paths/~1b/get/parameters/0"), diags[1].Provenance.Pointer) + assert.Equal(t, diag.BudgetExceeded, diags[1].Code) + }) + + t.Run("what a container spans but does not read is validated through its own $ref", func(t *testing.T) { + t.Parallel() + other := `paths: + /x: + x-shared: {name: q, in: sideways, schema: {type: string}} + get: {responses: {"200": {description: ok}}} +` + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /b: {get: {parameters: [{$ref: "./other.yaml#/paths/~1x/x-shared"}], responses: {"200": {description: ok}}}} + /a: {$ref: "./other.yaml#/paths/~1x"} +` + diags := compile(t, other, root, Options{}) + + require.Len(t, diags, 1, "the path item at /a holds the parameter but never reads it: %+v", diags) + assert.Equal(t, jsontext.Pointer("/paths/~1x/x-shared/in"), diags[0].Provenance.Pointer) + assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) + }) + + t.Run("one node read as two kinds is validated as each", func(t *testing.T) { + t.Parallel() + other := "components:\n responses:\n R: {description: ok, type: 42, " + + "content: {application/json: {schema: {minLength: -2}}}}\n" + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: {get: {responses: {"200": {$ref: "./other.yaml#/components/responses/R"}}}} +components: + schemas: + S: {$ref: "./other.yaml#/components/responses/R"} +` + diags := compile(t, other, root, Options{}) + + sites := map[jsontext.Pointer][]string{} + for _, d := range diags { + sites[d.Provenance.Pointer] = append(sites[d.Provenance.Pointer], d.Message) + } + const minLength = "/components/responses/R/content/application~1json/schema/minLength" + require.Len(t, sites[minLength], 1, "read as a response: %+v", diags) + assert.Contains(t, sites[minLength][0], `Schema Object field "minLength"`) + assert.NotEmpty(t, sites["/components/responses/R/type"], "read as a schema, type: 42 is a finding: %+v", diags) + }) + + t.Run("a contained object at the lesser $ref and its container report once", func(t *testing.T) { + t.Parallel() + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /b: {$ref: "./other.yaml#/paths/~1x"} + /a: + get: + operationId: getA + parameters: + - $ref: "./other.yaml#/paths/~1x/get/parameters/0" + responses: {"200": {description: ok}} +` + diags := compile(t, other, root, Options{}) + + require.Len(t, diags, 1, "the container draws it again, and that copy is dropped: %+v", diags) + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) + }) +} + +// TestResolve_ReachedSitesDoNotDependOnDeclarationOrder compiles one source as +// written and with its paths and parameters reversed, and requires the same +// reports, each at its node in other.yaml whichever $ref reached it first. The +// shapes: a component two paths reach, a container at the lesser $ref and one +// at the greater, and an internal $ref whose chain leaves the source, which +// sorts before the external $ref it chains to. +func TestResolve_ReachedSitesDoNotDependOnDeclarationOrder(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": `paths: + /x: + get: + parameters: [{name: q, in: under, schema: {type: string}}] + responses: {"200": {description: ok}} + /y: + get: + parameters: [{name: r, in: over, schema: {type: string}}] + responses: {"200": {description: ok}} +components: + parameters: + P: {name: p, in: sideways, schema: {type: string}} + P2: {in: aslant, schema: {type: string}} +`}) + paths := []string{ + "/b: {get: {parameters: [{$ref: './other.yaml#/components/parameters/P'}], responses: {'200': {description: ok}}}}", + "/a: {get: {parameters: [{$ref: './other.yaml#/components/parameters/P'}], responses: {'200': {description: ok}}}}", + "/d: {$ref: './other.yaml#/paths/~1x'}", + "/c: {get: {parameters: [{$ref: './other.yaml#/paths/~1x/get/parameters/0'}], responses: {'200': {description: ok}}}}", + "/f: {get: {parameters: [{$ref: './other.yaml#/paths/~1y/get/parameters/0'}], responses: {'200': {description: ok}}}}", + "/e: {$ref: './other.yaml#/paths/~1y'}", + } + parameters := []string{ + "R: {$ref: './other.yaml#/components/parameters/P2'}", + "Q: {$ref: '#/components/parameters/R'}", + } + compile := func(paths, parameters []string) []string { + src := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n " + strings.Join(paths, "\n ") + + "\ncomponents:\n parameters:\n " + strings.Join(parameters, "\n ") + "\n" + _, diags := loadExternal(t, dir, src, Options{}) + out := make([]string, 0, len(diags)) + for _, d := range diags { + out = append(out, fmt.Sprintf("%s %s %s", d.Code, d.Provenance.Pointer, d.Message)) + } + slices.Sort(out) + return out + } + + asWritten := compile(paths, parameters) + slices.Reverse(paths) + slices.Reverse(parameters) + reversed := compile(paths, parameters) + + if d := cmp.Diff(asWritten, reversed); d != "" { + t.Errorf("reports depend on declaration order (-as written +reversed):\n%s", d) + } + sites := make([]string, 0, len(asWritten)) + for _, line := range asWritten { + code, rest, _ := strings.Cut(line, " ") + site, _, _ := strings.Cut(rest, " ") + sites = append(sites, code+" "+site) + } + assert.Equal(t, []string{ + "openapi/validation/validation-allowed-values /components/parameters/P/in", // P: /a and /b reach it + "openapi/validation/validation-allowed-values /components/parameters/P2/in", // P2: Q chains to R's target + "openapi/validation/validation-allowed-values /paths/~1x/get/parameters/0/in", // q: held by /d, reached at /c + "openapi/validation/validation-allowed-values /paths/~1y/get/parameters/0/in", // r: /e holds what /f reaches + "openapi/validation/validation-required-field /components/parameters/P2", // P2 writes no name + }, sites) +} + +// TestResolve_AChainIsChargedToTheDocumentItEndsIn reaches one object of +// b.yaml directly and a schema inside it through a.yaml. Both are b.yaml's +// nodes, so both are charged to b.yaml, and together they cross a budget +// neither crosses alone. Charged to the first document each $ref names, the +// schema would go to a.yaml and nothing would cross. +func TestResolve_AChainIsChargedToTheDocumentItEndsIn(t *testing.T) { + t.Parallel() + root := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: {get: {parameters: [{name: q, in: query, schema: {$ref: "./a.yaml#/components/schemas/X"}}], responses: {"200": {description: ok}}}} + /b: {get: {parameters: [{$ref: "./b.yaml#/components/parameters/Y"}], responses: {"200": {description: ok}}}} +` + dir := externalDir(t, map[string]string{ + "a.yaml": "components:\n schemas:\n X: {$ref: \"./b.yaml#/components/parameters/Y/schema\"}\n", + "b.yaml": "components:\n parameters:\n Y: {name: p, in: sideways, schema: " + + "{type: object, properties: {p1: {type: string}, p2: {type: string}, p3: {type: string}, p4: {type: string}, p5: {type: string}, p6: {type: string}, p7: {type: string}, p8: {type: string}, p9: {type: string}, p10: {type: string}}}}\n", + }) + bPath := filepath.Join(dir, "b.yaml") + + _, diags := loadExternal(t, dir, root, Options{MaxSourceNodes: 60, MaxAliasSurplus: 1}) + + require.Len(t, diags, 1, "%+v", diags) + assert.Equal(t, diag.BudgetExceeded, diags[0].Code) + assert.Equal(t, jsontext.Pointer("/paths/~1b/get/parameters/0"), diags[0].Provenance.Pointer) + assert.Contains(t, diags[0].Message, "reach in "+bPath+" span more than the 61 nodes") + + _, diags = loadExternal(t, dir, root, Options{MaxSourceNodes: 120, MaxAliasSurplus: 1}) + require.Len(t, diags, 1, "a budget both fit admits both: %+v", diags) + assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) +} + +// v32OtherFixture and v32RootFixture are a 3.2 root reaching two external +// schemas, one writing a keyword only 3.2 defines (Pet's defaultMapping) and +// one with a defect (Broken: {type: 42}). +const v32OtherFixture = `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Pet: + type: object + properties: {petType: {type: string}} + required: [petType] + discriminator: + propertyName: petType + defaultMapping: '#/components/schemas/Dog' + mapping: {cat: '#/components/schemas/Cat'} + Dog: {allOf: [{$ref: '#/components/schemas/Pet'}]} + Cat: {allOf: [{$ref: '#/components/schemas/Pet'}]} + Broken: {type: 42} +` + +const v32RootFixture = `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + S: {$ref: "./other.yaml#/components/schemas/Pet"} + B: {$ref: "./other.yaml#/components/schemas/Broken"} +` + +// TestResolve_AReachedSchemaIsHeldToTheSourcesMinor: Pet's defaultMapping is a +// keyword 3.2 defines, so S, reached from a 3.2 source, reports nothing, while +// the type: 42 of Broken, which B reaches, is reported beside it. The second case reaches the same keyword +// nested in a path item rather than as the object itself. +func TestResolve_AReachedSchemaIsHeldToTheSourcesMinor(t *testing.T) { + t.Parallel() + + t.Run("a schema reached directly", func(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": v32OtherFixture}) + + _, diags := loadExternal(t, dir, v32RootFixture, Options{}) + + for _, d := range diags { + assert.Equal(t, jsontext.Pointer("/components/schemas/Broken/type"), d.Provenance.Pointer, + "S's defaultMapping is a 3.2 keyword and reports nothing: %+v", d) + } + assert.NotEmpty(t, diags, "Broken's type: 42 is a defect") + }) + + t.Run("the same keyword nested inside a reached path item", func(t *testing.T) { + t.Parallel() + other := `openapi: 3.2.0 +info: {title: O, version: "1"} +paths: + /x: + get: + operationId: getX + parameters: + - name: q + in: query + schema: + type: object + properties: {petType: {type: string}} + required: [petType] + discriminator: + propertyName: petType + defaultMapping: '#/components/schemas/Dog' + responses: {"200": {description: ok}} +components: + schemas: + Dog: {type: object} +` + root := `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /x: {$ref: "./other.yaml#/paths/~1x"} +` + dir := externalDir(t, map[string]string{"other.yaml": other}) + + _, diags := loadExternal(t, dir, root, Options{}) + + assert.Empty(t, diags, "the nested defaultMapping is a 3.2 keyword too: %+v", diags) + }) +} + +// TestResolve_AReached30SchemaIsCheckedAsTheSourcesAre compiles one 3.0 schema +// in the source, reached directly, and reached inside a parameter, and each +// draws the same two findings, as 3.0 words them: its type list, which 3.0 +// does not admit (3.0.4 §Schema Object: type "Value MUST be a string"), and its +// negative minLength. examples is not a 3.0 keyword, and draws none. +func TestResolve_AReached30SchemaIsCheckedAsTheSourcesAre(t *testing.T) { + t.Parallel() + const header = "openapi: 3.0.3\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n" + const body = "{type: [string, 'null'], examples: [a], minLength: -1}" + for name, c := range map[string]struct{ root, other string }{ + "in the source": {root: header + " schemas:\n S: " + body + "\n"}, + "reached directly": { + root: header + " schemas:\n S: {$ref: './other.yaml#/components/schemas/T'}\n", + other: "components:\n schemas:\n T: " + body + "\n", + }, + "reached inside a parameter": { + root: header + " parameters:\n P: {$ref: './other.yaml#/components/parameters/Q'}\n", + other: "components:\n parameters:\n Q: {name: q, in: query, schema: " + body + "}\n", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": c.other}) + + _, diags := loadExternal(t, dir, c.root, Options{}) + + require.Len(t, diags, 2, "%+v", diags) + for _, d := range diags { + assert.Equal(t, diag.Validation+"/validation-type-mismatch", d.Code) + } + assert.Contains(t, diags[0].Message, `Schema Object field "minLength" must be a non-negative integer in OpenAPI 3.0`) + assert.Contains(t, diags[1].Message, `Schema Object field "type" must be a string in OpenAPI 3.0`) + }) + } +} + +// nestedOtherFixture is an external document whose path item holds a parameter +// with an invalid in: sideways and a schema of twenty properties. Reached once +// through the parameter and once through the path item around it, those nodes +// are charged twice, which crosses a budget a single charge does not. Small, a +// separate response with a defect of its own, is reached after that, to pin +// that a document over its budget is not validated further. +func nestedOtherFixture() string { + var b strings.Builder + b.WriteString("openapi: 3.1.0\ninfo: {title: O, version: \"1\"}\npaths:\n" + + " /x:\n get:\n operationId: getX\n parameters:\n" + + " - name: q\n in: sideways\n schema:\n" + + " type: object\n properties:\n") + for i := 1; i <= 20; i++ { + fmt.Fprintf(&b, " p%d: {type: string}\n", i) + } + b.WriteString(" responses:\n \"200\": {description: ok}\n" + + "components:\n responses:\n Small: {description: ok, content: {application/json: {schema: {minLength: -1}}}}\n") + return b.String() +} + +// nestedRootPaths reach nestedOtherFixture's parameter from /a, the path item +// around it from /b, and Small from /c. They are declared last first: in that +// order a walk reaches the path item before the parameter it holds. +var nestedRootPaths = []string{ + ` /c: + get: + operationId: getC + responses: + "200": {$ref: "./other.yaml#/components/responses/Small"} +`, + " /b: {$ref: \"./other.yaml#/paths/~1x\"}\n", + ` /a: + get: + operationId: getA + parameters: + - $ref: "./other.yaml#/paths/~1x/get/parameters/0" + responses: {"200": {description: ok}} +`, +} + +// nestedRoot returns a source declaring paths in the order given. +func nestedRoot(paths []string) string { + return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + strings.Join(paths, "") +} + +// TestResolve_ReachedValidationIsBudgeted runs nestedRootPaths under three +// budgets. At 151 nodes (150 + 1) the parameter's finding is reported once, +// reached from /a, the path item at /b, which charges those nodes again, is refused +// as budget-exceeded, and Small at /c is not validated, in either declaration +// order. The three total 206 nodes, so 206 (205 + 1) admits all of them where +// MaxSourceNodes alone, 205, would not: that boundary is what shows the alias +// budget's share is counted. An unbounded budget admits them all too. +func TestResolve_ReachedValidationIsBudgeted(t *testing.T) { + t.Parallel() + nestedRootFixture := nestedRoot(nestedRootPaths) + other := nestedOtherFixture() + dir := externalDir(t, map[string]string{"other.yaml": other}) + otherPath := filepath.Join(dir, "other.yaml") + + t.Run("a tight budget refuses the container after the overlap", func(t *testing.T) { + t.Parallel() + _, diags := loadExternal(t, dir, nestedRootFixture, Options{MaxSourceNodes: 150, MaxAliasSurplus: 1}) + + require.Len(t, diags, 2, "exactly two: Small's document is already over budget, so neither "+ + "its finding nor a second budget-exceeded is reported: %+v", diags) + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) + assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) + + assert.Equal(t, diag.BudgetExceeded, diags[1].Code) + assert.Equal(t, ir.SeverityError, diags[1].Severity) + assert.Equal(t, jsontext.Pointer("/paths/~1b"), diags[1].Provenance.Pointer) + assert.Equal(t, fmt.Sprintf( + "the objects references reach in %s span more than the 151 nodes a document is validated over; "+ + "its validation stops at this one", otherPath), + diags[1].Message) + + reversed := slices.Clone(nestedRootPaths) + slices.Reverse(reversed) + _, again := loadExternal(t, dir, nestedRoot(reversed), Options{MaxSourceNodes: 150, MaxAliasSurplus: 1}) + if d := cmp.Diff(diags, again); d != "" { + t.Errorf("the budget is crossed at another object when the paths are reversed (-as written +reversed):\n%s", d) + } + }) + + for name, opts := range map[string]Options{ + "a budget the three fit admits them all": {MaxSourceNodes: 205, MaxAliasSurplus: 1}, + "an unbounded budget admits them all": {}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := loadExternal(t, dir, nestedRootFixture, opts) + + require.Len(t, diags, 2, "%+v", diags) + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), diags[0].Provenance.Pointer) + assert.Equal(t, diag.Validation+"/validation-allowed-values", diags[0].Code) + assert.Equal(t, jsontext.Pointer("/components/responses/Small/content/application~1json/schema/minLength"), + diags[1].Provenance.Pointer) + assert.Equal(t, diag.Validation+"/validation-type-mismatch", diags[1].Code) + }) + } +} + +// respelledOther is an external document with an anchored get whose parameter +// is itself invalid, a put with an invalid schema, and an /unreached path. +const respelledOther = `openapi: 3.1.0 +info: {title: O, version: "1"} +paths: + /x: + get: &g + operationId: EXTGET + parameters: + - {name: q, in: sideways, schema: {type: string}} + responses: {"200": {description: ok}} + put: + operationId: EXTPUT + parameters: + - {name: p, schema: {type: string, minLength: -1}} + responses: + "200": {description: ok} + "404": &r {description: EXTANCHOREDRESP} + /unreached: + get: + operationId: getU + parameters: + - {name: u, in: nowhere, schema: {type: string}} + responses: {"200": {description: ok}} +` + +// respelledRoot is a source whose two paths reference other.yaml's anchored +// path item over HTTP, spelling its URL with scheme. +func respelledRoot(scheme, host string) string { + url := scheme + "://" + host + "/other.yaml#/paths/~1x" + return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + " /x: {$ref: \"" + url + "\"}\n /y: {$ref: \"" + url + "\"}\n" +} + +// TestResolve_ValidatesOnceAfterARebuild: an upper-cased scheme forces GitHub +// #538's rebuild, since the resolver's own parse of a mis-keyed document skips +// the anchored get. /x's 8:25 finding, inside that get, exists only in the +// recovered document, and validateReached, run once on the document resolve +// returns, must find it exactly once. other.yaml is served over HTTP so the +// spelling is the one difference between the runs, which must report the same +// diagnostics: each finding once, placed in other.yaml at its node. +func TestResolve_ValidatesOnceAfterARebuild(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(respelledOther)) + assert.NoError(t, err) + })) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + run := func(scheme string) []ir.Diagnostic { + src := compilers.Source{Path: "root.yaml", Data: []byte(respelledRoot(scheme, host))} + _, diags, err := Load(t.Context(), 0, src, Options{Reader: reader.Disk{}}) + require.NoError(t, err) + return diags + } + + notRebuilt := run("http") + rebuilt := run("HTTP") + + respelled := cmp.Transformer("respelled", func(msg string) string { + return strings.ReplaceAll(msg, "HTTP://", "http://") + }) + if d := cmp.Diff(notRebuilt, rebuilt, respelled); d != "" { + t.Errorf("the diagnostics must be identical whether or not a rebuild ran (-not-rebuilt +rebuilt):\n%s", d) + } + made := map[string]bool{} + recovered := 0 + for _, d := range rebuilt { + assert.Equal(t, 1, d.Provenance.Source, "placed in other.yaml, where its node is: %+v", d) + key := fmt.Sprint(d.Code, d.Provenance, d.Message) + assert.False(t, made[key], "made once: %+v", d) + made[key] = true + assert.False(t, strings.HasPrefix(string(d.Provenance.Pointer), "/paths/~1unreached"), + "/unreached must not be validated: %+v", d) + if d.Provenance.Position == (ir.Position{Line: 8, Column: 25}) { + recovered++ + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/in"), d.Provenance.Pointer) + } + } + assert.Equal(t, 1, recovered, "the recovered get's own finding, exactly once: %+v", rebuilt) +} + +// inSourceFinding is one finding on a node of the source: the pointer it is +// placed at, its code and what it says, and at, the snippet of the fixture the +// node starts at, whose line and column it carries beside the pointer. +// "{line}" in text stands for that line. +type inSourceFinding struct { + pointer, code, text, at string +} + +// render spells the finding as renderDiags spells a diagnostic. +func (f inSourceFinding) render(t *testing.T, spec string) string { + t.Helper() + line, col := markerPosition(t, spec, f.at) + text := strings.ReplaceAll(f.text, "{line}", fmt.Sprint(line)) + return fmt.Sprintf("%s %d:%d %s %s", f.pointer, line, col, f.code, text) +} + +// renderDiags spells each diagnostic as its pointer, position, code and +// message, sorted. +func renderDiags(diags []ir.Diagnostic) []string { + out := make([]string, 0, len(diags)) + for _, d := range diags { + at := d.Provenance.Position + out = append(out, fmt.Sprintf("%s %d:%d %s %s", d.Provenance.Pointer, at.Line, at.Column, d.Code, d.Message)) + } + slices.Sort(out) + return out +} + +// reachedInSourceFixture is GitHub #872's reproducer: three security schemes +// and a parameter, each written under an x-* key and reached by a $ref, each +// missing what its own kind requires. +const reachedInSourceFixture = `openapi: 3.1.0 +info: {title: T, version: "1"} +security: + - key: [] + - oidc: [] + - oauth: [] +paths: + /x: + get: + parameters: + - $ref: '#/x-params/q' + responses: {"200": {description: ok}} +x-schemes: + key: {type: apiKey, in: header} + oidc: {type: openIdConnect} + oauth: {type: oauth2} +x-params: + q: {name: q, schema: {type: string}} +components: + securitySchemes: + key: {$ref: '#/x-schemes/key'} + oidc: {$ref: '#/x-schemes/oidc'} + oauth: {$ref: '#/x-schemes/oauth'} +` + +// TestResolve_WhatAReferenceReachesInTheSourceIsValidated pins that an object a +// $ref reaches in the source is validated, whether or not external references +// are read (GitHub #872): one under an x-* key, which the source's model never +// declares, whether reached by a $ref, a discriminator mapping entry, or a $ref +// inside raw YAML. Each finding is placed as the source's own are, at the +// pointer and position of the node it is about, not at the $ref (GitHub +// #879), resolution's and validation's alike. An object the model declares is +// reported once, by the source; a valid one reports nothing. +func TestResolve_WhatAReferenceReachesInTheSourceIsValidated(t *testing.T) { + t.Parallel() + const ( + required = "openapi/validation/validation-required-field" + allowed = "openapi/validation/validation-allowed-values" + invalid = "openapi/validation/validation-type-mismatch" + mismatch = "openapi/validation/validation-type-mismatch" + minimum = `Schema Object field "minLength" must be a non-negative integer in OpenAPI 3.1 (Schema Object)` + inValues = `Parameter Object field "in" must be one of "query", "header", "path", "cookie" in OpenAPI 3.1 (Parameter Object)` + noIn = `Parameter Object field "in" is REQUIRED in OpenAPI 3.1 (Parameter Object)` + noName = `Security Scheme Object field "name" is REQUIRED when type=apiKey in OpenAPI 3.1 (Security Scheme Object)` + noURL = `Security Scheme Object field "openIdConnectUrl" is REQUIRED when type=openIdConnect in OpenAPI 3.1 (Security Scheme Object)` + noFlows = `Security Scheme Object field "flows" is REQUIRED when type=oauth2 in OpenAPI 3.1 (Security Scheme Object)` + ) + const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + byOperation := header + `paths: + /x: + get: + parameters: + - $ref: '%s' + responses: {"200": {description: ok}} +` + for _, c := range []struct { + name string + spec string + want []inSourceFinding + }{ + {"GitHub #872's reproducer", reachedInSourceFixture, []inSourceFinding{ + {"/x-params/q", required, noIn, "{name: q"}, + {"/x-schemes/key", required, noName, "{type: apiKey"}, + {"/x-schemes/oidc", required, noURL, "{type: openIdConnect}"}, + {"/x-schemes/oauth", required, noFlows, "{type: oauth2}"}, + }}, + { + "a schema only a discriminator mapping entry reaches", + mappingSpec(petMapping("x: '#/x-lib/Bad'"), "x-lib:\n Bad: {type: string, minLength: -1}\n"), + []inSourceFinding{{"/x-lib/Bad/minLength", invalid, minimum, "-1"}}, + }, + { + "a schema a $ref inside raw YAML reaches", + mappingSpec(" S: {$ref: '#/x-lib/A'}\n", + "x-lib:\n A: {type: object, properties: {b: {$ref: '#/x-lib/B'}}}\n B: {type: string, minLength: -1}\n"), + []inSourceFinding{{"/x-lib/B/minLength", invalid, minimum, "-1"}}, + }, + { + "a type mismatch is told once", + mappingSpec(" S: {$ref: '#/x-lib/A'}\n", "x-lib:\n A: {type: string, minLength: abc}\n"), + []inSourceFinding{{"/x-lib/A/minLength", mismatch, minimum, "abc"}}, + }, + { + "a declared component is reported by the source alone", + fmt.Sprintf(byOperation, "#/components/parameters/P") + + "components:\n parameters:\n P: {name: p, in: sideways, schema: {type: string}}\n", + []inSourceFinding{{"/components/parameters/P/in", allowed, inValues, "sideways"}}, + }, + { + "a valid object under an extension reports nothing", + fmt.Sprintf(byOperation, "#/x-params/q") + + "x-params:\n q: {name: q, in: query, schema: {type: string}}\n", + nil, + }, + } { + for _, external := range []bool{false, true} { + t.Run(fmt.Sprintf("%s, external references %t", c.name, external), func(t *testing.T) { + t.Parallel() + want := make([]string, 0, len(c.want)) + for _, f := range c.want { + want = append(want, f.render(t, c.spec)) + } + slices.Sort(want) + + _, diags := loadTargets(t, "spec.yaml", c.spec, external) + + assert.Empty(t, cmp.Diff(want, renderDiags(diags))) + }) + } + } +} + +// TestResolve_InSourceReachedSitesDoNotDependOnDeclarationOrder pins that an +// object in the source reached from several places is reported once, at the +// node the finding is about rather than at any of them, whatever order they +// and the object are declared in. The places are $refs, discriminator mapping +// entries, and a $ref inside raw YAML; each case is compiled as written and +// then reversed, with the object moved from after the components to before +// them. +func TestResolve_InSourceReachedSitesDoNotDependOnDeclarationOrder(t *testing.T) { + t.Parallel() + const bad = " Bad: {type: string, minLength: -1}\n" + const lib = "x-lib:\n" + bad + ref := func(name string) string { return " " + name + ": {$ref: '#/x-lib/Bad'}\n" } + holder := func(name, mapping string) string { + return " " + name + ": {type: object, discriminator: {propertyName: k, mapping: {" + mapping + "}}}\n" + } + for _, c := range []struct { + name string + lib string + entries []string + }{ + {"two $refs and an entry, the least a $ref", lib, + []string{ref("A"), ref("B"), holder("Pet", "x: '#/x-lib/Bad'")}}, + {"two $refs and an entry, the least an entry", lib, + []string{holder("Alpha", "x: '#/x-lib/Bad'"), ref("Beta"), ref("Gamma")}}, + {"two entries of one mapping and a $ref", lib, + []string{holder("Alpha", "y: '#/x-lib/Bad', x: '#/x-lib/Bad'"), ref("Beta")}}, + {"a $ref and a $ref inside raw YAML", "x-lib:\n A: {type: object, properties: {b: {$ref: '#/x-lib/Bad'}}}\n" + bad, + []string{" S: {$ref: '#/x-lib/A'}\n", ref("T")}}, + } { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + compile := func(entries []string, libFirst bool) []string { + const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n" + components := "components:\n schemas:\n" + strings.Join(entries, "") + spec := header + components + c.lib + if libFirst { + spec = header + c.lib + components + } + _, diags := loadTargets(t, "spec.yaml", spec, false) + return diagLines(diags) + } + want := []string{"/x-lib/Bad/minLength openapi/validation/validation-type-mismatch"} + + backwards := slices.Clone(c.entries) + slices.Reverse(backwards) + + asWritten := compile(c.entries, false) + reversed := compile(backwards, true) + + assert.Empty(t, cmp.Diff(want, asWritten), "as written") + assert.Empty(t, cmp.Diff(want, reversed), "reversed") + }) + } +} + +// TestResolve_AnObjectTheSourceDeclaresIsNotChargedToTheBudget pins that what a +// $ref reaches in the source is charged to the validation budget only when the +// source's own validation did not cover it. A parameter and the schema inside +// it, both reached, fit a budget of the source's own size when the model +// declares them, as it has validated them already; written under an x-* key, +// the same two overlap, are charged twice, and cross it. +func TestResolve_AnObjectTheSourceDeclaresIsNotChargedToTheBudget(t *testing.T) { + t.Parallel() + var props []string + for i := 1; i <= 20; i++ { + props = append(props, fmt.Sprintf("p%d: {type: string}", i)) + } + param := "{name: q, in: query, schema: {type: object, properties: {" + strings.Join(props, ", ") + "}}}" + const header = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + const paths = "paths:\n /a:\n get:\n parameters:\n - $ref: '%s'\n responses: {\"200\": {description: ok}}\n" + declared := header + fmt.Sprintf(paths, "#/components/parameters/P") + + "components:\n schemas:\n S: {$ref: '#/components/parameters/P/schema'}\n parameters:\n P: " + param + "\n" + undeclared := header + fmt.Sprintf(paths, "#/x-lib/P") + + "components:\n schemas:\n S: {$ref: '#/x-lib/P/schema'}\nx-lib:\n P: " + param + "\n" + // load compiles spec under a budget of its own size, and returns the budget + // a document's validation is held to: that size and the alias surplus. + load := func(t *testing.T, spec string) (diags []ir.Diagnostic, limit int) { + t.Helper() + root, _, err := decodeStream([]byte(spec)) + require.NoError(t, err) + nodes := nodeCount(root) + opts := Options{MaxSourceNodes: nodes, MaxAliasSurplus: 1} + got, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(spec)}, opts) + require.NoError(t, err) + require.NotNil(t, got, "%+v", diags) + return diags, nodes + 1 + } + + t.Run("a declared parameter and its schema", func(t *testing.T) { + t.Parallel() + diags, _ := load(t, declared) + + assert.Empty(t, diags) + }) + t.Run("the same two under an extension", func(t *testing.T) { + t.Parallel() + diags, limit := load(t, undeclared) + + require.Len(t, diags, 1, "%+v", diags) + assert.Equal(t, diag.BudgetExceeded, diags[0].Code) + assert.Equal(t, ir.SeverityError, diags[0].Severity) + assert.Equal(t, jsontext.Pointer("/paths/~1a/get/parameters/0"), diags[0].Provenance.Pointer) + assert.Contains(t, diags[0].Message, fmt.Sprintf("span more than the %d nodes", limit)) + }) +} + +// TestResolve_AReachedObjectIsHeldToTheLaterVersionTable pins that an object +// a $ref reaches in the source outside its model draws what the same object +// written inline draws in an older document: a warning for each construct a +// later minor added, at the pointer it is written at, even beneath a path +// item's method or a callback's expression, and an additionalOperations' +// operations checked. An object +// reached both itself and inside another reached object draws each warning +// once. A 3.2 document draws none; another document's object is held to the +// source's version (GitHub #839), each warning placed at its pointer there. +func TestResolve_AReachedObjectIsHeldToTheLaterVersionTable(t *testing.T) { + t.Parallel() + const later = " openapi/later-version-construct" + pet := "x-lib:\n Pet: {type: object, discriminator: {propertyName: k, defaultMapping: '#/components/schemas/Cat'}}\n" + cases := []struct { + name, version, body string + want []string + }{ + {"a schema keyword", "3.1.0", + "paths: {}\ncomponents:\n schemas:\n Ref: {$ref: '#/x-lib/Pet'}\n Cat: {type: object}\n" + pet, + []string{"/x-lib/Pet/discriminator/defaultMapping" + later}}, + {"a response field", "3.1.0", + "paths: {}\ncomponents:\n responses:\n R: {$ref: '#/x-responses/R'}\nx-responses:\n R: {description: ok, summary: S}\n", + []string{"/x-responses/R/summary" + later}}, + {"a security scheme field", "3.1.0", + "paths: {}\ncomponents:\n securitySchemes:\n s: {$ref: '#/x-schemes/s'}\nx-schemes:\n" + + " s: {type: http, scheme: basic, deprecated: true}\n", + []string{"/x-schemes/s/deprecated" + later}}, + {"an example field", "3.1.0", + "paths:\n /a:\n get:\n responses:\n \"200\":\n description: ok\n" + + " content: {application/json: {examples: {e: {$ref: '#/x-examples/E'}}}}\n" + + "x-examples:\n E: {dataValue: 1}\n", + []string{"/x-examples/E/dataValue" + later}}, + {"a path item's additionalOperations, its operations checked", "3.1.0", + "paths:\n /a: {$ref: '#/x-paths/A'}\nx-paths:\n A:\n additionalOperations:\n" + + " PURGE: {parameters: [{name: q, in: sideways, schema: {type: string}}], " + + "responses: {\"200\": {description: ok}}}\n", + []string{"/x-paths/A/additionalOperations" + later, + "/x-paths/A/additionalOperations/PURGE/parameters/0/in openapi/validation/validation-allowed-values"}}, + {"an operation of a path item, under its method", "3.1.0", + "paths:\n /a: {$ref: '#/x-paths/A'}\nx-paths:\n A:\n" + + " get: {responses: {\"200\": {description: ok, summary: S}}}\n", + []string{"/x-paths/A/get/responses/200/summary" + later}}, + {"an operation of a callback, under its expression", "3.1.0", + "paths:\n /a:\n get:\n callbacks: {cb: {$ref: '#/x-cb/C'}}\n" + + " responses: {\"200\": {description: ok}}\nx-cb:\n C:\n" + + " '{$request.body#/u}': {post: {responses: {\"200\": {description: ok, summary: S}}}}\n", + []string{"/x-cb/C/{$request.body#~1u}/post/responses/200/summary" + later}}, + {"an object reached inside another reached one, warned once", "3.1.0", + "paths: {}\ncomponents:\n schemas:\n Ref: {$ref: '#/x-lib/Pet'}\n" + + " Inner: {$ref: '#/x-lib/Pet/properties/kind'}\n Cat: {type: object}\n" + + "x-lib:\n Pet: {type: object, properties: {kind: {type: object, discriminator: " + + "{propertyName: k, defaultMapping: '#/components/schemas/Cat'}}}}\n", + []string{"/x-lib/Pet/properties/kind/discriminator/defaultMapping" + later}}, + {"a 3.2 document, which added them", "3.2.0", + "paths: {}\ncomponents:\n schemas:\n Ref: {$ref: '#/x-lib/Pet'}\n Cat: {type: object}\n" + + " responses:\n R: {$ref: '#/x-responses/R'}\n" + pet + "x-responses:\n R: {description: ok, summary: S}\n", + []string{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + spec := "openapi: " + c.version + "\ninfo: {title: T, version: \"1\"}\n" + c.body + _, diags := loadTargets(t, "spec.yaml", spec, false) + assert.Empty(t, cmp.Diff(c.want, diagLines(diags)), "%+v", diags) + }) + } + + t.Run("another document's object is held to the source's version", func(t *testing.T) { + t.Parallel() + dir := externalDir(t, map[string]string{"other.yaml": pet}) + _, diags := loadExternal(t, dir, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n"+ + "components:\n schemas:\n Ref: {$ref: 'other.yaml#/x-lib/Pet'}\n Cat: {type: object}\n", Options{}) + assert.Empty(t, cmp.Diff([]string{"/x-lib/Pet/discriminator/defaultMapping" + later}, diagLines(diags)), "%+v", diags) + require.Len(t, diags, 1) + assert.Equal(t, 1, diags[0].Provenance.Source, "placed in other.yaml's entry") + }) +} diff --git a/compilers/openapi/internal/load/unprepared_external_internal_test.go b/compilers/openapi/internal/load/unprepared_external_internal_test.go new file mode 100644 index 00000000..b07fb521 --- /dev/null +++ b/compilers/openapi/internal/load/unprepared_external_internal_test.go @@ -0,0 +1,630 @@ +package load + +import ( + "context" + "encoding/json/jsontext" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/google/go-cmp/cmp" + soa "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/engine/reader" + "github.com/dexpace/morphic/ir" +) + +// TestResolveExternal_RecoversARespelledURL pins the fix for GitHub #538: for +// each URL spelling net/url respells, the anchored entries the resolver's own +// parse would have skipped are recovered, and the document is fetched exactly +// once — the second pass finds the first pass's prepared tree and bytes under +// the resolver's own key and reads nothing over the wire. +func TestResolveExternal_RecoversARespelledURL(t *testing.T) { + t.Parallel() + for _, tc := range respellings { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, requests := countingServer(t, anchoredExternalDoc) + url := tc.spell(srv.URL + "/ext.yaml") + + doc, diags, err := resolveSpec(t, rootReferencing(url), "root.yaml") + + require.NoError(t, err) + assert.Empty(t, diags, "recovery leaves no internal-invariant diagnostic") + assertRecovered(t, doc) + assert.Equal(t, int32(1), requests.Load(), "the document is fetched exactly once") + }) + } +} + +// TestResolveExternal_RecoversAChainedHop probes a chain: the root's reference +// reaches a.yaml canonically, and a.yaml's own path item is itself a reference +// to a respelled b.yaml. Both documents are fetched exactly once in total, so +// the second pass re-fetched neither — it found both under the keys the first +// pass's hop-by-hop walk (resolutionTrail) recorded. +func TestResolveExternal_RecoversAChainedHop(t *testing.T) { + t.Parallel() + var reqA, reqB atomic.Int32 + mux := http.NewServeMux() + var srvURL string // set once the server exists; the handler closes over it + mux.HandleFunc("/a.yaml", func(w http.ResponseWriter, _ *http.Request) { + reqA.Add(1) + bURL := "HTTP://" + strings.TrimPrefix(srvURL, "http://") + "/b.yaml" + _, err := w.Write([]byte("openapi: 3.1.0\ninfo: {title: A, version: \"1\"}\n" + + "paths:\n /x: {$ref: \"" + bURL + "#/paths/~1x\"}\n")) + assert.NoError(t, err) + }) + mux.HandleFunc("/b.yaml", func(w http.ResponseWriter, _ *http.Request) { + reqB.Add(1) + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + srvURL = srv.URL + + doc, diags, err := resolveSpec(t, rootReferencing(srv.URL+"/a.yaml"), "root.yaml") + + require.NoError(t, err) + assert.Empty(t, diags) + assertRecovered(t, doc) + assert.Equal(t, int32(1), reqA.Load(), "a.yaml is fetched exactly once") + assert.Equal(t, int32(1), reqB.Load(), "b.yaml is fetched exactly once; the second pass fetches nothing new") +} + +// TestResolveExternal_AnAnchorlessRespellingIsNotRebuilt pins the anyAnchored +// gate: a respelled URL whose document carries no anchor at all has nothing the +// resolver's own parse could have skipped, so the second pass — and the rebuild +// it would cost — never runs. +func TestResolveExternal_AnAnchorlessRespellingIsNotRebuilt(t *testing.T) { + t.Parallel() + const plain = "openapi: 3.1.0\ninfo: {title: O, version: \"1\"}\n" + + "paths:\n /x:\n get: {operationId: getX, responses: {\"200\": {description: ok}}}\n" + srv, requests := countingServer(t, plain) + url := "HTTP://" + strings.TrimPrefix(srv.URL, "http://") + "/ext.yaml" + rebuild := func() (*soa.OpenAPI, error) { + t.Fatal("rebuild must not be called: the respelled document carries no anchor to recover") + return nil, nil // unreachable: t.Fatal stops this goroutine first + } + + doc, diags, err := resolveSpecWith(t, rootReferencing(url), rebuild) + + require.NoError(t, err) + assert.Empty(t, diags) + ref, ok := doc.Paths.Get("/x") + require.True(t, ok) + get := ref.GetObject().Get() + require.NotNil(t, get) + assert.Equal(t, "getX", get.GetOperationID()) + assert.Equal(t, int32(1), requests.Load()) +} + +// TestResolveExternal_TheSecondPassSendsNoRequestTheFirstMade pins that the +// second pass is answered as the first pass's requests were, for the references +// that failed in it: a failed status, a transport error, a refusal, and a +// document without the fragment named each reach the server once and are +// reported once. flaky.yaml would succeed if asked again, under a spelling the +// resolver keys apart, so a second request would read it unprepared where the +// first pass built nothing from it. +func TestResolveExternal_TheSecondPassSendsNoRequestTheFirstMade(t *testing.T) { + t.Parallel() + var flaky, refused, unfound atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/ext.yaml", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + }) + mux.HandleFunc("/flaky.yaml", func(w http.ResponseWriter, _ *http.Request) { + if flaky.Add(1) == 1 { + http.Error(w, "busy", http.StatusServiceUnavailable) + return + } + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + }) + mux.HandleFunc("/refused.yaml", func(w http.ResponseWriter, _ *http.Request) { + refused.Add(1) + _, err := w.Write([]byte("p: &a [*a]\n")) + assert.NoError(t, err) + }) + mux.HandleFunc("/unfound.yaml", func(w http.ResponseWriter, _ *http.Request) { + unfound.Add(1) + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + // A server of its own: the transport retries a request that fails on a + // reused connection, and the first one to a server never is. + var cut atomic.Int32 + cutSrv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + cut.Add(1) + hj, ok := w.(http.Hijacker) + if !assert.True(t, ok) { + return + } + conn, _, err := hj.Hijack() + if !assert.NoError(t, err) { + return + } + assert.NoError(t, conn.Close()) + }) + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + " /x: {$ref: \"" + respelled(srv.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n" + + " /flaky: {$ref: \"" + respelled(srv.URL, "HTTP") + "/flaky.yaml#/paths/~1x\"}\n" + + " /refused: {$ref: \"" + srv.URL + "/refused.yaml#/paths/~1x\"}\n" + + " /unfound: {$ref: \"" + srv.URL + "/unfound.yaml#/paths/~1nowhere\"}\n" + + " /cut: {$ref: \"" + cutSrv.URL + "/cut.yaml#/paths/~1x\"}\n" + + doc, diags, err := resolveSpec(t, spec, "root.yaml") + + require.NoError(t, err) + assertRecovered(t, doc) + codes := make([]string, len(diags)) + for i, d := range diags { + codes[i] = d.Code + } + assert.Equal(t, []string{diag.UnresolvedRef, diag.UnresolvedRef, diag.UnresolvedRef, diag.UnresolvedRef}, codes, + "each failure once, and no internal fault for flaky.yaml") + assert.Equal(t, int32(1), flaky.Load(), "a failed status is the document's answer") + assert.Equal(t, int32(1), refused.Load(), "a refusal is the document's answer") + assert.Equal(t, int32(1), unfound.Load(), "a document's bytes are its answer") + assert.Equal(t, int32(1), cut.Load(), "a transport error is the document's answer") +} + +// TestResolveExternal_EveryReferenceGetsTheOneAnswerItsDocumentGot pins that a +// document is read once a compile, whichever reference asks and in either +// pass: the references to a URL, spelled alike or as the resolver keys apart, +// all fail with the 503 its one request got, or all resolve. A second request +// would have succeeded, so a reference that asked again would resolve what +// another failed on. A respelled $ref to another server sets the second pass +// off in each case. +func TestResolveExternal_EveryReferenceGetsTheOneAnswerItsDocumentGot(t *testing.T) { + t.Parallel() + trigger, _ := countingServer(t, anchoredExternalDoc) + for _, tc := range []struct { + name string + schemes []string + failFirst bool + }{ + {"three spellings, failed", []string{"HTTP", "Http", "hTTP"}, true}, + {"three spellings, served", []string{"HTTP", "Http", "hTTP"}, false}, + {"one spelling, failed", []string{"http", "http"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + failing := int32(2) + if tc.failFirst { + failing = 1 + } + srv := newTestServer(t, failingHandler(t, failing, &requests)) + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + " /x: {$ref: \"" + respelled(trigger.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n" + for i, scheme := range tc.schemes { + spec += fmt.Sprintf(" /p%d: {$ref: \"%s/ext.yaml#/paths/~1x\"}\n", i, respelled(srv.URL, scheme)) + } + + doc, diags, err := resolveSpec(t, spec, "root.yaml") + + require.NoError(t, err) + assertRecoveredAt(t, doc, "/x") + assert.Equal(t, int32(1), requests.Load(), "one request, in neither pass sent again") + if !tc.failFirst { + assert.Empty(t, diags) + for i := range tc.schemes { + assertRecoveredAt(t, doc, fmt.Sprintf("/p%d", i)) + } + return + } + require.Len(t, diags, len(tc.schemes), "every reference fails, each once") + for _, d := range diags { + assert.Equal(t, diag.UnresolvedRef, d.Code) + assert.Contains(t, d.Message, "503") + } + }) + } +} + +// failingHandler serves anchoredExternalDoc to every request but the n-th, +// which fails with 503, counting them in requests. +func failingHandler(t *testing.T, n int32, requests *atomic.Int32) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, _ *http.Request) { + if requests.Add(1) == n { + http.Error(w, "busy", http.StatusServiceUnavailable) + return + } + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + } +} + +// TestResolveExternal_TheSecondPassResolvesASelfReferenceAsTheFirstDid pins +// that both passes answer a $ref naming the source's own file from the source +// as held (GitHub #759): /b reaches /a's own declaration whether or not another +// $ref is respelled, which is what decides whether a second pass runs. +func TestResolveExternal_TheSecondPassResolvesASelfReferenceAsTheFirstDid(t *testing.T) { + t.Parallel() + trigger, _ := countingServer(t, anchoredExternalDoc) + for _, tc := range []struct { + name string + other string + }{ + {"alone", ""}, + {"beside a respelled $ref", " /x: {$ref: \"" + respelled(trigger.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + tc.other + + " /a: {$ref: '#/components/pathItems/Shared'}\n" + + " /b: {$ref: './self.yaml#/components/pathItems/Shared'}\n" + + "components:\n pathItems:\n Shared:\n" + + " get: {operationId: dup, responses: {\"200\": {description: ok}}}\n" + dir := t.TempDir() + writeFile(t, dir, "self.yaml", spec) + + doc, diags, err := resolveSpec(t, spec, filepath.Join(dir, "self.yaml")) + + require.NoError(t, err) + assert.Empty(t, diags) + a, ok := doc.Paths.Get("/a") + require.True(t, ok) + b, ok := doc.Paths.Get("/b") + require.True(t, ok) + require.NotNil(t, a.GetObject()) + require.NotNil(t, b.GetObject()) + assert.Same(t, a.GetObject(), b.GetObject(), + "/b reaches the source's own declaration, as /a does") + }) + } +} + +// TestResolveExternal_ARebuildErrorIsReturned pins that resolveExternal returns +// a rebuild failure to its caller rather than swallowing it. +// +// Load's own rebuild cannot fail once its first unmarshal has succeeded: +// unmarshal is a pure function of (ctx, data, root) and ignores ctx, so only an +// injected error reaches this branch. Load's wrap of it is driven through the +// rebuildDoc seam by TestLoad_ARebuildFailureIsWrappedAsRebuildSource. +func TestResolveExternal_ARebuildErrorIsReturned(t *testing.T) { + t.Parallel() + srv, requests := countingServer(t, anchoredExternalDoc) + url := "HTTP://" + strings.TrimPrefix(srv.URL, "http://") + "/ext.yaml" + sentinel := errors.New("rebuild boom") + rebuild := func() (*soa.OpenAPI, error) { return nil, sentinel } + + doc, diags, err := resolveSpecWith(t, rootReferencing(url), rebuild) + + assert.Nil(t, doc) + assert.Nil(t, diags) + assert.ErrorIs(t, err, sentinel) + assert.Equal(t, int32(1), requests.Load(), "the first pass still reads the document once before rebuild fails") +} + +// otherDocWithAnchoredGetAndFinding is a document reached only through an +// external $ref: an anchored get operation (so the resolver's own parse would +// fold right over it, GitHub #501) whose 200 response carries a finding +// (GitHub #537), and a second, separately anchored response on the same +// operation — proving recovery for the responses map's fold too, not only the +// path item's, which anchoredExternalDoc already covers. +const otherDocWithAnchoredGetAndFinding = `openapi: 3.1.0 +info: {title: O, version: "1"} +paths: + /x: + get: &g + operationId: EXTGET + responses: + "200": + description: ok + headers: + X: + schema: {type: string} + required: notabool + "404": &n + description: missing +` + +// combinedRootSpec is a source with two ordinary ghost references beside the +// $ref to other.yaml at url, so the diagnostic list the test below compares is +// more than the one entry the recovery pass touches. +func combinedRootSpec(url string) string { + return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + " /a:\n parameters:\n - {$ref: '#/components/parameters/GhostParam'}\n" + + " get: {operationId: getA, responses: {\"200\": {description: ok}}}\n" + + " /x: {$ref: \"" + url + "#/paths/~1x\"}\n" + + "components:\n schemas:\n S: {$ref: '#/components/schemas/GhostSchema'}\n" +} + +// TestResolveExternal_ASecondPassReportsWhatOnePassWould pins how per-reference +// reporting meets resolveExternal's two passes (see its doc comment): the +// second pass REPLACES the first pass's diagnostics rather than adding to them, +// and replacing must neither double nor lose a report. The two ordinary ghost +// references in combinedRootSpec are what makes that a claim about the whole +// diagnostic list a compile reports, not only about the one entry the recovery +// touches. +func TestResolveExternal_ASecondPassReportsWhatOnePassWould(t *testing.T) { + t.Parallel() + srv, requests := countingServer(t, otherDocWithAnchoredGetAndFinding) + + run := func(url string) (*Document, []ir.Diagnostic) { + requests.Store(0) + src := compilers.Source{Path: "root.yaml", Data: []byte(combinedRootSpec(url))} + got, diags, err := Load(t.Context(), 0, src, Options{Reader: reader.Disk{}}) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, int32(1), requests.Load(), "one request per compile") + return got, diags + } + + respelledURL := respelled(srv.URL, "HTTP") + "/other.yaml" + recovered, twoPassDiags := run(respelledURL) + _, onePassDiags := run(srv.URL + "/other.yaml") + + if d := cmp.Diff(spelledAs(onePassDiags, srv.URL, respelled(srv.URL, "HTTP")), twoPassDiags); d != "" { + t.Errorf("a second pass must report exactly what one pass would (-onePass +twoPass):\n%s", d) + } + + ref, ok := recovered.Doc.Paths.Get("/x") + require.True(t, ok) + item := ref.GetObject() + require.NotNil(t, item) + get := item.Get() + require.NotNil(t, get, "the anchored get was folded, not skipped") + assert.Equal(t, "EXTGET", get.GetOperationID()) + _, ok = get.GetResponses().Get("404") + assert.True(t, ok, "the separately anchored 404 was folded, not skipped") +} + +// twoRefsTarget is an external document with an anchored get, so that a +// respelling of its URL forces the rebuild. /x's put has a parameter missing +// its in, a finding the unmarshal makes rather than validation; /w is clean. +const twoRefsTarget = `openapi: 3.1.0 +info: {title: O, version: "1"} +paths: + /w: + get: {operationId: W, responses: {"200": {description: ok}}} + /x: + get: &g + operationId: G + responses: {"200": {description: ok}} + put: + operationId: P + parameters: + - {name: p, schema: {type: string}} + responses: {"200": {description: ok}} +` + +// TestResolveExternal_TwoRefsToOneTargetReportItsFindingOnce pins that a finding +// in a target two $refs reach is reported once, at its node, with or without a +// rebuild. /w reads the document first, so the library builds /x's +// target from bytes it already holds, and caches no object built that way: it +// builds the target again for /y, which the reached check's identity of a +// finding keeps from a second report. +func TestResolveExternal_TwoRefsToOneTargetReportItsFindingOnce(t *testing.T) { + t.Parallel() + srv, _ := countingServer(t, twoRefsTarget) + run := func(base string, wantRebuilds int32) []ir.Diagnostic { + doc := base + "/other.yaml#/paths/" + src := compilers.Source{Path: "root.yaml", Data: []byte("openapi: 3.1.0\n" + + "info: {title: T, version: \"1\"}\npaths:\n" + + " /w: {$ref: \"" + doc + "~1w\"}\n" + + " /x: {$ref: \"" + doc + "~1x\"}\n /y: {$ref: \"" + doc + "~1x\"}\n")} + var rebuilds atomic.Int32 + rebuild := func(ctx context.Context, data []byte, root *yaml.Node) (*soa.OpenAPI, error) { + rebuilds.Add(1) + return unmarshal(ctx, data, root) + } + _, diags, err := Load(t.Context(), 0, src, Options{Reader: reader.Disk{}, rebuildDoc: rebuild}) + require.NoError(t, err) + require.Equal(t, wantRebuilds, rebuilds.Load(), "%s takes the path this case names", base) + return diags + } + + onePass := run(srv.URL, 0) + rebuilt := run(respelled(srv.URL, "HTTP"), 1) + + if d := cmp.Diff(spelledAs(onePass, srv.URL, respelled(srv.URL, "HTTP")), rebuilt); d != "" { + t.Errorf("two $refs to one target must report alike with and without a rebuild (-onePass +rebuilt):\n%s", d) + } + for name, diags := range map[string][]ir.Diagnostic{"one pass": onePass, "rebuilt": rebuilt} { + var sites []jsontext.Pointer + for _, d := range diags { + if d.Code == diag.Validation+"/validation-required-field" { + sites = append(sites, d.Provenance.Pointer) + } + } + assert.Equal(t, []jsontext.Pointer{"/paths/~1x/put/parameters/0"}, sites, + "%s: the finding is reported once, at its node", name) + } +} + +// spelledAs returns diags with every mention of from in a message spelled as +// to, so two runs whose $refs spell one URL differently compare on the rest: +// a finding's message names its document as the $ref spelled it. +func spelledAs(diags []ir.Diagnostic, from, to string) []ir.Diagnostic { + out := make([]ir.Diagnostic, len(diags)) + for i, d := range diags { + d.Message = strings.ReplaceAll(d.Message, from, to) + out[i] = d + } + return out +} + +// TestResolveExternal_ADocumentOnlyTheSecondPassReadsIsPrepared holds the second +// pass's reader to the model it resolves: other.yaml is read for the first time +// there, and its prepared tree must be stored where the rebuilt model looks, not +// in the first model, or its anchored entries are skipped. +func TestResolveExternal_ADocumentOnlyTheSecondPassReadsIsPrepared(t *testing.T) { + t.Parallel() + ext, extRequests := countingServer(t, anchoredExternalDoc) + other, otherRequests := countingServer(t, anchoredExternalDoc) + extURL := "HTTP://" + strings.TrimPrefix(ext.URL, "http://") + "/ext.yaml" + first, rebuild := secondPassSpecs(t, extURL, other.URL+"/other.yaml") + + doc, diags, err := resolveSpecWith(t, first, rebuild) + + require.NoError(t, err) + assert.Empty(t, diags) + assertRecoveredAt(t, doc, "/x") + assertRecoveredAt(t, doc, "/y") + assert.Equal(t, int32(1), extRequests.Load(), "ext.yaml is fetched once, in the first pass") + assert.Equal(t, int32(1), otherRequests.Load(), "other.yaml is fetched once, in the second") +} + +// TestResolveExternal_ARespelledDocumentOnlyTheSecondPassReadsIsReported pins +// that resolveExternal reports a document the second pass read unprepared, +// rather than dropping its anchored entries in silence: other.yaml is respelled, +// so the resolver misses the tree stored under its request's URL. +func TestResolveExternal_ARespelledDocumentOnlyTheSecondPassReadsIsReported(t *testing.T) { + t.Parallel() + ext, _ := countingServer(t, anchoredExternalDoc) + other, _ := countingServer(t, anchoredExternalDoc) + extURL := "HTTP://" + strings.TrimPrefix(ext.URL, "http://") + "/ext.yaml" + otherURL := "HTTP://" + strings.TrimPrefix(other.URL, "http://") + "/other.yaml" + first, rebuild := secondPassSpecs(t, extURL, otherURL) + + _, diags, err := resolveSpecWith(t, first, rebuild) + + require.NoError(t, err) + require.Len(t, diags, 1) + assert.Equal(t, ir.SeverityError, diags[0].Severity) + assert.Equal(t, diag.InternalInvariant, diags[0].Code) + assert.Contains(t, diags[0].Message, otherURL) + assert.Equal(t, jsontext.Pointer("/paths/~1y"), diags[0].Provenance.Pointer, + "reported at the reference that read it") +} + +// respelledChain resolves a root whose /x starts a chain of n documents served +// over HTTP, each reached through an upper-case scheme and so missed by the +// resolver. Only the last holds anchored entries, so only reaching it matters; +// it returns /x's path item as resolved, and the diagnostics. With secondPass, +// the root's /t also reaches an anchored document that way, which sets the +// second pass off whatever the chain's length. +func respelledChain(t *testing.T, n int, secondPass bool) (*soa.PathItem, []ir.Diagnostic) { + t.Helper() + var base string + mux := http.NewServeMux() + mux.HandleFunc("/t.yaml", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + }) + for i := range n { + mux.HandleFunc(fmt.Sprintf("/f%d.yaml", i), func(w http.ResponseWriter, _ *http.Request) { + body := anchoredExternalDoc + if i < n-1 { + next := fmt.Sprintf("HTTP://%s/f%d.yaml", strings.TrimPrefix(base, "http://"), i+1) + body = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + " /x: {$ref: \"" + next + "#/paths/~1x\"}\n" + } + _, err := w.Write([]byte(body)) + assert.NoError(t, err) + }) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + base = srv.URL + + root := rootReferencing(respelled(srv.URL, "HTTP") + "/f0.yaml") + if secondPass { + root += " /t: {$ref: \"" + respelled(srv.URL, "HTTP") + "/t.yaml#/paths/~1x\"}\n" + } + doc, diags, err := resolveSpec(t, root, "root.yaml") + require.NoError(t, err) + ref, ok := doc.Paths.Get("/x") + require.True(t, ok) + require.NotNil(t, ref.GetObject()) + return ref.GetObject(), diags +} + +// TestResolveExternal_TheHopBoundIsWhereRecoveryEnds pins what the bound costs, +// from both sides: the last document of a chain of maxResolutionHops is +// recovered, and a chain one longer is not, with no report, because recovery +// never learns the last document was read. A second pass another $ref sets off +// resolves that one from the bytes its one read got, as the first pass did. The bound is +// for an absurd chain; this keeps a change to what happens at it deliberate. +func TestResolveExternal_TheHopBoundIsWhereRecoveryEnds(t *testing.T) { + t.Parallel() + + t.Run("a chain at the bound is recovered", func(t *testing.T) { + t.Parallel() + item, diags := respelledChain(t, maxResolutionHops, false) + + assert.Empty(t, diags) + require.NotNil(t, item.Get(), "the last document's anchored GET entry was folded") + require.NotNil(t, item.Put()) + }) + + for _, tc := range []struct { + name string + secondPass bool + }{ + {"a chain one past it is not", false}, + {"nor in a second pass another $ref sets off", true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + item, diags := respelledChain(t, maxResolutionHops+1, tc.secondPass) + + assert.Empty(t, diags, "nothing reports the entries that were skipped") + assert.Nil(t, item.Get(), "the last document is the resolver's own parse, which skips them") + assert.Nil(t, item.Put()) + }) + } +} + +// TestResolveExternal_ACanonicalDocumentPastTheHopBoundStaysPrepared covers a +// document no reference records: the last of a chain one document past +// maxResolutionHops, spelled canonically. Its request is answered with the bytes +// the first pass read, and the tree the first pass prepared is handed over +// under the key it was prepared under, which is the resolver's, so its anchored +// entries hold and nothing in the chain is fetched twice. ext.yaml is what sets +// the second pass off. +func TestResolveExternal_ACanonicalDocumentPastTheHopBoundStaysPrepared(t *testing.T) { + t.Parallel() + const n = maxResolutionHops + 1 + var base string + var fetches [n]atomic.Int32 + mux := http.NewServeMux() + for i := range n { + mux.HandleFunc(fmt.Sprintf("/f%d.yaml", i), func(w http.ResponseWriter, _ *http.Request) { + fetches[i].Add(1) + body := anchoredExternalDoc + if i < n-1 { + body = rootReferencing(fmt.Sprintf("%s/f%d.yaml", base, i+1)) + } + _, err := w.Write([]byte(body)) + assert.NoError(t, err) + }) + } + mux.HandleFunc("/ext.yaml", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(anchoredExternalDoc)) + assert.NoError(t, err) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + base = srv.URL + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + " /x: {$ref: \"" + respelled(srv.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n" + + " /chain: {$ref: \"" + srv.URL + "/f0.yaml#/paths/~1x\"}\n" + + doc, diags, err := resolveSpec(t, spec, "root.yaml") + + require.NoError(t, err) + assert.Empty(t, diags) + assertRecoveredAt(t, doc, "/x") + assertRecoveredAt(t, doc, "/chain") + for i := range fetches { + assert.Equal(t, int32(1), fetches[i].Load(), "f%d.yaml is fetched once", i) + } +} diff --git a/compilers/openapi/internal/load/unprepared_internal_test.go b/compilers/openapi/internal/load/unprepared_internal_test.go index 51a28c96..9c59e920 100644 --- a/compilers/openapi/internal/load/unprepared_internal_test.go +++ b/compilers/openapi/internal/load/unprepared_internal_test.go @@ -11,11 +11,9 @@ import ( "path/filepath" "slices" "strings" - "sync" "sync/atomic" "testing" - "github.com/google/go-cmp/cmp" oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" soa "github.com/speakeasy-api/openapi/openapi" "github.com/speakeasy-api/openapi/references" @@ -52,15 +50,6 @@ func resolveSpec(t *testing.T, spec, path string) (*soa.OpenAPI, []ir.Diagnostic return resolved, passDiags(pointerAt(0, overlay.Origin{}), pass, missed), err } -// resolveSpecWith is resolveSpec at root.yaml with a caller-supplied rebuild, for -// the tests that need to observe or fail whether it is called. -func resolveSpecWith(t *testing.T, spec string, rebuild func() (*soa.OpenAPI, error)) (*soa.OpenAPI, []ir.Diagnostic, error) { - t.Helper() - resolved, _, _, pass, missed, err := resolveExternal(t.Context(), modelOf(t, spec), - Siblings{}, sourceDocument{path: "root.yaml"}, Options{Reader: reader.Disk{}}, rebuild) - return resolved, passDiags(pointerAt(0, overlay.Origin{}), pass, missed), err -} - // passDiags is what pass reports, and the documents missed it found // unprepared, a finding in another document placed at the source's root, as no // document is listed: nil for no pass, as a failed rebuild leaves. @@ -121,30 +110,6 @@ func assertRecoveredAt(t *testing.T, doc *soa.OpenAPI, path string) { assert.Equal(t, "EXTPUT", put.GetOperationID()) } -// respellings is the set of URL spellings net/url respells relative to how -// http.NewRequest's caller wrote them (GitHub #538): the resolver keys a -// document by the reference's absolute URL as written, while the reader stores -// it under req.URL.String(). -// -// A literal space in the path is absent: url.Parse and http.NewRequest -// normalize it and its %20 escape to one request key, so the two spellings -// never produce different keys. An empty port is covered by TestPreparedFor, -// since a test server cannot listen on the default port. -var respellings = []struct { - name string - spell func(canonical string) string -}{ - {"upper-case scheme", func(c string) string { - return "HTTP://" + strings.TrimPrefix(c, "http://") - }}, - {"mixed-case scheme", func(c string) string { - return "Http://" + strings.TrimPrefix(c, "http://") - }}, - {"escaped userinfo", func(c string) string { - return strings.Replace(c, "http://", "http://us%65r@", 1) - }}, -} - // countingServer starts an httptest.Server serving body to every request, and // returns it with a counter of how many requests it received. func countingServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) { @@ -159,449 +124,6 @@ func countingServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) return srv, &requests } -// TestResolveExternal_RecoversARespelledURL pins the fix for GitHub #538: for -// each URL spelling net/url respells, the anchored entries the resolver's own -// parse would have skipped are recovered, and the document is fetched exactly -// once — the second pass finds the first pass's prepared tree and bytes under -// the resolver's own key and reads nothing over the wire. -func TestResolveExternal_RecoversARespelledURL(t *testing.T) { - t.Parallel() - for _, tc := range respellings { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - srv, requests := countingServer(t, anchoredExternalDoc) - url := tc.spell(srv.URL + "/ext.yaml") - - doc, diags, err := resolveSpec(t, rootReferencing(url), "root.yaml") - - require.NoError(t, err) - assert.Empty(t, diags, "recovery leaves no internal-invariant diagnostic") - assertRecovered(t, doc) - assert.Equal(t, int32(1), requests.Load(), "the document is fetched exactly once") - }) - } -} - -// TestResolveExternal_RecoversAChainedHop probes a chain: the root's reference -// reaches a.yaml canonically, and a.yaml's own path item is itself a reference -// to a respelled b.yaml. Both documents are fetched exactly once in total, so -// the second pass re-fetched neither — it found both under the keys the first -// pass's hop-by-hop walk (resolutionTrail) recorded. -func TestResolveExternal_RecoversAChainedHop(t *testing.T) { - t.Parallel() - var reqA, reqB atomic.Int32 - mux := http.NewServeMux() - var srvURL string // set once the server exists; the handler closes over it - mux.HandleFunc("/a.yaml", func(w http.ResponseWriter, _ *http.Request) { - reqA.Add(1) - bURL := "HTTP://" + strings.TrimPrefix(srvURL, "http://") + "/b.yaml" - _, err := w.Write([]byte("openapi: 3.1.0\ninfo: {title: A, version: \"1\"}\n" + - "paths:\n /x: {$ref: \"" + bURL + "#/paths/~1x\"}\n")) - assert.NoError(t, err) - }) - mux.HandleFunc("/b.yaml", func(w http.ResponseWriter, _ *http.Request) { - reqB.Add(1) - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - srvURL = srv.URL - - doc, diags, err := resolveSpec(t, rootReferencing(srv.URL+"/a.yaml"), "root.yaml") - - require.NoError(t, err) - assert.Empty(t, diags) - assertRecovered(t, doc) - assert.Equal(t, int32(1), reqA.Load(), "a.yaml is fetched exactly once") - assert.Equal(t, int32(1), reqB.Load(), "b.yaml is fetched exactly once; the second pass fetches nothing new") -} - -// TestResolveExternal_AnAnchorlessRespellingIsNotRebuilt pins the anyAnchored -// gate: a respelled URL whose document carries no anchor at all has nothing the -// resolver's own parse could have skipped, so the second pass — and the rebuild -// it would cost — never runs. -func TestResolveExternal_AnAnchorlessRespellingIsNotRebuilt(t *testing.T) { - t.Parallel() - const plain = "openapi: 3.1.0\ninfo: {title: O, version: \"1\"}\n" + - "paths:\n /x:\n get: {operationId: getX, responses: {\"200\": {description: ok}}}\n" - srv, requests := countingServer(t, plain) - url := "HTTP://" + strings.TrimPrefix(srv.URL, "http://") + "/ext.yaml" - rebuild := func() (*soa.OpenAPI, error) { - t.Fatal("rebuild must not be called: the respelled document carries no anchor to recover") - return nil, nil // unreachable: t.Fatal stops this goroutine first - } - - doc, diags, err := resolveSpecWith(t, rootReferencing(url), rebuild) - - require.NoError(t, err) - assert.Empty(t, diags) - ref, ok := doc.Paths.Get("/x") - require.True(t, ok) - get := ref.GetObject().Get() - require.NotNil(t, get) - assert.Equal(t, "getX", get.GetOperationID()) - assert.Equal(t, int32(1), requests.Load()) -} - -// respelled returns url with its scheme spelled scheme, which net/url respells -// back to "http" in the request built from it. -func respelled(url, scheme string) string { - return scheme + "://" + strings.TrimPrefix(url, "http://") -} - -// TestResolveExternal_TheSecondPassSendsNoRequestTheFirstMade pins that the -// second pass is answered as the first pass's requests were, for the references -// that failed in it: a failed status, a transport error, a refusal, and a -// document without the fragment named each reach the server once and are -// reported once. flaky.yaml would succeed if asked again, under a spelling the -// resolver keys apart, so a second request would read it unprepared where the -// first pass built nothing from it. -func TestResolveExternal_TheSecondPassSendsNoRequestTheFirstMade(t *testing.T) { - t.Parallel() - var flaky, refused, unfound atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/ext.yaml", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - }) - mux.HandleFunc("/flaky.yaml", func(w http.ResponseWriter, _ *http.Request) { - if flaky.Add(1) == 1 { - http.Error(w, "busy", http.StatusServiceUnavailable) - return - } - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - }) - mux.HandleFunc("/refused.yaml", func(w http.ResponseWriter, _ *http.Request) { - refused.Add(1) - _, err := w.Write([]byte("p: &a [*a]\n")) - assert.NoError(t, err) - }) - mux.HandleFunc("/unfound.yaml", func(w http.ResponseWriter, _ *http.Request) { - unfound.Add(1) - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - // A server of its own: the transport retries a request that fails on a - // reused connection, and the first one to a server never is. - var cut atomic.Int32 - cutSrv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { - cut.Add(1) - hj, ok := w.(http.Hijacker) - if !assert.True(t, ok) { - return - } - conn, _, err := hj.Hijack() - if !assert.NoError(t, err) { - return - } - assert.NoError(t, conn.Close()) - }) - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - " /x: {$ref: \"" + respelled(srv.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n" + - " /flaky: {$ref: \"" + respelled(srv.URL, "HTTP") + "/flaky.yaml#/paths/~1x\"}\n" + - " /refused: {$ref: \"" + srv.URL + "/refused.yaml#/paths/~1x\"}\n" + - " /unfound: {$ref: \"" + srv.URL + "/unfound.yaml#/paths/~1nowhere\"}\n" + - " /cut: {$ref: \"" + cutSrv.URL + "/cut.yaml#/paths/~1x\"}\n" - - doc, diags, err := resolveSpec(t, spec, "root.yaml") - - require.NoError(t, err) - assertRecovered(t, doc) - codes := make([]string, len(diags)) - for i, d := range diags { - codes[i] = d.Code - } - assert.Equal(t, []string{diag.UnresolvedRef, diag.UnresolvedRef, diag.UnresolvedRef, diag.UnresolvedRef}, codes, - "each failure once, and no internal fault for flaky.yaml") - assert.Equal(t, int32(1), flaky.Load(), "a failed status is the document's answer") - assert.Equal(t, int32(1), refused.Load(), "a refusal is the document's answer") - assert.Equal(t, int32(1), unfound.Load(), "a document's bytes are its answer") - assert.Equal(t, int32(1), cut.Load(), "a transport error is the document's answer") -} - -// TestResolveExternal_EveryReferenceGetsTheOneAnswerItsDocumentGot pins that a -// document is read once a compile, whichever reference asks and in either -// pass: the references to a URL, spelled alike or as the resolver keys apart, -// all fail with the 503 its one request got, or all resolve. A second request -// would have succeeded, so a reference that asked again would resolve what -// another failed on. A respelled $ref to another server sets the second pass -// off in each case. -func TestResolveExternal_EveryReferenceGetsTheOneAnswerItsDocumentGot(t *testing.T) { - t.Parallel() - trigger, _ := countingServer(t, anchoredExternalDoc) - for _, tc := range []struct { - name string - schemes []string - failFirst bool - }{ - {"three spellings, failed", []string{"HTTP", "Http", "hTTP"}, true}, - {"three spellings, served", []string{"HTTP", "Http", "hTTP"}, false}, - {"one spelling, failed", []string{"http", "http"}, true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - var requests atomic.Int32 - failing := int32(2) - if tc.failFirst { - failing = 1 - } - srv := newTestServer(t, failingHandler(t, failing, &requests)) - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - " /x: {$ref: \"" + respelled(trigger.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n" - for i, scheme := range tc.schemes { - spec += fmt.Sprintf(" /p%d: {$ref: \"%s/ext.yaml#/paths/~1x\"}\n", i, respelled(srv.URL, scheme)) - } - - doc, diags, err := resolveSpec(t, spec, "root.yaml") - - require.NoError(t, err) - assertRecoveredAt(t, doc, "/x") - assert.Equal(t, int32(1), requests.Load(), "one request, in neither pass sent again") - if !tc.failFirst { - assert.Empty(t, diags) - for i := range tc.schemes { - assertRecoveredAt(t, doc, fmt.Sprintf("/p%d", i)) - } - return - } - require.Len(t, diags, len(tc.schemes), "every reference fails, each once") - for _, d := range diags { - assert.Equal(t, diag.UnresolvedRef, d.Code) - assert.Contains(t, d.Message, "503") - } - }) - } -} - -// failingHandler serves anchoredExternalDoc to every request but the n-th, -// which fails with 503, counting them in requests. -func failingHandler(t *testing.T, n int32, requests *atomic.Int32) http.HandlerFunc { - t.Helper() - return func(w http.ResponseWriter, _ *http.Request) { - if requests.Add(1) == n { - http.Error(w, "busy", http.StatusServiceUnavailable) - return - } - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - } -} - -// TestResolveExternal_TheSecondPassResolvesASelfReferenceAsTheFirstDid pins -// that both passes answer a $ref naming the source's own file from the source -// as held (GitHub #759): /b reaches /a's own declaration whether or not another -// $ref is respelled, which is what decides whether a second pass runs. -func TestResolveExternal_TheSecondPassResolvesASelfReferenceAsTheFirstDid(t *testing.T) { - t.Parallel() - trigger, _ := countingServer(t, anchoredExternalDoc) - for _, tc := range []struct { - name string - other string - }{ - {"alone", ""}, - {"beside a respelled $ref", " /x: {$ref: \"" + respelled(trigger.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + tc.other + - " /a: {$ref: '#/components/pathItems/Shared'}\n" + - " /b: {$ref: './self.yaml#/components/pathItems/Shared'}\n" + - "components:\n pathItems:\n Shared:\n" + - " get: {operationId: dup, responses: {\"200\": {description: ok}}}\n" - dir := t.TempDir() - writeFile(t, dir, "self.yaml", spec) - - doc, diags, err := resolveSpec(t, spec, filepath.Join(dir, "self.yaml")) - - require.NoError(t, err) - assert.Empty(t, diags) - a, ok := doc.Paths.Get("/a") - require.True(t, ok) - b, ok := doc.Paths.Get("/b") - require.True(t, ok) - require.NotNil(t, a.GetObject()) - require.NotNil(t, b.GetObject()) - assert.Same(t, a.GetObject(), b.GetObject(), - "/b reaches the source's own declaration, as /a does") - }) - } -} - -// TestResolveExternal_ARebuildErrorIsReturned pins that resolveExternal returns -// a rebuild failure to its caller rather than swallowing it. -// -// Load's own rebuild cannot fail once its first unmarshal has succeeded: -// unmarshal is a pure function of (ctx, data, root) and ignores ctx, so only an -// injected error reaches this branch. Load's wrap of it is driven through the -// rebuildDoc seam by TestLoad_ARebuildFailureIsWrappedAsRebuildSource. -func TestResolveExternal_ARebuildErrorIsReturned(t *testing.T) { - t.Parallel() - srv, requests := countingServer(t, anchoredExternalDoc) - url := "HTTP://" + strings.TrimPrefix(srv.URL, "http://") + "/ext.yaml" - sentinel := errors.New("rebuild boom") - rebuild := func() (*soa.OpenAPI, error) { return nil, sentinel } - - doc, diags, err := resolveSpecWith(t, rootReferencing(url), rebuild) - - assert.Nil(t, doc) - assert.Nil(t, diags) - assert.ErrorIs(t, err, sentinel) - assert.Equal(t, int32(1), requests.Load(), "the first pass still reads the document once before rebuild fails") -} - -// otherDocWithAnchoredGetAndFinding is a document reached only through an -// external $ref: an anchored get operation (so the resolver's own parse would -// fold right over it, GitHub #501) whose 200 response carries a finding -// (GitHub #537), and a second, separately anchored response on the same -// operation — proving recovery for the responses map's fold too, not only the -// path item's, which anchoredExternalDoc already covers. -const otherDocWithAnchoredGetAndFinding = `openapi: 3.1.0 -info: {title: O, version: "1"} -paths: - /x: - get: &g - operationId: EXTGET - responses: - "200": - description: ok - headers: - X: - schema: {type: string} - required: notabool - "404": &n - description: missing -` - -// combinedRootSpec is a source with two ordinary ghost references beside the -// $ref to other.yaml at url, so the diagnostic list the test below compares is -// more than the one entry the recovery pass touches. -func combinedRootSpec(url string) string { - return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - " /a:\n parameters:\n - {$ref: '#/components/parameters/GhostParam'}\n" + - " get: {operationId: getA, responses: {\"200\": {description: ok}}}\n" + - " /x: {$ref: \"" + url + "#/paths/~1x\"}\n" + - "components:\n schemas:\n S: {$ref: '#/components/schemas/GhostSchema'}\n" -} - -// TestResolveExternal_ASecondPassReportsWhatOnePassWould pins how per-reference -// reporting meets resolveExternal's two passes (see its doc comment): the -// second pass REPLACES the first pass's diagnostics rather than adding to them, -// and replacing must neither double nor lose a report. The two ordinary ghost -// references in combinedRootSpec are what makes that a claim about the whole -// diagnostic list a compile reports, not only about the one entry the recovery -// touches. -func TestResolveExternal_ASecondPassReportsWhatOnePassWould(t *testing.T) { - t.Parallel() - srv, requests := countingServer(t, otherDocWithAnchoredGetAndFinding) - - run := func(url string) (*Document, []ir.Diagnostic) { - requests.Store(0) - src := compilers.Source{Path: "root.yaml", Data: []byte(combinedRootSpec(url))} - got, diags, err := Load(t.Context(), 0, src, Options{Reader: reader.Disk{}}) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, int32(1), requests.Load(), "one request per compile") - return got, diags - } - - respelledURL := respelled(srv.URL, "HTTP") + "/other.yaml" - recovered, twoPassDiags := run(respelledURL) - _, onePassDiags := run(srv.URL + "/other.yaml") - - if d := cmp.Diff(spelledAs(onePassDiags, srv.URL, respelled(srv.URL, "HTTP")), twoPassDiags); d != "" { - t.Errorf("a second pass must report exactly what one pass would (-onePass +twoPass):\n%s", d) - } - - ref, ok := recovered.Doc.Paths.Get("/x") - require.True(t, ok) - item := ref.GetObject() - require.NotNil(t, item) - get := item.Get() - require.NotNil(t, get, "the anchored get was folded, not skipped") - assert.Equal(t, "EXTGET", get.GetOperationID()) - _, ok = get.GetResponses().Get("404") - assert.True(t, ok, "the separately anchored 404 was folded, not skipped") -} - -// twoRefsTarget is an external document with an anchored get, so that a -// respelling of its URL forces the rebuild. /x's put has a parameter missing -// its in, a finding the unmarshal makes rather than validation; /w is clean. -const twoRefsTarget = `openapi: 3.1.0 -info: {title: O, version: "1"} -paths: - /w: - get: {operationId: W, responses: {"200": {description: ok}}} - /x: - get: &g - operationId: G - responses: {"200": {description: ok}} - put: - operationId: P - parameters: - - {name: p, schema: {type: string}} - responses: {"200": {description: ok}} -` - -// TestResolveExternal_TwoRefsToOneTargetReportItsFindingOnce pins that a finding -// in a target two $refs reach is reported once, at its node, with or without a -// rebuild. /w reads the document first, so the library builds /x's -// target from bytes it already holds, and caches no object built that way: it -// builds the target again for /y, which the reached check's identity of a -// finding keeps from a second report. -func TestResolveExternal_TwoRefsToOneTargetReportItsFindingOnce(t *testing.T) { - t.Parallel() - srv, _ := countingServer(t, twoRefsTarget) - run := func(base string, wantRebuilds int32) []ir.Diagnostic { - doc := base + "/other.yaml#/paths/" - src := compilers.Source{Path: "root.yaml", Data: []byte("openapi: 3.1.0\n" + - "info: {title: T, version: \"1\"}\npaths:\n" + - " /w: {$ref: \"" + doc + "~1w\"}\n" + - " /x: {$ref: \"" + doc + "~1x\"}\n /y: {$ref: \"" + doc + "~1x\"}\n")} - var rebuilds atomic.Int32 - rebuild := func(ctx context.Context, data []byte, root *yaml.Node) (*soa.OpenAPI, error) { - rebuilds.Add(1) - return unmarshal(ctx, data, root) - } - _, diags, err := Load(t.Context(), 0, src, Options{Reader: reader.Disk{}, rebuildDoc: rebuild}) - require.NoError(t, err) - require.Equal(t, wantRebuilds, rebuilds.Load(), "%s takes the path this case names", base) - return diags - } - - onePass := run(srv.URL, 0) - rebuilt := run(respelled(srv.URL, "HTTP"), 1) - - if d := cmp.Diff(spelledAs(onePass, srv.URL, respelled(srv.URL, "HTTP")), rebuilt); d != "" { - t.Errorf("two $refs to one target must report alike with and without a rebuild (-onePass +rebuilt):\n%s", d) - } - for name, diags := range map[string][]ir.Diagnostic{"one pass": onePass, "rebuilt": rebuilt} { - var sites []jsontext.Pointer - for _, d := range diags { - if d.Code == diag.Validation+"/validation-required-field" { - sites = append(sites, d.Provenance.Pointer) - } - } - assert.Equal(t, []jsontext.Pointer{"/paths/~1x/put/parameters/0"}, sites, - "%s: the finding is reported once, at its node", name) - } -} - -// spelledAs returns diags with every mention of from in a message spelled as -// to, so two runs whose $refs spell one URL differently compare on the rest: -// a finding's message names its document as the $ref spelled it. -func spelledAs(diags []ir.Diagnostic, from, to string) []ir.Diagnostic { - out := make([]ir.Diagnostic, len(diags)) - for i, d := range diags { - d.Message = strings.ReplaceAll(d.Message, from, to) - out[i] = d - } - return out -} - // TestLoad_RecoversARespelledExternalReference drives the recovery through the // public entry point rather than resolveExternal directly, so build's own // rebuild closure — not a test-supplied stand-in — is what runs and succeeds. @@ -820,50 +342,6 @@ func secondPassSpecs(t *testing.T, extURL, otherURL string) (string, func() (*so return rootReferencing(extURL), func() (*soa.OpenAPI, error) { return modelOf(t, second), nil } } -// TestResolveExternal_ADocumentOnlyTheSecondPassReadsIsPrepared holds the second -// pass's reader to the model it resolves: other.yaml is read for the first time -// there, and its prepared tree must be stored where the rebuilt model looks, not -// in the first model, or its anchored entries are skipped. -func TestResolveExternal_ADocumentOnlyTheSecondPassReadsIsPrepared(t *testing.T) { - t.Parallel() - ext, extRequests := countingServer(t, anchoredExternalDoc) - other, otherRequests := countingServer(t, anchoredExternalDoc) - extURL := "HTTP://" + strings.TrimPrefix(ext.URL, "http://") + "/ext.yaml" - first, rebuild := secondPassSpecs(t, extURL, other.URL+"/other.yaml") - - doc, diags, err := resolveSpecWith(t, first, rebuild) - - require.NoError(t, err) - assert.Empty(t, diags) - assertRecoveredAt(t, doc, "/x") - assertRecoveredAt(t, doc, "/y") - assert.Equal(t, int32(1), extRequests.Load(), "ext.yaml is fetched once, in the first pass") - assert.Equal(t, int32(1), otherRequests.Load(), "other.yaml is fetched once, in the second") -} - -// TestResolveExternal_ARespelledDocumentOnlyTheSecondPassReadsIsReported pins -// that resolveExternal reports a document the second pass read unprepared, -// rather than dropping its anchored entries in silence: other.yaml is respelled, -// so the resolver misses the tree stored under its request's URL. -func TestResolveExternal_ARespelledDocumentOnlyTheSecondPassReadsIsReported(t *testing.T) { - t.Parallel() - ext, _ := countingServer(t, anchoredExternalDoc) - other, _ := countingServer(t, anchoredExternalDoc) - extURL := "HTTP://" + strings.TrimPrefix(ext.URL, "http://") + "/ext.yaml" - otherURL := "HTTP://" + strings.TrimPrefix(other.URL, "http://") + "/other.yaml" - first, rebuild := secondPassSpecs(t, extURL, otherURL) - - _, diags, err := resolveSpecWith(t, first, rebuild) - - require.NoError(t, err) - require.Len(t, diags, 1) - assert.Equal(t, ir.SeverityError, diags[0].Severity) - assert.Equal(t, diag.InternalInvariant, diags[0].Code) - assert.Contains(t, diags[0].Message, otherURL) - assert.Equal(t, jsontext.Pointer("/paths/~1y"), diags[0].Provenance.Pointer, - "reported at the reference that read it") -} - // aliasKindsFixture carries one internal $ref of every Referenced* alias // resolutionTrail switches on, resolved against the document itself (no I/O), // so TestResolutionTrail can drive the dispatch alone without the recovery @@ -1144,130 +622,6 @@ func TestResolutionTrail_StopsAtMaxResolutionHops(t *testing.T) { } } -// respelledChain resolves a root whose /x starts a chain of n documents served -// over HTTP, each reached through an upper-case scheme and so missed by the -// resolver. Only the last holds anchored entries, so only reaching it matters; -// it returns /x's path item as resolved, and the diagnostics. With secondPass, -// the root's /t also reaches an anchored document that way, which sets the -// second pass off whatever the chain's length. -func respelledChain(t *testing.T, n int, secondPass bool) (*soa.PathItem, []ir.Diagnostic) { - t.Helper() - var base string - mux := http.NewServeMux() - mux.HandleFunc("/t.yaml", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - }) - for i := range n { - mux.HandleFunc(fmt.Sprintf("/f%d.yaml", i), func(w http.ResponseWriter, _ *http.Request) { - body := anchoredExternalDoc - if i < n-1 { - next := fmt.Sprintf("HTTP://%s/f%d.yaml", strings.TrimPrefix(base, "http://"), i+1) - body = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - " /x: {$ref: \"" + next + "#/paths/~1x\"}\n" - } - _, err := w.Write([]byte(body)) - assert.NoError(t, err) - }) - } - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - base = srv.URL - - root := rootReferencing(respelled(srv.URL, "HTTP") + "/f0.yaml") - if secondPass { - root += " /t: {$ref: \"" + respelled(srv.URL, "HTTP") + "/t.yaml#/paths/~1x\"}\n" - } - doc, diags, err := resolveSpec(t, root, "root.yaml") - require.NoError(t, err) - ref, ok := doc.Paths.Get("/x") - require.True(t, ok) - require.NotNil(t, ref.GetObject()) - return ref.GetObject(), diags -} - -// TestResolveExternal_TheHopBoundIsWhereRecoveryEnds pins what the bound costs, -// from both sides: the last document of a chain of maxResolutionHops is -// recovered, and a chain one longer is not, with no report, because recovery -// never learns the last document was read. A second pass another $ref sets off -// resolves that one from the bytes its one read got, as the first pass did. The bound is -// for an absurd chain; this keeps a change to what happens at it deliberate. -func TestResolveExternal_TheHopBoundIsWhereRecoveryEnds(t *testing.T) { - t.Parallel() - - t.Run("a chain at the bound is recovered", func(t *testing.T) { - t.Parallel() - item, diags := respelledChain(t, maxResolutionHops, false) - - assert.Empty(t, diags) - require.NotNil(t, item.Get(), "the last document's anchored GET entry was folded") - require.NotNil(t, item.Put()) - }) - - for _, tc := range []struct { - name string - secondPass bool - }{ - {"a chain one past it is not", false}, - {"nor in a second pass another $ref sets off", true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - item, diags := respelledChain(t, maxResolutionHops+1, tc.secondPass) - - assert.Empty(t, diags, "nothing reports the entries that were skipped") - assert.Nil(t, item.Get(), "the last document is the resolver's own parse, which skips them") - assert.Nil(t, item.Put()) - }) - } -} - -// TestResolveExternal_ACanonicalDocumentPastTheHopBoundStaysPrepared covers a -// document no reference records: the last of a chain one document past -// maxResolutionHops, spelled canonically. Its request is answered with the bytes -// the first pass read, and the tree the first pass prepared is handed over -// under the key it was prepared under, which is the resolver's, so its anchored -// entries hold and nothing in the chain is fetched twice. ext.yaml is what sets -// the second pass off. -func TestResolveExternal_ACanonicalDocumentPastTheHopBoundStaysPrepared(t *testing.T) { - t.Parallel() - const n = maxResolutionHops + 1 - var base string - var fetches [n]atomic.Int32 - mux := http.NewServeMux() - for i := range n { - mux.HandleFunc(fmt.Sprintf("/f%d.yaml", i), func(w http.ResponseWriter, _ *http.Request) { - fetches[i].Add(1) - body := anchoredExternalDoc - if i < n-1 { - body = rootReferencing(fmt.Sprintf("%s/f%d.yaml", base, i+1)) - } - _, err := w.Write([]byte(body)) - assert.NoError(t, err) - }) - } - mux.HandleFunc("/ext.yaml", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(anchoredExternalDoc)) - assert.NoError(t, err) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - base = srv.URL - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - " /x: {$ref: \"" + respelled(srv.URL, "HTTP") + "/ext.yaml#/paths/~1x\"}\n" + - " /chain: {$ref: \"" + srv.URL + "/f0.yaml#/paths/~1x\"}\n" - - doc, diags, err := resolveSpec(t, spec, "root.yaml") - - require.NoError(t, err) - assert.Empty(t, diags) - assertRecoveredAt(t, doc, "/x") - assertRecoveredAt(t, doc, "/chain") - for i := range fetches { - assert.Equal(t, int32(1), fetches[i].Load(), "f%d.yaml is fetched once", i) - } -} - // writeFile writes content to name under dir, failing the test on error. func writeFile(t *testing.T, dir, name, content string) { t.Helper() @@ -1434,152 +788,6 @@ func TestUnprepared(t *testing.T) { }) } -// TestExternalReads_OnceReadsEachDocumentOnce pins the record's one read per -// key: the answer the first ask got is every later ask's, a failure included, -// each key keeps its own, and only a first ask is charged. -func TestExternalReads_OnceReadsEachDocumentOnce(t *testing.T) { - t.Parallel() - r := newExternalReads(sourceDocument{}) - reads := map[string]int{} - readAs := func(key string, a answer) func() answer { - return func() answer { - reads[key]++ - return a - } - } - gone := answer{err: errors.New("GET k: 404 Not Found")} - found := answer{body: []byte("doc")} - - first := r.once("k", 0, readAs("k", gone)) - again := r.once("k", 0, readAs("k", found)) - other := r.once("other", 0, readAs("other", found)) - - assert.Equal(t, gone, first) - assert.Equal(t, gone, again, "a later ask gets the first answer, not a read of its own") - assert.Equal(t, found, other, "each key keeps its own answer") - assert.Equal(t, map[string]int{"k": 1, "other": 1}, reads) - assert.Equal(t, 2, r.documents, "a document is charged once") -} - -// TestExternalReads_OnceReadsOnceUnderConcurrentAsks pins the one read where -// asks race: every ask for one key, from goroutines at once, gets the answer of -// a single read, and the document is charged once. -func TestExternalReads_OnceReadsOnceUnderConcurrentAsks(t *testing.T) { - t.Parallel() - r := newExternalReads(sourceDocument{}) - var reads atomic.Int32 - var wg sync.WaitGroup - got := make([]answer, 16) - for i := range got { - wg.Go(func() { - got[i] = r.once("k", 0, func() answer { - return answer{body: fmt.Appendf(nil, "read %d", reads.Add(1))} - }) - }) - } - wg.Wait() - - assert.Equal(t, int32(1), reads.Load()) - for _, a := range got { - assert.Equal(t, "read 1", string(a.body)) - } - assert.Equal(t, 1, r.documents) -} - -// TestExternalReads_OnceRefusesADocumentPastTheBudget pins the budget's edge: -// limit documents are read, the next is refused unread with the budget named, -// and a refused one stays refused, however often it is asked for and with -// whatever limit. -func TestExternalReads_OnceRefusesADocumentPastTheBudget(t *testing.T) { - t.Parallel() - r := newExternalReads(sourceDocument{}) - read := 0 - fetch := func() answer { - read++ - return answer{} - } - for i := range 2 { - assert.NoError(t, r.once(fmt.Sprintf("k%d", i), 2, fetch).err) - } - - past := r.once("k2", 2, fetch) - again := r.once("k2", 0, fetch) - - require.Error(t, past.err) - assert.Equal(t, "external document k2 refused: it is past the 2-document budget", past.err.Error()) - assert.Equal(t, past, again, "the refusal is the document's answer") - assert.Equal(t, 2, read, "nothing past the budget is read") - assert.Equal(t, 3, r.documents, "the refused document is charged, so the budget reads as crossed") -} - -// TestExternalReads_OnceLeavesAFailureWhereAReadPanics pins that a read -// stopped by a panic, which the resolution walk recovers from, leaves its -// document failed rather than answered as empty, and is not read again. -func TestExternalReads_OnceLeavesAFailureWhereAReadPanics(t *testing.T) { - t.Parallel() - r := newExternalReads(sourceDocument{}) - assert.Panics(t, func() { - r.once("k", 0, func() answer { panic("read") }) - }) - - got := r.once("k", 0, func() answer { return answer{} }) - - require.Error(t, got.err) - assert.Equal(t, "external document k refused: its read did not finish", got.err.Error()) -} - -// TestExternalReads_ExhaustedReportsOnceAtTheSource pins the report: nothing -// while every read was within the budget, and one error at the source once a -// document fell past it. -func TestExternalReads_ExhaustedReportsOnceAtTheSource(t *testing.T) { - t.Parallel() - at := pointerAt(0, overlay.Origin{}) - r := newExternalReads(sourceDocument{}) - ok := func() answer { return answer{} } - r.once("a", 1, ok) - assert.Empty(t, r.exhausted(at, 1), "a budget spent exactly is not crossed") - - r.once("b", 1, ok) - r.once("c", 1, ok) - - got := r.exhausted(at, 1) - require.Len(t, got, 1) - assert.Equal(t, diag.BudgetExceeded, got[0].Code) - assert.Equal(t, ir.SeverityError, got[0].Severity) - assert.Equal(t, at(""), got[0].Provenance) - assert.Equal(t, "external references name more documents than the 1-document budget; "+ - "the $refs into the rest are reported unresolved", got[0].Message) - assert.Empty(t, r.exhausted(at, 0), "an unbounded budget is never crossed") -} - -// TestExternalReads_IsSafeForConcurrentUse holds the record to what the readers -// sharing it promise: the resolver's interfaces do not rule out concurrent -// calls, and a write racing another is a fatal map fault that no recover can -// catch. Under -race, unsynchronized access fails here. -func TestExternalReads_IsSafeForConcurrentUse(t *testing.T) { - t.Parallel() - r := newExternalReads(sourceDocument{}) - for i := range 8 { - key := fmt.Sprintf("http://host/doc%d.yaml", i) - t.Run(fmt.Sprintf("doc%d", i), func(t *testing.T) { - t.Parallel() - tree := &yaml.Node{Kind: yaml.ScalarNode, Value: key} - doc := &soa.OpenAPI{} - doc.InitCache() - for range 100 { - r.recordTree(key, tree, nil) - got, ok := r.preparedFor(key) - assert.True(t, ok) - assert.Same(t, tree, got) - assert.True(t, r.prepared(tree)) - a := r.once(key, 0, func() answer { return answer{body: []byte(key)} }) - assert.Equal(t, key, string(a.body)) - r.handOver(doc, nil) - } - }) - } -} - // TestChain_EndsOnNothing pins when a resolution's chain is said to end on a // position holding no object (GitHub #1044): it reached the position its last // hop names and built nothing there. A chain that holds no record, was cut, or diff --git a/compilers/openapi/internal/load/unprepared_reads_internal_test.go b/compilers/openapi/internal/load/unprepared_reads_internal_test.go new file mode 100644 index 00000000..1b9e0626 --- /dev/null +++ b/compilers/openapi/internal/load/unprepared_reads_internal_test.go @@ -0,0 +1,164 @@ +package load + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + + soa "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/overlay" + "github.com/dexpace/morphic/ir" +) + +// TestExternalReads_OnceReadsEachDocumentOnce pins the record's one read per +// key: the answer the first ask got is every later ask's, a failure included, +// each key keeps its own, and only a first ask is charged. +func TestExternalReads_OnceReadsEachDocumentOnce(t *testing.T) { + t.Parallel() + r := newExternalReads(sourceDocument{}) + reads := map[string]int{} + readAs := func(key string, a answer) func() answer { + return func() answer { + reads[key]++ + return a + } + } + gone := answer{err: errors.New("GET k: 404 Not Found")} + found := answer{body: []byte("doc")} + + first := r.once("k", 0, readAs("k", gone)) + again := r.once("k", 0, readAs("k", found)) + other := r.once("other", 0, readAs("other", found)) + + assert.Equal(t, gone, first) + assert.Equal(t, gone, again, "a later ask gets the first answer, not a read of its own") + assert.Equal(t, found, other, "each key keeps its own answer") + assert.Equal(t, map[string]int{"k": 1, "other": 1}, reads) + assert.Equal(t, 2, r.documents, "a document is charged once") +} + +// TestExternalReads_OnceReadsOnceUnderConcurrentAsks pins the one read where +// asks race: every ask for one key, from goroutines at once, gets the answer of +// a single read, and the document is charged once. +func TestExternalReads_OnceReadsOnceUnderConcurrentAsks(t *testing.T) { + t.Parallel() + r := newExternalReads(sourceDocument{}) + var reads atomic.Int32 + var wg sync.WaitGroup + got := make([]answer, 16) + for i := range got { + wg.Go(func() { + got[i] = r.once("k", 0, func() answer { + return answer{body: fmt.Appendf(nil, "read %d", reads.Add(1))} + }) + }) + } + wg.Wait() + + assert.Equal(t, int32(1), reads.Load()) + for _, a := range got { + assert.Equal(t, "read 1", string(a.body)) + } + assert.Equal(t, 1, r.documents) +} + +// TestExternalReads_OnceRefusesADocumentPastTheBudget pins the budget's edge: +// limit documents are read, the next is refused unread with the budget named, +// and a refused one stays refused, however often it is asked for and with +// whatever limit. +func TestExternalReads_OnceRefusesADocumentPastTheBudget(t *testing.T) { + t.Parallel() + r := newExternalReads(sourceDocument{}) + read := 0 + fetch := func() answer { + read++ + return answer{} + } + for i := range 2 { + assert.NoError(t, r.once(fmt.Sprintf("k%d", i), 2, fetch).err) + } + + past := r.once("k2", 2, fetch) + again := r.once("k2", 0, fetch) + + require.Error(t, past.err) + assert.Equal(t, "external document k2 refused: it is past the 2-document budget", past.err.Error()) + assert.Equal(t, past, again, "the refusal is the document's answer") + assert.Equal(t, 2, read, "nothing past the budget is read") + assert.Equal(t, 3, r.documents, "the refused document is charged, so the budget reads as crossed") +} + +// TestExternalReads_OnceLeavesAFailureWhereAReadPanics pins that a read +// stopped by a panic, which the resolution walk recovers from, leaves its +// document failed rather than answered as empty, and is not read again. +func TestExternalReads_OnceLeavesAFailureWhereAReadPanics(t *testing.T) { + t.Parallel() + r := newExternalReads(sourceDocument{}) + assert.Panics(t, func() { + r.once("k", 0, func() answer { panic("read") }) + }) + + got := r.once("k", 0, func() answer { return answer{} }) + + require.Error(t, got.err) + assert.Equal(t, "external document k refused: its read did not finish", got.err.Error()) +} + +// TestExternalReads_ExhaustedReportsOnceAtTheSource pins the report: nothing +// while every read was within the budget, and one error at the source once a +// document fell past it. +func TestExternalReads_ExhaustedReportsOnceAtTheSource(t *testing.T) { + t.Parallel() + at := pointerAt(0, overlay.Origin{}) + r := newExternalReads(sourceDocument{}) + ok := func() answer { return answer{} } + r.once("a", 1, ok) + assert.Empty(t, r.exhausted(at, 1), "a budget spent exactly is not crossed") + + r.once("b", 1, ok) + r.once("c", 1, ok) + + got := r.exhausted(at, 1) + require.Len(t, got, 1) + assert.Equal(t, diag.BudgetExceeded, got[0].Code) + assert.Equal(t, ir.SeverityError, got[0].Severity) + assert.Equal(t, at(""), got[0].Provenance) + assert.Equal(t, "external references name more documents than the 1-document budget; "+ + "the $refs into the rest are reported unresolved", got[0].Message) + assert.Empty(t, r.exhausted(at, 0), "an unbounded budget is never crossed") +} + +// TestExternalReads_IsSafeForConcurrentUse holds the record to what the readers +// sharing it promise: the resolver's interfaces do not rule out concurrent +// calls, and a write racing another is a fatal map fault that no recover can +// catch. Under -race, unsynchronized access fails here. +func TestExternalReads_IsSafeForConcurrentUse(t *testing.T) { + t.Parallel() + r := newExternalReads(sourceDocument{}) + for i := range 8 { + key := fmt.Sprintf("http://host/doc%d.yaml", i) + t.Run(fmt.Sprintf("doc%d", i), func(t *testing.T) { + t.Parallel() + tree := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + doc := &soa.OpenAPI{} + doc.InitCache() + for range 100 { + r.recordTree(key, tree, nil) + got, ok := r.preparedFor(key) + assert.True(t, ok) + assert.Same(t, tree, got) + assert.True(t, r.prepared(tree)) + a := r.once(key, 0, func() answer { return answer{body: []byte(key)} }) + assert.Equal(t, key, string(a.body)) + r.handOver(doc, nil) + } + }) + } +} diff --git a/compilers/openapi/internal/merge/fold_cases_internal_test.go b/compilers/openapi/internal/merge/fold_cases_internal_test.go index 21fb5121..049c7114 100644 --- a/compilers/openapi/internal/merge/fold_cases_internal_test.go +++ b/compilers/openapi/internal/merge/fold_cases_internal_test.go @@ -7,7 +7,6 @@ import ( "slices" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,200 +58,6 @@ func TestMergeProperty_AppendsThenFolds(t *testing.T) { assert.Empty(t, *recorded, "agreeing declarations are not a conflict") } -// TestFold_DifferingDescriptionsKeepTheFirst pins a merge outcome -// that is reported without being a conflict: two branches describing the same -// field differently cannot both be kept, so the first wins, the loss is -// announced rather than silent, and the losing declaration is kept whole. -func TestFold_DifferingDescriptionsKeepTheFirst(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - dst := ir.Property{WireName: "id", Docs: ir.Docs{Description: "first"}} - - dst = foldTwo(g, dst, ir.Property{WireName: "id", Docs: ir.Docs{Description: "second"}, Provenance: ptrAt("/other")}, - written(`{"description":"second"}`)) - - assert.Equal(t, "first", dst.Docs.Description) - require.Len(t, *recorded, 1) - assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity) - assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code) - assert.Contains(t, (*recorded)[0].Message, `"id"`) - assert.Contains(t, (*recorded)[0].Message, "description") - assert.Equal(t, ir.RawValue(`{"description":"second"}`), dst.Unmodeled[losingDeclarationKey+"/other"].Value) -} - -// TestFold_AnIdenticalDescriptionIsNotADisagreement pins the other -// side of that check: repeating the same description in both branches is one -// construct written twice, not two that disagree. -func TestFold_AnIdenticalDescriptionIsNotADisagreement(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - dst := ir.Property{WireName: "id", Docs: ir.Docs{Description: "same"}} - - dst = foldTwo(g, dst, ir.Property{WireName: "id", Docs: ir.Docs{Description: "same"}, Provenance: ptrAt("/other")}, unread(t)) - - assert.Equal(t, "same", dst.Docs.Description) - assert.Empty(t, *recorded) -} - -// TestFold_TitleAndExternalDocsFoldLikeDescription pins that a -// summary and external links fold as a description does: an empty side adopts -// the other, equal sides are one construct written twice, and differing sides -// keep the first and the redeclaration whole, in either declaration order. -func TestFold_TitleAndExternalDocsFoldLikeDescription(t *testing.T) { - t.Parallel() - linkA := []ir.Link{{URL: "https://a.example"}} - linkB := []ir.Link{{URL: "https://b.example"}} - summary := func(s string) ir.Docs { return ir.Docs{Summary: s} } - links := func(l []ir.Link) ir.Docs { return ir.Docs{ExternalDocs: l} } - - tests := []struct { - name string - dst, src ir.Docs - want ir.Docs - detail string // empty when nothing is lost - }{ - {"summary, dst empty", summary(""), summary("B"), summary("B"), ""}, - {"summary, src empty", summary("A"), summary(""), summary("A"), ""}, - {"summary, equal", summary("A"), summary("A"), summary("A"), ""}, - {"summary, different", summary("A"), summary("B"), summary("A"), "title"}, - {"summary, different swapped", summary("B"), summary("A"), summary("B"), "title"}, - {"external docs, dst empty", links(nil), links(linkB), links(linkB), ""}, - {"external docs, src empty", links(linkA), links(nil), links(linkA), ""}, - {"external docs, equal", links(linkA), links(linkA), links(linkA), ""}, - {"external docs, different", links(linkA), links(linkB), links(linkA), "external docs"}, - {"external docs, different swapped", links(linkB), links(linkA), links(linkB), "external docs"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - dst := ir.Property{WireName: "id", Docs: tt.dst} - src := ir.Property{WireName: "id", Docs: tt.src, Provenance: ptrAt("/other")} - source := unread(t) - if tt.detail != "" { - source = written(`{"x":1}`) - } - - dst = foldTwo(g, dst, src, source) - - assert.Empty(t, cmp.Diff(tt.want, dst.Docs)) - if tt.detail == "" { - assert.Empty(t, *recorded) - assert.Empty(t, dst.Unmodeled) - return - } - require.Len(t, *recorded, 1) - assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity) - assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code) - assert.Equal(t, jsontext.Pointer("/other"), (*recorded)[0].Provenance.Pointer) - assert.Contains(t, (*recorded)[0].Message, `"id"`) - assert.Contains(t, (*recorded)[0].Message, tt.detail) - require.Len(t, dst.Unmodeled, 1) - entry, ok := dst.Unmodeled[losingDeclarationKey+"/other"] - require.True(t, ok) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Equal(t, ir.RawValue(`{"x":1}`), entry.Value) - }) - } -} - -// TestFold_EveryDocsDisagreementIsReported pins that foldDocs -// judges each docs field rather than stopping at the first that differs: a -// redeclaration giving all three differently is reported three times, and -// kept once. -func TestFold_EveryDocsDisagreementIsReported(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - first := ir.Docs{Summary: "A", Description: "dA", ExternalDocs: []ir.Link{{URL: "https://a.example"}}} - dst := ir.Property{WireName: "id", Docs: first} - src := ir.Property{WireName: "id", Provenance: ptrAt("/other"), - Docs: ir.Docs{Summary: "B", Description: "dB", ExternalDocs: []ir.Link{{URL: "https://b.example"}}}} - - dst = foldTwo(g, dst, src, written(`{"x":1}`)) - - assert.Empty(t, cmp.Diff(first, dst.Docs)) - require.Len(t, *recorded, 3) - for i, detail := range []string{"title", "description", "external docs"} { - assert.Contains(t, (*recorded)[i].Message, detail) - } - require.Len(t, dst.Unmodeled, 1) - assert.Equal(t, ir.RawValue(`{"x":1}`), dst.Unmodeled[losingDeclarationKey+"/other"].Value) -} - -// readOnlyVisibility and writeOnlyVisibility are the two non-empty shapes -// EffectiveVisibility ever produces, reused across the visibility-merge -// cases below rather than re-spelling the lifecycle lists in each one. -var ( - readOnlyVisibility = ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleDelete, ir.LifecycleQuery}} - writeOnlyVisibility = ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleCreate, ir.LifecycleUpdate}} -) - -// TestFold_VisibilityAdoptedFromRedeclaration pins the issue's -// own reproduction at the reconcile layer (GitHub #34): a property left -// unrestricted by its first declaration must adopt a later branch's readOnly -// rather than silently keep the first, empty Visibility. -func TestFold_VisibilityAdoptedFromRedeclaration(t *testing.T) { - t.Parallel() - g, _ := stubMerger(nil) - dst := ir.Property{WireName: "id"} - - dst = foldTwo(g, dst, ir.Property{WireName: "id", Visibility: readOnlyVisibility, Provenance: ptrAt("/other")}, unread(t)) - - assert.Equal(t, readOnlyVisibility, dst.Visibility) -} - -// TestFold_DisjointVisibilityIsARestrictionNotAConflict pins both -// halves of the choice for an allOf pairing that admits no lifecycle at all -// (readOnly against writeOnly on the same field). mergeVisibility represents it -// exactly as None, so it is recorded rather than dropped and kept — nothing is -// arbitrarily discarded, as it would be for an incompatible-type -// redeclaration. But exact is not unremarkable: a -// field neither a request nor a response can carry is a composition that cannot -// take effect, so it is warned about under its own code. -func TestFold_DisjointVisibilityIsARestrictionNotAConflict(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - dst := ir.Property{WireName: "id", Visibility: readOnlyVisibility} - - dst = foldTwo(g, dst, ir.Property{WireName: "id", Visibility: writeOnlyVisibility, Provenance: ptrAt("/other")}, unread(t)) - - assert.Equal(t, ir.Visibility{None: true}, dst.Visibility) - require.Len(t, *recorded, 1, "an emptied visibility set is reported, not passed over in silence") - assert.Equal(t, diag.DisjointVisibility, (*recorded)[0].Code, - "reported under its own code, not as a conflicting redeclaration") - assert.Equal(t, ir.SeverityWarning, (*recorded)[0].Severity) -} - -// TestFold_AlreadyInvisibleVisibilityIsNotReAnnounced pins the half -// of the report guard the disjoint case above cannot reach. The warning is about -// the transition — two restricted branches whose intersection empties — not -// about the state of being invisible, so a side that was already None merges -// quietly. Drop that half of the condition and every later redeclaration of an -// invisible field re-announces a fact the document has already been told, which -// no assertion in the disjoint case would notice. -func TestFold_AlreadyInvisibleVisibilityIsNotReAnnounced(t *testing.T) { - t.Parallel() - tests := []struct { - name string - dst, src ir.Visibility - }{ - {name: "first declaration was already invisible", dst: ir.Visibility{None: true}, src: readOnlyVisibility}, - {name: "redeclaration is already invisible", dst: readOnlyVisibility, src: ir.Visibility{None: true}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - dst := ir.Property{WireName: "id", Visibility: tc.dst} - - dst = foldTwo(g, dst, ir.Property{WireName: "id", Visibility: tc.src, Provenance: ptrAt("/other")}, unread(t)) - - assert.Equal(t, ir.Visibility{None: true}, dst.Visibility) - assert.Empty(t, *recorded, "None was already the answer; this merge announced nothing new") - }) - } -} - // TestFold_AConstraintDisagreementIsReported pins the second of the two // conflict routes. Two declarations that agree on type but pin one keyword to // different values fold to the first one's bound, so the other, which may be @@ -307,156 +112,6 @@ func TestResolvePrimKind_EnumResolvesThroughItsValueType(t *testing.T) { assert.Equal(t, ir.PrimString, kind) } -// TestFold_AConflictingTypeTakesNothingWithIt pins that a -// discarded declaration is discarded whole. The type conflict is diagnosed and -// the loser's type kept under Unmodeled, but the fold below it used to run to -// completion regardless, adopting the loser's default, constraints and examples -// onto the winner. That leaves the document asserting two contradictory things -// about one field — an integer carrying a string default — and nothing in -// pass/validate or irverify compares a Value's kind to its property's type, so -// an emitter renders it into non-compiling code. -func TestFold_AConflictingTypeTakesNothingWithIt(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, - }) - dst := ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/int"}} - src := ir.Property{ - WireName: "id", - Type: ir.TypeRef{Target: "t/str"}, - Default: &ir.Value{Kind: ir.ValueString, Str: "abc"}, - Constraints: &ir.Constraints{MaxLength: new(int64(10))}, - Examples: []ir.Example{{Value: &ir.Value{Kind: ir.ValueString, Str: "abc"}}}, - } - - dst = foldTwo(g, dst, src, written(`{"type":"string"}`)) - - require.Len(t, *recorded, 1, "the conflict is diagnosed") - assert.Equal(t, ir.TypeID("t/int"), dst.Type.Target, "the first declaration wins the shape") - assert.Nil(t, dst.Default, "a string default does not belong to an integer field") - assert.Nil(t, dst.Constraints, "maxLength constrains the string that was discarded") - assert.Empty(t, dst.Examples, "the examples are of the discarded shape") -} - -// TestFold_NullabilityIntersectsWhenTargetsAgree pins the case -// the judgment settles before it reads Nullable: one branch admits null and -// the other does not, so the intersection forbids it. Neither reconciled nor -// preserved before, and the answer flipped with branch order — the same source -// compiled to nullable: true or false depending on which branch was written -// first. foldNullVerdicts already states the conjunction rule for the -// single-schema case; this is the same rule across a redeclaration. -func TestFold_NullabilityIntersectsWhenTargetsAgree(t *testing.T) { - t.Parallel() - reg := map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - } - cases := []struct { - name string - dstNull, srcNull, want bool - }{ - {"nullable then not", true, false, false}, - {"not then nullable", false, true, false}, - {"both nullable", true, true, true}, - {"neither nullable", false, false, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(reg) - dst := ir.Property{WireName: "x", Type: ir.TypeRef{Target: "t/str", Nullable: tc.dstNull}} - src := ir.Property{WireName: "x", Type: ir.TypeRef{Target: "t/str", Nullable: tc.srcNull}} - - dst = foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, tc.want, dst.Type.Nullable, "the merged field admits null only where both branches do") - assert.Empty(t, *recorded, "an intersection the IR can express is not a conflict") - }) - } -} - -// TestFold_ADroppedTypeIsKeptEvenWhenItDoesNotConflict pins the -// gap between what the merge drops and what it calls a conflict. The judgment -// does not guess about two composites of one kind or an opaque scalar, but the -// fold keeps the winner's type regardless, so the redeclaration once vanished -// with neither a diagnostic nor an entry: #424's own failure, a consumer diffing -// two versions seeing no change. It is kept, and reported as a pair the merge -// does not compare, an info rather than a conflict: which type the field keeps -// then follows declaration order, and only the report says so. -func TestFold_ADroppedTypeIsKeptEvenWhenItDoesNotConflict(t *testing.T) { - t.Parallel() - reg := map[ir.TypeID]ir.TypeDef{ - "t/A": &ir.Model{ID: "t/A"}, - "t/B": &ir.Model{ID: "t/B"}, - "t/any": &ir.Any{ID: "t/any"}, - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - "t/opq": &ir.Scalar{ID: "t/opq"}, - } - cases := []struct { - name string - dstTarget, srcTarget ir.TypeID - }{ - {"two models of one kind", "t/A", "t/B"}, - {"an opaque scalar keeps the position", "t/opq", "t/str"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(reg) - dst := ir.Property{WireName: "id", Type: ir.TypeRef{Target: tc.dstTarget}} - src := ir.Property{ - WireName: "id", - Type: ir.TypeRef{Target: tc.srcTarget}, - Provenance: ir.Provenance{Source: 1, Pointer: "/p/allOf/1/properties/id"}, - } - - dst = foldTwo(g, dst, src, written(`{"type":"object"}`)) - - assert.Equal(t, tc.dstTarget, dst.Type.Target, "the first declaration still wins") - require.Len(t, *recorded, 1) - assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity, "a drop not called a conflict is no warning") - assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code) - assert.Contains(t, (*recorded)[0].Message, "that the merge does not compare") - entry, ok := dst.Unmodeled[losingDeclarationKey+"/p/allOf/1/properties/id"] - require.True(t, ok, "the dropped declaration is kept; got %v", dst.Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - }) - } -} - -// TestFold_ADroppedTypeTakesNothingWithItEvenUnjudged is -// TestFold_AConflictingTypeTakesNothingWithIt for the drops -// the judgment declines to classify. The skip was keyed on the conflict -// diagnostic rather than on the drop, so two Models — never called a conflict — -// still folded the loser's default and constraints onto a winner whose type -// could not hold them, beside an entry saying that type was dropped: the -// contradiction the skip exists to prevent, on the one path it did not cover. -func TestFold_ADroppedTypeTakesNothingWithItEvenUnjudged(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/A": &ir.Model{ID: "t/A"}, - "t/B": &ir.Model{ID: "t/B"}, - }) - dst := ir.Property{WireName: "x", Type: ir.TypeRef{Target: "t/A"}} - src := ir.Property{ - WireName: "x", - Type: ir.TypeRef{Target: "t/B"}, - Default: &ir.Value{Kind: ir.ValueObject}, - Constraints: &ir.Constraints{MinProps: new(int64(1))}, - Examples: []ir.Example{{Value: &ir.Value{Kind: ir.ValueObject}}}, - Provenance: ptrAt("/allOf/1/properties/x"), - } - - dst = foldTwo(g, dst, src, written(`{"type":"object","default":{},"minProperties":1}`)) - - require.Len(t, *recorded, 1) - assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code, "two models of one kind are still not called a conflict") - assert.Nil(t, dst.Default, "the default describes the shape that was dropped") - assert.Nil(t, dst.Constraints, "so does minProperties") - assert.Empty(t, dst.Examples, "and so do the examples") - assert.Contains(t, dst.Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", "the whole declaration is kept instead") -} - // TestFold_ALosingDetailIsKeptAndReported sweeps the fold's // adopt-if-absent fields. Each of them took the first declaration's value and // dropped a present, different value from the redeclaration with nothing said @@ -579,405 +234,6 @@ func narrowingRegistry() map[ir.TypeID]ir.TypeDef { func primRef(k ir.PrimKind) ir.TypeRef { return ir.TypeRef{Target: ir.TypeID("t/prim/" + string(k))} } -// TestFold_ANarrowerPrimitiveWinsInEitherOrder pins the refinement -// rule: a redeclaration that narrows a bare primitive loses nothing, so the -// narrower target wins whichever branch is written first, with no diagnostic -// and no kept declaration. -func TestFold_ANarrowerPrimitiveWinsInEitherOrder(t *testing.T) { - t.Parallel() - cases := []struct { - name string - broad, narrow ir.PrimKind - }{ - {"number and integer", ir.PrimNumber, ir.PrimInteger}, - {"integer and int32", ir.PrimInteger, ir.PrimInt32}, - {"string and datetime_offset", ir.PrimString, ir.PrimDatetimeOffset}, - {"int32 narrows int64", ir.PrimInt64, ir.PrimInt32}, - {"url and string", ir.PrimString, ir.PrimURL}, - {"float64 and number", ir.PrimNumber, ir.PrimFloat64}, - {"uuid and string", ir.PrimString, ir.PrimUUID}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - var merged [2]ir.TypeRef - for i, order := range [][2]ir.PrimKind{{tc.broad, tc.narrow}, {tc.narrow, tc.broad}} { - g, recorded := stubMerger(narrowingRegistry()) - dst := ir.Property{WireName: "x", Type: primRef(order[0])} - src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} - - dst = foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, primRef(tc.narrow).Target, dst.Type.Target, "the narrower target wins (order %d)", i) - assert.Empty(t, *recorded, "a refinement is not a conflict (order %d)", i) - assert.Empty(t, dst.Unmodeled, "nothing is dropped, so nothing is kept (order %d)", i) - merged[i] = dst.Type - } - assert.Empty(t, cmp.Diff(merged[0], merged[1]), "the merged type does not depend on branch order") - }) - } -} - -// TestFold_AUint8NarrowsInt16InEitherOrder pins a containment the -// old chain omitted: every uint8 is an int16, so int16 declared beside uint8 -// keeps uint8 whichever branch is written first, with nothing reported or kept. -func TestFold_AUint8NarrowsInt16InEitherOrder(t *testing.T) { - t.Parallel() - var merged [2]ir.TypeRef - for i, order := range [][2]ir.PrimKind{{ir.PrimInt16, ir.PrimUint8}, {ir.PrimUint8, ir.PrimInt16}} { - g, recorded := stubMerger(narrowingRegistry()) - dst := ir.Property{WireName: "x", Type: primRef(order[0])} - src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} - - dst = foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, primRef(ir.PrimUint8).Target, dst.Type.Target, "uint8 wins (order %d)", i) - assert.Empty(t, *recorded, "a containment is not a conflict (order %d)", i) - assert.Empty(t, dst.Unmodeled, "nothing is dropped (order %d)", i) - merged[i] = dst.Type - } - assert.Empty(t, cmp.Diff(merged[0], merged[1]), "the merged type does not depend on branch order") -} - -// TestFold_ANarrowingIntersectsNullability pins that the swap -// keeps the conjunction the equal-target case already states: over every pair -// of null admissions, in both orders, the merged field admits null only where -// both branches do. -func TestFold_ANarrowingIntersectsNullability(t *testing.T) { - t.Parallel() - for _, broadFirst := range []bool{true, false} { - for _, broadNull := range []bool{true, false} { - for _, narrowNull := range []bool{true, false} { - name := fmt.Sprintf("broadFirst=%v/broadNullable=%v/narrowNullable=%v", broadFirst, broadNull, narrowNull) - t.Run(name, func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(narrowingRegistry()) - broad := primRef(ir.PrimNumber) - broad.Nullable = broadNull - narrow := primRef(ir.PrimInteger) - narrow.Nullable = narrowNull - dst, src := broad, narrow - if !broadFirst { - dst, src = narrow, broad - } - p := ir.Property{WireName: "x", Type: dst} - - p = foldTwo(g, p, ir.Property{WireName: "x", Type: src, Provenance: ptrAt("/p")}, unread(t)) - - assert.Equal(t, narrow.Target, p.Type.Target) - assert.Equal(t, broadNull && narrowNull, p.Type.Nullable, - "null is admitted only where both branches admit it") - assert.Empty(t, *recorded) - }) - } - } - } -} - -// TestFold_ADroppedTargetKeepsTheFirstNullAdmission pins a drop: -// an unrelated redeclaration's type is lost whole, so -// the merged field keeps the first declaration's null admission, not the -// conjunction a same-referent or narrowing pair takes. -func TestFold_ADroppedTargetKeepsTheFirstNullAdmission(t *testing.T) { - t.Parallel() - for _, firstNull := range []bool{true, false} { - t.Run(fmt.Sprintf("firstNullable=%v", firstNull), func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(narrowingRegistry()) - dst := ir.Property{WireName: "x", Type: primRef(ir.PrimString)} - dst.Type.Nullable = firstNull - src := ir.Property{WireName: "x", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/allOf/1/properties/x")} - src.Type.Nullable = !firstNull - - dst = foldTwo(g, dst, src, written(`{"type":"integer"}`)) - - assert.Equal(t, primRef(ir.PrimString).Target, dst.Type.Target, "the first declaration keeps the shape") - assert.Equal(t, firstNull, dst.Type.Nullable, "a dropped type takes its null admission with it") - require.Len(t, *recorded, 1) - assert.Equal(t, diag.ConflictingRedecl, (*recorded)[0].Code) - }) - } -} - -// TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail pins that -// nothing is dropped, so the broader declaration's constraints and default are -// folded as for any agreeing pair. -func TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail(t *testing.T) { - t.Parallel() - for _, broadFirst := range []bool{true, false} { - t.Run(fmt.Sprintf("broadFirst=%v", broadFirst), func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(narrowingRegistry()) - broad := ir.Property{ - WireName: "x", - Type: primRef(ir.PrimNumber), - Default: &ir.Value{Kind: ir.ValueNumber, Num: "1"}, - Constraints: &ir.Constraints{Min: bigVal("0")}, - Provenance: ptrAt("/allOf/0/properties/x"), - } - narrow := ir.Property{WireName: "x", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/allOf/1/properties/x")} - dst, src := broad, narrow - if !broadFirst { - dst, src = narrow, broad - } - - dst = foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, primRef(ir.PrimInteger).Target, dst.Type.Target) - require.NotNil(t, dst.Constraints) - assert.Equal(t, bigVal("0"), dst.Constraints.Min, "the broader declaration's bound lands on the merged field") - require.NotNil(t, dst.Default) - assert.Equal(t, ir.BigVal("1"), dst.Default.Num, "and so does its default") - assert.Empty(t, *recorded) - }) - } -} - -// TestFold_ANarrowingIsNoDrop pins that a bare narrowing settles on one target -// in either order and is no drop, so the redeclaration is neither classified -// nor reported nor kept. -func TestFold_ANarrowingIsNoDrop(t *testing.T) { - t.Parallel() - for i, order := range [][2]ir.PrimKind{{ir.PrimInteger, ir.PrimNumber}, {ir.PrimNumber, ir.PrimInteger}} { - g, recorded := stubMerger(narrowingRegistry()) - dst := ir.Property{WireName: "x", Type: primRef(order[0])} - src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} - - got := foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, primRef(ir.PrimInteger), got.Type, "the narrower target wins (order %d)", i) - assert.Empty(t, *recorded, "a narrowing draws no diagnostic (order %d)", i) - assert.Empty(t, got.Unmodeled, "and keeps nothing (order %d)", i) - } -} - -// TestFold_ANarrowingCarriesTheNullMarkers pins TypeRef.Inferred through a -// narrowing: the field's null rests on a heuristic wherever either side's did -// (intersectNull), whichever side wins, except where the loser is the top -// type, whose null bit and marker say nothing, so the winner's stand alone. -func TestFold_ANarrowingCarriesTheNullMarkers(t *testing.T) { - t.Parallel() - nullRef := func(k ir.PrimKind, nullable bool, inferred string) ir.TypeRef { - r := primRef(k) - r.Nullable, r.Inferred = nullable, inferred - return r - } - for _, tc := range []struct { - name string - dst, src, want ir.TypeRef - }{ - {"the narrower redeclaration takes the first's marker", - nullRef(ir.PrimString, true, "h"), nullRef(ir.PrimURL, true, ""), nullRef(ir.PrimURL, true, "h")}, - {"the narrower first declaration takes the redeclaration's marker", - nullRef(ir.PrimURL, true, ""), nullRef(ir.PrimString, true, "h"), nullRef(ir.PrimURL, true, "h")}, - {"both markers, the first's first", - nullRef(ir.PrimString, true, "a"), nullRef(ir.PrimURL, true, "b"), nullRef(ir.PrimURL, true, "a,b")}, - {"a side admitting no null drops the marker", - nullRef(ir.PrimString, true, "h"), nullRef(ir.PrimURL, false, ""), nullRef(ir.PrimURL, false, "")}, - {"the top type's marker says nothing", - nullRef(ir.PrimAny, true, "h"), nullRef(ir.PrimURL, true, "w"), nullRef(ir.PrimURL, true, "w")}, - {"nor does its missing null", - nullRef(ir.PrimURL, true, "w"), nullRef(ir.PrimAny, false, ""), nullRef(ir.PrimURL, true, "w")}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(narrowingRegistry()) - got := foldTwo(g, ir.Property{WireName: "x", Type: tc.dst}, - ir.Property{WireName: "x", Type: tc.src, Provenance: ptrAt("/allOf/1/properties/x")}, unread(t)) - - assert.Equal(t, tc.want, got.Type) - assert.Empty(t, *recorded) - }) - } -} - -// TestFold_TheJudgmentsVerdictIsWhatIsReported pins the three -// paths of the fold: a disjoint pair warns, an overlapping pair draws an info, -// and one target declared twice judges null alone and adopts a default. -func TestFold_TheJudgmentsVerdictIsWhatIsReported(t *testing.T) { - t.Parallel() - d := str("x") - for _, tc := range []struct { - name string - a, b ir.TypeID - dflt *ir.Value - severity ir.Severity - code string - keptEntry bool - }{ - {"a literal against a kind it is disjoint from", "t/lit-x", "t/prim/integer", nil, - ir.SeverityWarning, diag.ConflictingRedecl, true}, - {"lists whose elements overlap", "t/list-int", "t/list-i32-null", nil, - ir.SeverityInfo, diag.DegradedConstruct, true}, - {"one target declared twice", "t/prim/integer", "t/prim/integer", &d, "", "", false}, - } { - for i, order := range [][2]ir.TypeID{{tc.a, tc.b}, {tc.b, tc.a}} { - g, recorded := stubMerger(judgeRegistry()) - dst := ir.Property{WireName: "x", Type: ref(order[0]), Provenance: ptrAt("/allOf/0/properties/x")} - src := ir.Property{WireName: "x", Type: ref(order[1]), Default: tc.dflt, - Provenance: ptrAt("/allOf/1/properties/x")} - if i == 1 { - dst.Default, src.Default = src.Default, nil - } - - dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) - - if tc.keptEntry { - require.Len(t, *recorded, 1, "%s (order %d)", tc.name, i) - assert.Equal(t, tc.severity, (*recorded)[0].Severity, "%s (order %d)", tc.name, i) - assert.Equal(t, tc.code, (*recorded)[0].Code, "%s (order %d)", tc.name, i) - assert.Contains(t, dst.Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", - "%s (order %d)", tc.name, i) - continue - } - assert.Empty(t, *recorded, "%s (order %d)", tc.name, i) - assert.Equal(t, tc.dflt, dst.Default, "the default is adopted (%s, order %d)", tc.name, i) - assert.Empty(t, dst.Unmodeled, "%s (order %d)", tc.name, i) - } - } -} - -// reconcileBothOrders reconciles kinds a and b in both declaration orders and -// returns what each order recorded and merged. -func reconcileBothOrders(t *testing.T, a, b ir.PrimKind) (dsts [2]ir.Property, diags [2][]ir.Diagnostic) { - t.Helper() - for i, order := range [][2]ir.PrimKind{{a, b}, {b, a}} { - g, recorded := stubMerger(narrowingRegistry()) - dst := ir.Property{WireName: "x", Type: primRef(order[0]), Provenance: ptrAt("/allOf/0/properties/x")} - src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} - - dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) - - dsts[i], diags[i] = dst, *recorded - } - return dsts, diags -} - -// TestFold_OverlappingKindsAreKeptAndReportedAsInfo pins that -// kinds sharing values with neither holding the other keep the first -// declaration's kind, draw an info, and keep the redeclaration, in both orders. -func TestFold_OverlappingKindsAreKeptAndReportedAsInfo(t *testing.T) { - t.Parallel() - cases := []struct { - name string - a, b ir.PrimKind - }{ - {"int32 and uint32", ir.PrimInt32, ir.PrimUint32}, - {"float32 and integer", ir.PrimFloat32, ir.PrimInteger}, - {"int64 and float64", ir.PrimInt64, ir.PrimFloat64}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - dsts, diags := reconcileBothOrders(t, tc.a, tc.b) - for i, first := range []ir.PrimKind{tc.a, tc.b} { - assert.Equal(t, primRef(first).Target, dsts[i].Type.Target, "the first declaration's kind stays (order %d)", i) - require.Len(t, diags[i], 1, "exactly one diagnostic (order %d)", i) - assert.Equal(t, diag.DegradedConstruct, diags[i][0].Code, "order %d", i) - assert.Equal(t, ir.SeverityInfo, diags[i][0].Severity, "order %d", i) - assert.Contains(t, dsts[i].Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", - "the redeclaration is kept (order %d)", i) - } - }) - } -} - -// TestFold_DisjointKindsStillWarn pins that kinds no value shares -// keep the conflicting-redeclaration warning, in both orders. -func TestFold_DisjointKindsStillWarn(t *testing.T) { - t.Parallel() - cases := []struct { - name string - a, b ir.PrimKind - }{ - {"string and integer", ir.PrimString, ir.PrimInteger}, - {"bool and number", ir.PrimBool, ir.PrimNumber}, - {"uuid and date-time", ir.PrimUUID, ir.PrimDatetimeOffset}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - dsts, diags := reconcileBothOrders(t, tc.a, tc.b) - for i, first := range []ir.PrimKind{tc.a, tc.b} { - assert.Equal(t, primRef(first).Target, dsts[i].Type.Target, "the first declaration still wins (order %d)", i) - require.Len(t, diags[i], 1, "exactly one diagnostic (order %d)", i) - assert.Equal(t, diag.ConflictingRedecl, diags[i][0].Code, "order %d", i) - assert.Equal(t, ir.SeverityWarning, diags[i][0].Severity, "order %d", i) - assert.Contains(t, dsts[i].Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", "order %d", i) - } - }) - } -} - -// enumRegistry adds two integer enums and two string enums to narrowingRegistry, -// each with a member the pairs below judge, and the uuid and bytes Scalar nodes -// that stand for a kind the same way a bare reference does. -func enumRegistry() map[ir.TypeID]ir.TypeDef { - reg := narrowingRegistry() - reg["t/enum/a"] = &ir.Enum{ID: "t/enum/a", ValueType: ir.PrimString, Members: []ir.EnumMember{{Value: str("a")}}} - reg["t/enum/b"] = &ir.Enum{ID: "t/enum/b", ValueType: ir.PrimString, Members: []ir.EnumMember{{Value: str("b")}}} - reg["t/enum/big"] = &ir.Enum{ID: "t/enum/big", ValueType: ir.PrimInteger, Members: []ir.EnumMember{{Value: num("5000000000")}}} - reg["t/enum/small"] = &ir.Enum{ID: "t/enum/small", ValueType: ir.PrimInteger, Members: []ir.EnumMember{{Value: num("1")}}} - uuid, bytes := primRef(ir.PrimUUID), primRef(ir.PrimBytes) - reg["t/scalar/uuid"] = &ir.Scalar{ID: "t/scalar/uuid", Base: &uuid} - reg["t/scalar/bytes"] = &ir.Scalar{ID: "t/scalar/bytes", Base: &bytes} - return reg -} - -// TestFold_EnumPairsAreJudgedByKind pins, in both orders, what an -// enum side draws: an enum over a bare kind or a Scalar node it equals or -// narrows wins; a bare kind or a Scalar node that admits one of its members -// overlaps it, which is info and keeps the first declaration, as does a kind -// with no judge; a kind that admits none of them warns, as does another -// family; and two enums of one kind, each with members, overlap. -func TestFold_EnumPairsAreJudgedByKind(t *testing.T) { - t.Parallel() - enum := func(id ir.TypeID) ir.TypeRef { return ir.TypeRef{Target: id} } - cases := []struct { - name string - a, b ir.TypeRef - want ir.Severity // "" = none - winner ir.TypeID // "" = the first declaration's - }{ - {"enum of string and uuid", enum("t/enum/a"), primRef(ir.PrimUUID), ir.SeverityWarning, ""}, - {"enum of integer and int32", enum("t/enum/big"), primRef(ir.PrimInt32), ir.SeverityWarning, ""}, - {"enum admitted by int32", enum("t/enum/small"), primRef(ir.PrimInt32), ir.SeverityInfo, ""}, - {"enum of string and bytes", enum("t/enum/a"), primRef(ir.PrimBytes), ir.SeverityInfo, ""}, - {"enum of string and a uuid scalar", enum("t/enum/a"), enum("t/scalar/uuid"), ir.SeverityWarning, ""}, - {"enum of string and a bytes scalar", enum("t/enum/a"), enum("t/scalar/bytes"), ir.SeverityInfo, ""}, - {"enum of string and integer", enum("t/enum/a"), primRef(ir.PrimInteger), ir.SeverityWarning, ""}, - {"enum of string and another enum", enum("t/enum/a"), enum("t/enum/b"), ir.SeverityInfo, ""}, - {"enum of string and bare string", enum("t/enum/a"), primRef(ir.PrimString), "", "t/enum/a"}, - {"enum of integer and number", enum("t/enum/big"), primRef(ir.PrimNumber), "", "t/enum/big"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - for i, order := range [][2]ir.TypeRef{{tc.a, tc.b}, {tc.b, tc.a}} { - g, recorded := stubMerger(enumRegistry()) - dst := ir.Property{WireName: "x", Type: order[0], Provenance: ptrAt("/allOf/0/properties/x")} - src := ir.Property{WireName: "x", Type: order[1], Provenance: ptrAt("/allOf/1/properties/x")} - - dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) - - wantTarget := order[0].Target - if tc.winner != "" { - wantTarget = tc.winner - } - assert.Equal(t, wantTarget, dst.Type.Target, "the winning target (order %d)", i) - if tc.want == "" { - assert.Empty(t, *recorded, "no diagnostic (order %d)", i) - continue - } - require.Len(t, *recorded, 1, "exactly one diagnostic (order %d)", i) - assert.Equal(t, tc.want, (*recorded)[0].Severity, "order %d", i) - assert.Contains(t, dst.Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", - "the redeclaration is kept (order %d)", i) - } - }) - } -} - // TestFold_AnInadmissibleValueIsKeptNotAdopted pins that no // declaration of a fold lands a default or example the merged field does not // admit: the field carries no such value, and one info and one Unmodeled entry @@ -1347,159 +603,3 @@ func TestFold_ADeclarationWithNoSourceIsReportedUnkeepable(t *testing.T) { assert.Contains(t, (*recorded)[1].Message, errNoSource.Error()) assert.Empty(t, got.Unmodeled) } - -// TestFold_AScalarOverANarrowerKindWins pins that a Scalar over a -// kind the bare side equals or contains wins in either order, with no -// diagnostic and no kept declaration. -func TestFold_AScalarOverANarrowerKindWins(t *testing.T) { - t.Parallel() - reg := judgeRegistry() - for i, order := range [][2]ir.TypeRef{ - {ref("t/scalar-int"), primRef(ir.PrimNumber)}, - {primRef(ir.PrimNumber), ref("t/scalar-int")}, - } { - g, recorded := stubMerger(reg) - dst := ir.Property{WireName: "x", Type: order[0], Provenance: ptrAt("/allOf/0/properties/x")} - src := ir.Property{WireName: "x", Type: order[1], Provenance: ptrAt("/allOf/1/properties/x")} - - dst = foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, ir.TypeID("t/scalar-int"), dst.Type.Target, "order %d", i) - assert.Empty(t, *recorded, "order %d", i) - assert.Empty(t, dst.Unmodeled, "order %d", i) - } -} - -// TestFold_TheTopTypeTakesTheOtherSidesNullAdmission pins that a -// narrower type over the top type keeps its own null admission rather than the -// conjunction with the top type's, since the top type admits null yet lowers -// non-nullable. -func TestFold_TheTopTypeTakesTheOtherSidesNullAdmission(t *testing.T) { - t.Parallel() - nullable := func(r ir.TypeRef) ir.TypeRef { r.Nullable = true; return r } - for i, order := range [][2]ir.TypeRef{ - {primRef(ir.PrimAny), nullable(primRef(ir.PrimInteger))}, - {nullable(primRef(ir.PrimInteger)), primRef(ir.PrimAny)}, - } { - g, recorded := stubMerger(judgeRegistry()) - dst := ir.Property{WireName: "x", Type: order[0], Provenance: ptrAt("/allOf/0/properties/x")} - src := ir.Property{WireName: "x", Type: order[1], Provenance: ptrAt("/allOf/1/properties/x")} - - dst = foldTwo(g, dst, src, unread(t)) - - assert.Equal(t, nullable(primRef(ir.PrimInteger)), dst.Type, "nullable integer (order %d)", i) - assert.Empty(t, *recorded, "order %d", i) - } -} - -// twinRegistry holds, for each of Enum, Literal and Scalar, two hoisted nodes -// that differ only in ID and member IDs, and the variants that differ in a -// field. t/enum/x-named and t/enum/x-named2 are named copies of t/enum/x1. -func twinRegistry() map[ir.TypeID]ir.TypeDef { - reg := narrowingRegistry() - members := func(prefix string, vs ...string) []ir.EnumMember { - out := make([]ir.EnumMember, 0, len(vs)) - for _, v := range vs { - out = append(out, ir.EnumMember{ID: ir.EnumMemberID(prefix + v), Value: str(v)}) - } - return out - } - enum := func(id ir.TypeID, ms []ir.EnumMember, desc string) *ir.Enum { - e := &ir.Enum{ID: id, ValueType: ir.PrimString, Members: ms} - e.Docs.Description = desc - return e - } - reg["t/enum/x1"] = enum("t/enum/x1", members("x1/", "a", "b"), "") - reg["t/enum/x2"] = enum("t/enum/x2", members("x2/", "a", "b"), "") - reg["t/enum/x-described"] = enum("t/enum/x-described", members("xd/", "a", "b"), "some") - reg["t/enum/x-reordered"] = enum("t/enum/x-reordered", members("xr/", "b", "a"), "") - for _, id := range []ir.TypeID{"t/enum/x-ext1", "t/enum/x-ext2"} { - e := enum(id, members(string(id)+"/", "a", "b"), "") - e.Unmodeled = ir.Unmodeled{"openapi:x-foo": { - Reason: ir.ReasonVendorExtension, - Value: ir.RawValue("1"), - Provenance: ptrAt(jsontext.Pointer("/at/" + string(id) + "/x-foo")), - }} - reg[id] = e - } - valued := enum("t/enum/x-ext-valued", members("xv/", "a", "b"), "") - valued.Unmodeled = ir.Unmodeled{"openapi:x-foo": { - Reason: ir.ReasonVendorExtension, - Value: ir.RawValue("2"), - Provenance: ptrAt("/at/t/enum/x-ext1/x-foo"), - }} - reg["t/enum/x-ext-valued"] = valued - reg["t/lit/1"] = &ir.Literal{ID: "t/lit/1", Value: str("a")} - reg["t/lit/2"] = &ir.Literal{ID: "t/lit/2", Value: str("a")} - str := primRef(ir.PrimString) - minLen := int64(2) - reg["t/hex/1"] = &ir.Scalar{ID: "t/hex/1", Base: &str} - reg["t/hex/2"] = &ir.Scalar{ID: "t/hex/2", Base: &str} - reg["t/hex/min"] = &ir.Scalar{ID: "t/hex/min", Base: &str, Constraints: &ir.Constraints{MinLength: &minLen}} - for _, td := range reg { - switch td.(type) { - case *ir.Enum, *ir.Literal, *ir.Scalar: - td.Common().Anonymous = true - } - } - reg["t/enum/x-named"] = enum("t/enum/x-named", members("xn/", "a", "b"), "") - reg["t/enum/x-named2"] = enum("t/enum/x-named2", members("xn2/", "a", "b"), "") - return reg -} - -// TestFold_IdenticalNodesAreOneType pins, in both orders, that two -// hoisted Enum, Literal or Scalar nodes equal but for what names their site are -// one type: no diagnostic and nothing kept aside. A named node wins over a -// hoisted copy of itself, and two named ones, or a pair that differs in a -// field, member order included, are an overlap reported as info. -func TestFold_IdenticalNodesAreOneType(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - a, b ir.TypeID - same bool - win ir.TypeID // the type that stands in both orders, or "" for the first - }{ - {"two identical enums", "t/enum/x1", "t/enum/x2", true, ""}, - {"two identical literals", "t/lit/1", "t/lit/2", true, ""}, - {"two identical scalars", "t/hex/1", "t/hex/2", true, ""}, - {"enums that differ in the provenance of an extension", "t/enum/x-ext1", "t/enum/x-ext2", true, ""}, - {"a named enum against a hoisted copy", "t/enum/x-named", "t/enum/x1", true, "t/enum/x-named"}, - {"two named enums declared alike", "t/enum/x-named", "t/enum/x-named2", false, ""}, - {"enums that differ in an extension's value", "t/enum/x-ext1", "t/enum/x-ext-valued", false, ""}, - {"enums that differ in a description", "t/enum/x1", "t/enum/x-described", false, ""}, - {"enums that differ in member order", "t/enum/x1", "t/enum/x-reordered", false, ""}, - {"scalars that differ in a constraint", "t/hex/1", "t/hex/min", false, ""}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - for i, order := range [][2]ir.TypeID{{tc.a, tc.b}, {tc.b, tc.a}} { - reg := twinRegistry() - g, recorded := stubMerger(reg) - dst := ir.Property{WireName: "x", Type: ir.TypeRef{Target: order[0]}, Provenance: ptrAt("/allOf/0/properties/x")} - src := ir.Property{WireName: "x", Type: ir.TypeRef{Target: order[1]}, Provenance: ptrAt("/allOf/1/properties/x")} - for _, p := range []ir.Property{dst, src} { - if c := reg[p.Type.Target].Common(); c.Anonymous { - c.Provenance = p.Provenance - } - } - - dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) - - want := tc.win - if want == "" { - want = order[0] - } - assert.Equal(t, want, dst.Type.Target, "the type that stands (order %d)", i) - if tc.same { - assert.Empty(t, *recorded, "no diagnostic (order %d)", i) - assert.Empty(t, dst.Unmodeled, "nothing is kept (order %d)", i) - continue - } - require.Len(t, *recorded, 1, "exactly one diagnostic (order %d)", i) - assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity, "order %d", i) - assert.Len(t, dst.Unmodeled, 1, "the redeclaration is kept (order %d)", i) - } - }) - } -} diff --git a/compilers/openapi/internal/merge/fold_docs_internal_test.go b/compilers/openapi/internal/merge/fold_docs_internal_test.go new file mode 100644 index 00000000..001ae5f2 --- /dev/null +++ b/compilers/openapi/internal/merge/fold_docs_internal_test.go @@ -0,0 +1,133 @@ +package merge + +import ( + "encoding/json/jsontext" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// TestFold_DifferingDescriptionsKeepTheFirst pins a merge outcome +// that is reported without being a conflict: two branches describing the same +// field differently cannot both be kept, so the first wins, the loss is +// announced rather than silent, and the losing declaration is kept whole. +func TestFold_DifferingDescriptionsKeepTheFirst(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + dst := ir.Property{WireName: "id", Docs: ir.Docs{Description: "first"}} + + dst = foldTwo(g, dst, ir.Property{WireName: "id", Docs: ir.Docs{Description: "second"}, Provenance: ptrAt("/other")}, + written(`{"description":"second"}`)) + + assert.Equal(t, "first", dst.Docs.Description) + require.Len(t, *recorded, 1) + assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity) + assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code) + assert.Contains(t, (*recorded)[0].Message, `"id"`) + assert.Contains(t, (*recorded)[0].Message, "description") + assert.Equal(t, ir.RawValue(`{"description":"second"}`), dst.Unmodeled[losingDeclarationKey+"/other"].Value) +} + +// TestFold_AnIdenticalDescriptionIsNotADisagreement pins the other +// side of that check: repeating the same description in both branches is one +// construct written twice, not two that disagree. +func TestFold_AnIdenticalDescriptionIsNotADisagreement(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + dst := ir.Property{WireName: "id", Docs: ir.Docs{Description: "same"}} + + dst = foldTwo(g, dst, ir.Property{WireName: "id", Docs: ir.Docs{Description: "same"}, Provenance: ptrAt("/other")}, unread(t)) + + assert.Equal(t, "same", dst.Docs.Description) + assert.Empty(t, *recorded) +} + +// TestFold_TitleAndExternalDocsFoldLikeDescription pins that a +// summary and external links fold as a description does: an empty side adopts +// the other, equal sides are one construct written twice, and differing sides +// keep the first and the redeclaration whole, in either declaration order. +func TestFold_TitleAndExternalDocsFoldLikeDescription(t *testing.T) { + t.Parallel() + linkA := []ir.Link{{URL: "https://a.example"}} + linkB := []ir.Link{{URL: "https://b.example"}} + summary := func(s string) ir.Docs { return ir.Docs{Summary: s} } + links := func(l []ir.Link) ir.Docs { return ir.Docs{ExternalDocs: l} } + + tests := []struct { + name string + dst, src ir.Docs + want ir.Docs + detail string // empty when nothing is lost + }{ + {"summary, dst empty", summary(""), summary("B"), summary("B"), ""}, + {"summary, src empty", summary("A"), summary(""), summary("A"), ""}, + {"summary, equal", summary("A"), summary("A"), summary("A"), ""}, + {"summary, different", summary("A"), summary("B"), summary("A"), "title"}, + {"summary, different swapped", summary("B"), summary("A"), summary("B"), "title"}, + {"external docs, dst empty", links(nil), links(linkB), links(linkB), ""}, + {"external docs, src empty", links(linkA), links(nil), links(linkA), ""}, + {"external docs, equal", links(linkA), links(linkA), links(linkA), ""}, + {"external docs, different", links(linkA), links(linkB), links(linkA), "external docs"}, + {"external docs, different swapped", links(linkB), links(linkA), links(linkB), "external docs"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + dst := ir.Property{WireName: "id", Docs: tt.dst} + src := ir.Property{WireName: "id", Docs: tt.src, Provenance: ptrAt("/other")} + source := unread(t) + if tt.detail != "" { + source = written(`{"x":1}`) + } + + dst = foldTwo(g, dst, src, source) + + assert.Empty(t, cmp.Diff(tt.want, dst.Docs)) + if tt.detail == "" { + assert.Empty(t, *recorded) + assert.Empty(t, dst.Unmodeled) + return + } + require.Len(t, *recorded, 1) + assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity) + assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code) + assert.Equal(t, jsontext.Pointer("/other"), (*recorded)[0].Provenance.Pointer) + assert.Contains(t, (*recorded)[0].Message, `"id"`) + assert.Contains(t, (*recorded)[0].Message, tt.detail) + require.Len(t, dst.Unmodeled, 1) + entry, ok := dst.Unmodeled[losingDeclarationKey+"/other"] + require.True(t, ok) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Equal(t, ir.RawValue(`{"x":1}`), entry.Value) + }) + } +} + +// TestFold_EveryDocsDisagreementIsReported pins that foldDocs +// judges each docs field rather than stopping at the first that differs: a +// redeclaration giving all three differently is reported three times, and +// kept once. +func TestFold_EveryDocsDisagreementIsReported(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + first := ir.Docs{Summary: "A", Description: "dA", ExternalDocs: []ir.Link{{URL: "https://a.example"}}} + dst := ir.Property{WireName: "id", Docs: first} + src := ir.Property{WireName: "id", Provenance: ptrAt("/other"), + Docs: ir.Docs{Summary: "B", Description: "dB", ExternalDocs: []ir.Link{{URL: "https://b.example"}}}} + + dst = foldTwo(g, dst, src, written(`{"x":1}`)) + + assert.Empty(t, cmp.Diff(first, dst.Docs)) + require.Len(t, *recorded, 3) + for i, detail := range []string{"title", "description", "external docs"} { + assert.Contains(t, (*recorded)[i].Message, detail) + } + require.Len(t, dst.Unmodeled, 1) + assert.Equal(t, ir.RawValue(`{"x":1}`), dst.Unmodeled[losingDeclarationKey+"/other"].Value) +} diff --git a/compilers/openapi/internal/merge/fold_narrowing_internal_test.go b/compilers/openapi/internal/merge/fold_narrowing_internal_test.go new file mode 100644 index 00000000..358d85a4 --- /dev/null +++ b/compilers/openapi/internal/merge/fold_narrowing_internal_test.go @@ -0,0 +1,269 @@ +package merge + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// TestFold_ANarrowerPrimitiveWinsInEitherOrder pins the refinement +// rule: a redeclaration that narrows a bare primitive loses nothing, so the +// narrower target wins whichever branch is written first, with no diagnostic +// and no kept declaration. +func TestFold_ANarrowerPrimitiveWinsInEitherOrder(t *testing.T) { + t.Parallel() + cases := []struct { + name string + broad, narrow ir.PrimKind + }{ + {"number and integer", ir.PrimNumber, ir.PrimInteger}, + {"integer and int32", ir.PrimInteger, ir.PrimInt32}, + {"string and datetime_offset", ir.PrimString, ir.PrimDatetimeOffset}, + {"int32 narrows int64", ir.PrimInt64, ir.PrimInt32}, + {"url and string", ir.PrimString, ir.PrimURL}, + {"float64 and number", ir.PrimNumber, ir.PrimFloat64}, + {"uuid and string", ir.PrimString, ir.PrimUUID}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var merged [2]ir.TypeRef + for i, order := range [][2]ir.PrimKind{{tc.broad, tc.narrow}, {tc.narrow, tc.broad}} { + g, recorded := stubMerger(narrowingRegistry()) + dst := ir.Property{WireName: "x", Type: primRef(order[0])} + src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} + + dst = foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, primRef(tc.narrow).Target, dst.Type.Target, "the narrower target wins (order %d)", i) + assert.Empty(t, *recorded, "a refinement is not a conflict (order %d)", i) + assert.Empty(t, dst.Unmodeled, "nothing is dropped, so nothing is kept (order %d)", i) + merged[i] = dst.Type + } + assert.Empty(t, cmp.Diff(merged[0], merged[1]), "the merged type does not depend on branch order") + }) + } +} + +// TestFold_AUint8NarrowsInt16InEitherOrder pins a containment the +// old chain omitted: every uint8 is an int16, so int16 declared beside uint8 +// keeps uint8 whichever branch is written first, with nothing reported or kept. +func TestFold_AUint8NarrowsInt16InEitherOrder(t *testing.T) { + t.Parallel() + var merged [2]ir.TypeRef + for i, order := range [][2]ir.PrimKind{{ir.PrimInt16, ir.PrimUint8}, {ir.PrimUint8, ir.PrimInt16}} { + g, recorded := stubMerger(narrowingRegistry()) + dst := ir.Property{WireName: "x", Type: primRef(order[0])} + src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} + + dst = foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, primRef(ir.PrimUint8).Target, dst.Type.Target, "uint8 wins (order %d)", i) + assert.Empty(t, *recorded, "a containment is not a conflict (order %d)", i) + assert.Empty(t, dst.Unmodeled, "nothing is dropped (order %d)", i) + merged[i] = dst.Type + } + assert.Empty(t, cmp.Diff(merged[0], merged[1]), "the merged type does not depend on branch order") +} + +// TestFold_ANarrowingIntersectsNullability pins that the swap +// keeps the conjunction the equal-target case already states: over every pair +// of null admissions, in both orders, the merged field admits null only where +// both branches do. +func TestFold_ANarrowingIntersectsNullability(t *testing.T) { + t.Parallel() + for _, broadFirst := range []bool{true, false} { + for _, broadNull := range []bool{true, false} { + for _, narrowNull := range []bool{true, false} { + name := fmt.Sprintf("broadFirst=%v/broadNullable=%v/narrowNullable=%v", broadFirst, broadNull, narrowNull) + t.Run(name, func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(narrowingRegistry()) + broad := primRef(ir.PrimNumber) + broad.Nullable = broadNull + narrow := primRef(ir.PrimInteger) + narrow.Nullable = narrowNull + dst, src := broad, narrow + if !broadFirst { + dst, src = narrow, broad + } + p := ir.Property{WireName: "x", Type: dst} + + p = foldTwo(g, p, ir.Property{WireName: "x", Type: src, Provenance: ptrAt("/p")}, unread(t)) + + assert.Equal(t, narrow.Target, p.Type.Target) + assert.Equal(t, broadNull && narrowNull, p.Type.Nullable, + "null is admitted only where both branches admit it") + assert.Empty(t, *recorded) + }) + } + } + } +} + +// TestFold_ADroppedTargetKeepsTheFirstNullAdmission pins a drop: +// an unrelated redeclaration's type is lost whole, so +// the merged field keeps the first declaration's null admission, not the +// conjunction a same-referent or narrowing pair takes. +func TestFold_ADroppedTargetKeepsTheFirstNullAdmission(t *testing.T) { + t.Parallel() + for _, firstNull := range []bool{true, false} { + t.Run(fmt.Sprintf("firstNullable=%v", firstNull), func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(narrowingRegistry()) + dst := ir.Property{WireName: "x", Type: primRef(ir.PrimString)} + dst.Type.Nullable = firstNull + src := ir.Property{WireName: "x", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/allOf/1/properties/x")} + src.Type.Nullable = !firstNull + + dst = foldTwo(g, dst, src, written(`{"type":"integer"}`)) + + assert.Equal(t, primRef(ir.PrimString).Target, dst.Type.Target, "the first declaration keeps the shape") + assert.Equal(t, firstNull, dst.Type.Nullable, "a dropped type takes its null admission with it") + require.Len(t, *recorded, 1) + assert.Equal(t, diag.ConflictingRedecl, (*recorded)[0].Code) + }) + } +} + +// TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail pins that +// nothing is dropped, so the broader declaration's constraints and default are +// folded as for any agreeing pair. +func TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail(t *testing.T) { + t.Parallel() + for _, broadFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("broadFirst=%v", broadFirst), func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(narrowingRegistry()) + broad := ir.Property{ + WireName: "x", + Type: primRef(ir.PrimNumber), + Default: &ir.Value{Kind: ir.ValueNumber, Num: "1"}, + Constraints: &ir.Constraints{Min: bigVal("0")}, + Provenance: ptrAt("/allOf/0/properties/x"), + } + narrow := ir.Property{WireName: "x", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/allOf/1/properties/x")} + dst, src := broad, narrow + if !broadFirst { + dst, src = narrow, broad + } + + dst = foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, primRef(ir.PrimInteger).Target, dst.Type.Target) + require.NotNil(t, dst.Constraints) + assert.Equal(t, bigVal("0"), dst.Constraints.Min, "the broader declaration's bound lands on the merged field") + require.NotNil(t, dst.Default) + assert.Equal(t, ir.BigVal("1"), dst.Default.Num, "and so does its default") + assert.Empty(t, *recorded) + }) + } +} + +// TestFold_ANarrowingIsNoDrop pins that a bare narrowing settles on one target +// in either order and is no drop, so the redeclaration is neither classified +// nor reported nor kept. +func TestFold_ANarrowingIsNoDrop(t *testing.T) { + t.Parallel() + for i, order := range [][2]ir.PrimKind{{ir.PrimInteger, ir.PrimNumber}, {ir.PrimNumber, ir.PrimInteger}} { + g, recorded := stubMerger(narrowingRegistry()) + dst := ir.Property{WireName: "x", Type: primRef(order[0])} + src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} + + got := foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, primRef(ir.PrimInteger), got.Type, "the narrower target wins (order %d)", i) + assert.Empty(t, *recorded, "a narrowing draws no diagnostic (order %d)", i) + assert.Empty(t, got.Unmodeled, "and keeps nothing (order %d)", i) + } +} + +// TestFold_ANarrowingCarriesTheNullMarkers pins TypeRef.Inferred through a +// narrowing: the field's null rests on a heuristic wherever either side's did +// (intersectNull), whichever side wins, except where the loser is the top +// type, whose null bit and marker say nothing, so the winner's stand alone. +func TestFold_ANarrowingCarriesTheNullMarkers(t *testing.T) { + t.Parallel() + nullRef := func(k ir.PrimKind, nullable bool, inferred string) ir.TypeRef { + r := primRef(k) + r.Nullable, r.Inferred = nullable, inferred + return r + } + for _, tc := range []struct { + name string + dst, src, want ir.TypeRef + }{ + {"the narrower redeclaration takes the first's marker", + nullRef(ir.PrimString, true, "h"), nullRef(ir.PrimURL, true, ""), nullRef(ir.PrimURL, true, "h")}, + {"the narrower first declaration takes the redeclaration's marker", + nullRef(ir.PrimURL, true, ""), nullRef(ir.PrimString, true, "h"), nullRef(ir.PrimURL, true, "h")}, + {"both markers, the first's first", + nullRef(ir.PrimString, true, "a"), nullRef(ir.PrimURL, true, "b"), nullRef(ir.PrimURL, true, "a,b")}, + {"a side admitting no null drops the marker", + nullRef(ir.PrimString, true, "h"), nullRef(ir.PrimURL, false, ""), nullRef(ir.PrimURL, false, "")}, + {"the top type's marker says nothing", + nullRef(ir.PrimAny, true, "h"), nullRef(ir.PrimURL, true, "w"), nullRef(ir.PrimURL, true, "w")}, + {"nor does its missing null", + nullRef(ir.PrimURL, true, "w"), nullRef(ir.PrimAny, false, ""), nullRef(ir.PrimURL, true, "w")}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(narrowingRegistry()) + got := foldTwo(g, ir.Property{WireName: "x", Type: tc.dst}, + ir.Property{WireName: "x", Type: tc.src, Provenance: ptrAt("/allOf/1/properties/x")}, unread(t)) + + assert.Equal(t, tc.want, got.Type) + assert.Empty(t, *recorded) + }) + } +} + +// TestFold_AScalarOverANarrowerKindWins pins that a Scalar over a +// kind the bare side equals or contains wins in either order, with no +// diagnostic and no kept declaration. +func TestFold_AScalarOverANarrowerKindWins(t *testing.T) { + t.Parallel() + reg := judgeRegistry() + for i, order := range [][2]ir.TypeRef{ + {ref("t/scalar-int"), primRef(ir.PrimNumber)}, + {primRef(ir.PrimNumber), ref("t/scalar-int")}, + } { + g, recorded := stubMerger(reg) + dst := ir.Property{WireName: "x", Type: order[0], Provenance: ptrAt("/allOf/0/properties/x")} + src := ir.Property{WireName: "x", Type: order[1], Provenance: ptrAt("/allOf/1/properties/x")} + + dst = foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, ir.TypeID("t/scalar-int"), dst.Type.Target, "order %d", i) + assert.Empty(t, *recorded, "order %d", i) + assert.Empty(t, dst.Unmodeled, "order %d", i) + } +} + +// TestFold_TheTopTypeTakesTheOtherSidesNullAdmission pins that a +// narrower type over the top type keeps its own null admission rather than the +// conjunction with the top type's, since the top type admits null yet lowers +// non-nullable. +func TestFold_TheTopTypeTakesTheOtherSidesNullAdmission(t *testing.T) { + t.Parallel() + nullable := func(r ir.TypeRef) ir.TypeRef { r.Nullable = true; return r } + for i, order := range [][2]ir.TypeRef{ + {primRef(ir.PrimAny), nullable(primRef(ir.PrimInteger))}, + {nullable(primRef(ir.PrimInteger)), primRef(ir.PrimAny)}, + } { + g, recorded := stubMerger(judgeRegistry()) + dst := ir.Property{WireName: "x", Type: order[0], Provenance: ptrAt("/allOf/0/properties/x")} + src := ir.Property{WireName: "x", Type: order[1], Provenance: ptrAt("/allOf/1/properties/x")} + + dst = foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, nullable(primRef(ir.PrimInteger)), dst.Type, "nullable integer (order %d)", i) + assert.Empty(t, *recorded, "order %d", i) + } +} diff --git a/compilers/openapi/internal/merge/fold_types_internal_test.go b/compilers/openapi/internal/merge/fold_types_internal_test.go new file mode 100644 index 00000000..7c222700 --- /dev/null +++ b/compilers/openapi/internal/merge/fold_types_internal_test.go @@ -0,0 +1,461 @@ +package merge + +import ( + "encoding/json/jsontext" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// TestFold_AConflictingTypeTakesNothingWithIt pins that a +// discarded declaration is discarded whole. The type conflict is diagnosed and +// the loser's type kept under Unmodeled, but the fold below it used to run to +// completion regardless, adopting the loser's default, constraints and examples +// onto the winner. That leaves the document asserting two contradictory things +// about one field — an integer carrying a string default — and nothing in +// pass/validate or irverify compares a Value's kind to its property's type, so +// an emitter renders it into non-compiling code. +func TestFold_AConflictingTypeTakesNothingWithIt(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, + }) + dst := ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/int"}} + src := ir.Property{ + WireName: "id", + Type: ir.TypeRef{Target: "t/str"}, + Default: &ir.Value{Kind: ir.ValueString, Str: "abc"}, + Constraints: &ir.Constraints{MaxLength: new(int64(10))}, + Examples: []ir.Example{{Value: &ir.Value{Kind: ir.ValueString, Str: "abc"}}}, + } + + dst = foldTwo(g, dst, src, written(`{"type":"string"}`)) + + require.Len(t, *recorded, 1, "the conflict is diagnosed") + assert.Equal(t, ir.TypeID("t/int"), dst.Type.Target, "the first declaration wins the shape") + assert.Nil(t, dst.Default, "a string default does not belong to an integer field") + assert.Nil(t, dst.Constraints, "maxLength constrains the string that was discarded") + assert.Empty(t, dst.Examples, "the examples are of the discarded shape") +} + +// TestFold_NullabilityIntersectsWhenTargetsAgree pins the case +// the judgment settles before it reads Nullable: one branch admits null and +// the other does not, so the intersection forbids it. Neither reconciled nor +// preserved before, and the answer flipped with branch order — the same source +// compiled to nullable: true or false depending on which branch was written +// first. foldNullVerdicts already states the conjunction rule for the +// single-schema case; this is the same rule across a redeclaration. +func TestFold_NullabilityIntersectsWhenTargetsAgree(t *testing.T) { + t.Parallel() + reg := map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + } + cases := []struct { + name string + dstNull, srcNull, want bool + }{ + {"nullable then not", true, false, false}, + {"not then nullable", false, true, false}, + {"both nullable", true, true, true}, + {"neither nullable", false, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(reg) + dst := ir.Property{WireName: "x", Type: ir.TypeRef{Target: "t/str", Nullable: tc.dstNull}} + src := ir.Property{WireName: "x", Type: ir.TypeRef{Target: "t/str", Nullable: tc.srcNull}} + + dst = foldTwo(g, dst, src, unread(t)) + + assert.Equal(t, tc.want, dst.Type.Nullable, "the merged field admits null only where both branches do") + assert.Empty(t, *recorded, "an intersection the IR can express is not a conflict") + }) + } +} + +// TestFold_ADroppedTypeIsKeptEvenWhenItDoesNotConflict pins the +// gap between what the merge drops and what it calls a conflict. The judgment +// does not guess about two composites of one kind or an opaque scalar, but the +// fold keeps the winner's type regardless, so the redeclaration once vanished +// with neither a diagnostic nor an entry: #424's own failure, a consumer diffing +// two versions seeing no change. It is kept, and reported as a pair the merge +// does not compare, an info rather than a conflict: which type the field keeps +// then follows declaration order, and only the report says so. +func TestFold_ADroppedTypeIsKeptEvenWhenItDoesNotConflict(t *testing.T) { + t.Parallel() + reg := map[ir.TypeID]ir.TypeDef{ + "t/A": &ir.Model{ID: "t/A"}, + "t/B": &ir.Model{ID: "t/B"}, + "t/any": &ir.Any{ID: "t/any"}, + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + "t/opq": &ir.Scalar{ID: "t/opq"}, + } + cases := []struct { + name string + dstTarget, srcTarget ir.TypeID + }{ + {"two models of one kind", "t/A", "t/B"}, + {"an opaque scalar keeps the position", "t/opq", "t/str"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(reg) + dst := ir.Property{WireName: "id", Type: ir.TypeRef{Target: tc.dstTarget}} + src := ir.Property{ + WireName: "id", + Type: ir.TypeRef{Target: tc.srcTarget}, + Provenance: ir.Provenance{Source: 1, Pointer: "/p/allOf/1/properties/id"}, + } + + dst = foldTwo(g, dst, src, written(`{"type":"object"}`)) + + assert.Equal(t, tc.dstTarget, dst.Type.Target, "the first declaration still wins") + require.Len(t, *recorded, 1) + assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity, "a drop not called a conflict is no warning") + assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code) + assert.Contains(t, (*recorded)[0].Message, "that the merge does not compare") + entry, ok := dst.Unmodeled[losingDeclarationKey+"/p/allOf/1/properties/id"] + require.True(t, ok, "the dropped declaration is kept; got %v", dst.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + }) + } +} + +// TestFold_ADroppedTypeTakesNothingWithItEvenUnjudged is +// TestFold_AConflictingTypeTakesNothingWithIt for the drops +// the judgment declines to classify. The skip was keyed on the conflict +// diagnostic rather than on the drop, so two Models — never called a conflict — +// still folded the loser's default and constraints onto a winner whose type +// could not hold them, beside an entry saying that type was dropped: the +// contradiction the skip exists to prevent, on the one path it did not cover. +func TestFold_ADroppedTypeTakesNothingWithItEvenUnjudged(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/A": &ir.Model{ID: "t/A"}, + "t/B": &ir.Model{ID: "t/B"}, + }) + dst := ir.Property{WireName: "x", Type: ir.TypeRef{Target: "t/A"}} + src := ir.Property{ + WireName: "x", + Type: ir.TypeRef{Target: "t/B"}, + Default: &ir.Value{Kind: ir.ValueObject}, + Constraints: &ir.Constraints{MinProps: new(int64(1))}, + Examples: []ir.Example{{Value: &ir.Value{Kind: ir.ValueObject}}}, + Provenance: ptrAt("/allOf/1/properties/x"), + } + + dst = foldTwo(g, dst, src, written(`{"type":"object","default":{},"minProperties":1}`)) + + require.Len(t, *recorded, 1) + assert.Equal(t, diag.DegradedConstruct, (*recorded)[0].Code, "two models of one kind are still not called a conflict") + assert.Nil(t, dst.Default, "the default describes the shape that was dropped") + assert.Nil(t, dst.Constraints, "so does minProperties") + assert.Empty(t, dst.Examples, "and so do the examples") + assert.Contains(t, dst.Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", "the whole declaration is kept instead") +} + +// TestFold_TheJudgmentsVerdictIsWhatIsReported pins the three +// paths of the fold: a disjoint pair warns, an overlapping pair draws an info, +// and one target declared twice judges null alone and adopts a default. +func TestFold_TheJudgmentsVerdictIsWhatIsReported(t *testing.T) { + t.Parallel() + d := str("x") + for _, tc := range []struct { + name string + a, b ir.TypeID + dflt *ir.Value + severity ir.Severity + code string + keptEntry bool + }{ + {"a literal against a kind it is disjoint from", "t/lit-x", "t/prim/integer", nil, + ir.SeverityWarning, diag.ConflictingRedecl, true}, + {"lists whose elements overlap", "t/list-int", "t/list-i32-null", nil, + ir.SeverityInfo, diag.DegradedConstruct, true}, + {"one target declared twice", "t/prim/integer", "t/prim/integer", &d, "", "", false}, + } { + for i, order := range [][2]ir.TypeID{{tc.a, tc.b}, {tc.b, tc.a}} { + g, recorded := stubMerger(judgeRegistry()) + dst := ir.Property{WireName: "x", Type: ref(order[0]), Provenance: ptrAt("/allOf/0/properties/x")} + src := ir.Property{WireName: "x", Type: ref(order[1]), Default: tc.dflt, + Provenance: ptrAt("/allOf/1/properties/x")} + if i == 1 { + dst.Default, src.Default = src.Default, nil + } + + dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) + + if tc.keptEntry { + require.Len(t, *recorded, 1, "%s (order %d)", tc.name, i) + assert.Equal(t, tc.severity, (*recorded)[0].Severity, "%s (order %d)", tc.name, i) + assert.Equal(t, tc.code, (*recorded)[0].Code, "%s (order %d)", tc.name, i) + assert.Contains(t, dst.Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", + "%s (order %d)", tc.name, i) + continue + } + assert.Empty(t, *recorded, "%s (order %d)", tc.name, i) + assert.Equal(t, tc.dflt, dst.Default, "the default is adopted (%s, order %d)", tc.name, i) + assert.Empty(t, dst.Unmodeled, "%s (order %d)", tc.name, i) + } + } +} + +// reconcileBothOrders reconciles kinds a and b in both declaration orders and +// returns what each order recorded and merged. +func reconcileBothOrders(t *testing.T, a, b ir.PrimKind) (dsts [2]ir.Property, diags [2][]ir.Diagnostic) { + t.Helper() + for i, order := range [][2]ir.PrimKind{{a, b}, {b, a}} { + g, recorded := stubMerger(narrowingRegistry()) + dst := ir.Property{WireName: "x", Type: primRef(order[0]), Provenance: ptrAt("/allOf/0/properties/x")} + src := ir.Property{WireName: "x", Type: primRef(order[1]), Provenance: ptrAt("/allOf/1/properties/x")} + + dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) + + dsts[i], diags[i] = dst, *recorded + } + return dsts, diags +} + +// TestFold_OverlappingKindsAreKeptAndReportedAsInfo pins that +// kinds sharing values with neither holding the other keep the first +// declaration's kind, draw an info, and keep the redeclaration, in both orders. +func TestFold_OverlappingKindsAreKeptAndReportedAsInfo(t *testing.T) { + t.Parallel() + cases := []struct { + name string + a, b ir.PrimKind + }{ + {"int32 and uint32", ir.PrimInt32, ir.PrimUint32}, + {"float32 and integer", ir.PrimFloat32, ir.PrimInteger}, + {"int64 and float64", ir.PrimInt64, ir.PrimFloat64}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dsts, diags := reconcileBothOrders(t, tc.a, tc.b) + for i, first := range []ir.PrimKind{tc.a, tc.b} { + assert.Equal(t, primRef(first).Target, dsts[i].Type.Target, "the first declaration's kind stays (order %d)", i) + require.Len(t, diags[i], 1, "exactly one diagnostic (order %d)", i) + assert.Equal(t, diag.DegradedConstruct, diags[i][0].Code, "order %d", i) + assert.Equal(t, ir.SeverityInfo, diags[i][0].Severity, "order %d", i) + assert.Contains(t, dsts[i].Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", + "the redeclaration is kept (order %d)", i) + } + }) + } +} + +// TestFold_DisjointKindsStillWarn pins that kinds no value shares +// keep the conflicting-redeclaration warning, in both orders. +func TestFold_DisjointKindsStillWarn(t *testing.T) { + t.Parallel() + cases := []struct { + name string + a, b ir.PrimKind + }{ + {"string and integer", ir.PrimString, ir.PrimInteger}, + {"bool and number", ir.PrimBool, ir.PrimNumber}, + {"uuid and date-time", ir.PrimUUID, ir.PrimDatetimeOffset}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dsts, diags := reconcileBothOrders(t, tc.a, tc.b) + for i, first := range []ir.PrimKind{tc.a, tc.b} { + assert.Equal(t, primRef(first).Target, dsts[i].Type.Target, "the first declaration still wins (order %d)", i) + require.Len(t, diags[i], 1, "exactly one diagnostic (order %d)", i) + assert.Equal(t, diag.ConflictingRedecl, diags[i][0].Code, "order %d", i) + assert.Equal(t, ir.SeverityWarning, diags[i][0].Severity, "order %d", i) + assert.Contains(t, dsts[i].Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", "order %d", i) + } + }) + } +} + +// enumRegistry adds two integer enums and two string enums to narrowingRegistry, +// each with a member the pairs below judge, and the uuid and bytes Scalar nodes +// that stand for a kind the same way a bare reference does. +func enumRegistry() map[ir.TypeID]ir.TypeDef { + reg := narrowingRegistry() + reg["t/enum/a"] = &ir.Enum{ID: "t/enum/a", ValueType: ir.PrimString, Members: []ir.EnumMember{{Value: str("a")}}} + reg["t/enum/b"] = &ir.Enum{ID: "t/enum/b", ValueType: ir.PrimString, Members: []ir.EnumMember{{Value: str("b")}}} + reg["t/enum/big"] = &ir.Enum{ID: "t/enum/big", ValueType: ir.PrimInteger, Members: []ir.EnumMember{{Value: num("5000000000")}}} + reg["t/enum/small"] = &ir.Enum{ID: "t/enum/small", ValueType: ir.PrimInteger, Members: []ir.EnumMember{{Value: num("1")}}} + uuid, bytes := primRef(ir.PrimUUID), primRef(ir.PrimBytes) + reg["t/scalar/uuid"] = &ir.Scalar{ID: "t/scalar/uuid", Base: &uuid} + reg["t/scalar/bytes"] = &ir.Scalar{ID: "t/scalar/bytes", Base: &bytes} + return reg +} + +// TestFold_EnumPairsAreJudgedByKind pins, in both orders, what an +// enum side draws: an enum over a bare kind or a Scalar node it equals or +// narrows wins; a bare kind or a Scalar node that admits one of its members +// overlaps it, which is info and keeps the first declaration, as does a kind +// with no judge; a kind that admits none of them warns, as does another +// family; and two enums of one kind, each with members, overlap. +func TestFold_EnumPairsAreJudgedByKind(t *testing.T) { + t.Parallel() + enum := func(id ir.TypeID) ir.TypeRef { return ir.TypeRef{Target: id} } + cases := []struct { + name string + a, b ir.TypeRef + want ir.Severity // "" = none + winner ir.TypeID // "" = the first declaration's + }{ + {"enum of string and uuid", enum("t/enum/a"), primRef(ir.PrimUUID), ir.SeverityWarning, ""}, + {"enum of integer and int32", enum("t/enum/big"), primRef(ir.PrimInt32), ir.SeverityWarning, ""}, + {"enum admitted by int32", enum("t/enum/small"), primRef(ir.PrimInt32), ir.SeverityInfo, ""}, + {"enum of string and bytes", enum("t/enum/a"), primRef(ir.PrimBytes), ir.SeverityInfo, ""}, + {"enum of string and a uuid scalar", enum("t/enum/a"), enum("t/scalar/uuid"), ir.SeverityWarning, ""}, + {"enum of string and a bytes scalar", enum("t/enum/a"), enum("t/scalar/bytes"), ir.SeverityInfo, ""}, + {"enum of string and integer", enum("t/enum/a"), primRef(ir.PrimInteger), ir.SeverityWarning, ""}, + {"enum of string and another enum", enum("t/enum/a"), enum("t/enum/b"), ir.SeverityInfo, ""}, + {"enum of string and bare string", enum("t/enum/a"), primRef(ir.PrimString), "", "t/enum/a"}, + {"enum of integer and number", enum("t/enum/big"), primRef(ir.PrimNumber), "", "t/enum/big"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for i, order := range [][2]ir.TypeRef{{tc.a, tc.b}, {tc.b, tc.a}} { + g, recorded := stubMerger(enumRegistry()) + dst := ir.Property{WireName: "x", Type: order[0], Provenance: ptrAt("/allOf/0/properties/x")} + src := ir.Property{WireName: "x", Type: order[1], Provenance: ptrAt("/allOf/1/properties/x")} + + dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) + + wantTarget := order[0].Target + if tc.winner != "" { + wantTarget = tc.winner + } + assert.Equal(t, wantTarget, dst.Type.Target, "the winning target (order %d)", i) + if tc.want == "" { + assert.Empty(t, *recorded, "no diagnostic (order %d)", i) + continue + } + require.Len(t, *recorded, 1, "exactly one diagnostic (order %d)", i) + assert.Equal(t, tc.want, (*recorded)[0].Severity, "order %d", i) + assert.Contains(t, dst.Unmodeled, losingDeclarationKey+"/allOf/1/properties/x", + "the redeclaration is kept (order %d)", i) + } + }) + } +} + +// twinRegistry holds, for each of Enum, Literal and Scalar, two hoisted nodes +// that differ only in ID and member IDs, and the variants that differ in a +// field. t/enum/x-named and t/enum/x-named2 are named copies of t/enum/x1. +func twinRegistry() map[ir.TypeID]ir.TypeDef { + reg := narrowingRegistry() + members := func(prefix string, vs ...string) []ir.EnumMember { + out := make([]ir.EnumMember, 0, len(vs)) + for _, v := range vs { + out = append(out, ir.EnumMember{ID: ir.EnumMemberID(prefix + v), Value: str(v)}) + } + return out + } + enum := func(id ir.TypeID, ms []ir.EnumMember, desc string) *ir.Enum { + e := &ir.Enum{ID: id, ValueType: ir.PrimString, Members: ms} + e.Docs.Description = desc + return e + } + reg["t/enum/x1"] = enum("t/enum/x1", members("x1/", "a", "b"), "") + reg["t/enum/x2"] = enum("t/enum/x2", members("x2/", "a", "b"), "") + reg["t/enum/x-described"] = enum("t/enum/x-described", members("xd/", "a", "b"), "some") + reg["t/enum/x-reordered"] = enum("t/enum/x-reordered", members("xr/", "b", "a"), "") + for _, id := range []ir.TypeID{"t/enum/x-ext1", "t/enum/x-ext2"} { + e := enum(id, members(string(id)+"/", "a", "b"), "") + e.Unmodeled = ir.Unmodeled{"openapi:x-foo": { + Reason: ir.ReasonVendorExtension, + Value: ir.RawValue("1"), + Provenance: ptrAt(jsontext.Pointer("/at/" + string(id) + "/x-foo")), + }} + reg[id] = e + } + valued := enum("t/enum/x-ext-valued", members("xv/", "a", "b"), "") + valued.Unmodeled = ir.Unmodeled{"openapi:x-foo": { + Reason: ir.ReasonVendorExtension, + Value: ir.RawValue("2"), + Provenance: ptrAt("/at/t/enum/x-ext1/x-foo"), + }} + reg["t/enum/x-ext-valued"] = valued + reg["t/lit/1"] = &ir.Literal{ID: "t/lit/1", Value: str("a")} + reg["t/lit/2"] = &ir.Literal{ID: "t/lit/2", Value: str("a")} + str := primRef(ir.PrimString) + minLen := int64(2) + reg["t/hex/1"] = &ir.Scalar{ID: "t/hex/1", Base: &str} + reg["t/hex/2"] = &ir.Scalar{ID: "t/hex/2", Base: &str} + reg["t/hex/min"] = &ir.Scalar{ID: "t/hex/min", Base: &str, Constraints: &ir.Constraints{MinLength: &minLen}} + for _, td := range reg { + switch td.(type) { + case *ir.Enum, *ir.Literal, *ir.Scalar: + td.Common().Anonymous = true + } + } + reg["t/enum/x-named"] = enum("t/enum/x-named", members("xn/", "a", "b"), "") + reg["t/enum/x-named2"] = enum("t/enum/x-named2", members("xn2/", "a", "b"), "") + return reg +} + +// TestFold_IdenticalNodesAreOneType pins, in both orders, that two +// hoisted Enum, Literal or Scalar nodes equal but for what names their site are +// one type: no diagnostic and nothing kept aside. A named node wins over a +// hoisted copy of itself, and two named ones, or a pair that differs in a +// field, member order included, are an overlap reported as info. +func TestFold_IdenticalNodesAreOneType(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + a, b ir.TypeID + same bool + win ir.TypeID // the type that stands in both orders, or "" for the first + }{ + {"two identical enums", "t/enum/x1", "t/enum/x2", true, ""}, + {"two identical literals", "t/lit/1", "t/lit/2", true, ""}, + {"two identical scalars", "t/hex/1", "t/hex/2", true, ""}, + {"enums that differ in the provenance of an extension", "t/enum/x-ext1", "t/enum/x-ext2", true, ""}, + {"a named enum against a hoisted copy", "t/enum/x-named", "t/enum/x1", true, "t/enum/x-named"}, + {"two named enums declared alike", "t/enum/x-named", "t/enum/x-named2", false, ""}, + {"enums that differ in an extension's value", "t/enum/x-ext1", "t/enum/x-ext-valued", false, ""}, + {"enums that differ in a description", "t/enum/x1", "t/enum/x-described", false, ""}, + {"enums that differ in member order", "t/enum/x1", "t/enum/x-reordered", false, ""}, + {"scalars that differ in a constraint", "t/hex/1", "t/hex/min", false, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for i, order := range [][2]ir.TypeID{{tc.a, tc.b}, {tc.b, tc.a}} { + reg := twinRegistry() + g, recorded := stubMerger(reg) + dst := ir.Property{WireName: "x", Type: ir.TypeRef{Target: order[0]}, Provenance: ptrAt("/allOf/0/properties/x")} + src := ir.Property{WireName: "x", Type: ir.TypeRef{Target: order[1]}, Provenance: ptrAt("/allOf/1/properties/x")} + for _, p := range []ir.Property{dst, src} { + if c := reg[p.Type.Target].Common(); c.Anonymous { + c.Provenance = p.Provenance + } + } + + dst = foldTwo(g, dst, src, written(`{"type":"x"}`)) + + want := tc.win + if want == "" { + want = order[0] + } + assert.Equal(t, want, dst.Type.Target, "the type that stands (order %d)", i) + if tc.same { + assert.Empty(t, *recorded, "no diagnostic (order %d)", i) + assert.Empty(t, dst.Unmodeled, "nothing is kept (order %d)", i) + continue + } + require.Len(t, *recorded, 1, "exactly one diagnostic (order %d)", i) + assert.Equal(t, ir.SeverityInfo, (*recorded)[0].Severity, "order %d", i) + assert.Len(t, dst.Unmodeled, 1, "the redeclaration is kept (order %d)", i) + } + }) + } +} diff --git a/compilers/openapi/internal/merge/fold_visibility_internal_test.go b/compilers/openapi/internal/merge/fold_visibility_internal_test.go new file mode 100644 index 00000000..0d54f255 --- /dev/null +++ b/compilers/openapi/internal/merge/fold_visibility_internal_test.go @@ -0,0 +1,77 @@ +package merge + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// TestFold_VisibilityAdoptedFromRedeclaration pins the issue's +// own reproduction at the reconcile layer (GitHub #34): a property left +// unrestricted by its first declaration must adopt a later branch's readOnly +// rather than silently keep the first, empty Visibility. +func TestFold_VisibilityAdoptedFromRedeclaration(t *testing.T) { + t.Parallel() + g, _ := stubMerger(nil) + dst := ir.Property{WireName: "id"} + + dst = foldTwo(g, dst, ir.Property{WireName: "id", Visibility: readOnlyVisibility, Provenance: ptrAt("/other")}, unread(t)) + + assert.Equal(t, readOnlyVisibility, dst.Visibility) +} + +// TestFold_DisjointVisibilityIsARestrictionNotAConflict pins both +// halves of the choice for an allOf pairing that admits no lifecycle at all +// (readOnly against writeOnly on the same field). mergeVisibility represents it +// exactly as None, so it is recorded rather than dropped and kept — nothing is +// arbitrarily discarded, as it would be for an incompatible-type +// redeclaration. But exact is not unremarkable: a +// field neither a request nor a response can carry is a composition that cannot +// take effect, so it is warned about under its own code. +func TestFold_DisjointVisibilityIsARestrictionNotAConflict(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + dst := ir.Property{WireName: "id", Visibility: readOnlyVisibility} + + dst = foldTwo(g, dst, ir.Property{WireName: "id", Visibility: writeOnlyVisibility, Provenance: ptrAt("/other")}, unread(t)) + + assert.Equal(t, ir.Visibility{None: true}, dst.Visibility) + require.Len(t, *recorded, 1, "an emptied visibility set is reported, not passed over in silence") + assert.Equal(t, diag.DisjointVisibility, (*recorded)[0].Code, + "reported under its own code, not as a conflicting redeclaration") + assert.Equal(t, ir.SeverityWarning, (*recorded)[0].Severity) +} + +// TestFold_AlreadyInvisibleVisibilityIsNotReAnnounced pins the half +// of the report guard the disjoint case above cannot reach. The warning is about +// the transition — two restricted branches whose intersection empties — not +// about the state of being invisible, so a side that was already None merges +// quietly. Drop that half of the condition and every later redeclaration of an +// invisible field re-announces a fact the document has already been told, which +// no assertion in the disjoint case would notice. +func TestFold_AlreadyInvisibleVisibilityIsNotReAnnounced(t *testing.T) { + t.Parallel() + tests := []struct { + name string + dst, src ir.Visibility + }{ + {name: "first declaration was already invisible", dst: ir.Visibility{None: true}, src: readOnlyVisibility}, + {name: "redeclaration is already invisible", dst: readOnlyVisibility, src: ir.Visibility{None: true}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + dst := ir.Property{WireName: "id", Visibility: tc.dst} + + dst = foldTwo(g, dst, ir.Property{WireName: "id", Visibility: tc.src, Provenance: ptrAt("/other")}, unread(t)) + + assert.Equal(t, ir.Visibility{None: true}, dst.Visibility) + assert.Empty(t, *recorded, "None was already the answer; this merge announced nothing new") + }) + } +} diff --git a/compilers/openapi/internal/merge/helpers_internal_test.go b/compilers/openapi/internal/merge/helpers_internal_test.go new file mode 100644 index 00000000..19155827 --- /dev/null +++ b/compilers/openapi/internal/merge/helpers_internal_test.go @@ -0,0 +1,13 @@ +package merge + +import ( + "github.com/dexpace/morphic/ir" +) + +// readOnlyVisibility and writeOnlyVisibility are the two non-empty shapes +// EffectiveVisibility ever produces, reused across the visibility-merge +// cases rather than re-spelling the lifecycle lists in each one. +var ( + readOnlyVisibility = ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleDelete, ir.LifecycleQuery}} + writeOnlyVisibility = ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleCreate, ir.LifecycleUpdate}} +) diff --git a/compilers/openapi/internal/operation/helpers_test.go b/compilers/openapi/internal/operation/helpers_test.go index e981aceb..faaff087 100644 --- a/compilers/openapi/internal/operation/helpers_test.go +++ b/compilers/openapi/internal/operation/helpers_test.go @@ -128,3 +128,14 @@ paths: examples: empty: {summary: no value here} ` + +// paramBySource returns the operation's logical parameter with the given source +// name. +func paramBySource(op ir.Operation, name string) (ir.Parameter, bool) { + for _, p := range op.Params { + if p.Name.Source == name { + return p, true + } + } + return ir.Parameter{}, false +} diff --git a/compilers/openapi/internal/operation/params_identity_test.go b/compilers/openapi/internal/operation/params_identity_test.go new file mode 100644 index 00000000..3a81642e --- /dev/null +++ b/compilers/openapi/internal/operation/params_identity_test.go @@ -0,0 +1,565 @@ +package operation_test + +import ( + "encoding/json/jsontext" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/engine/reader" + "github.com/dexpace/morphic/ir" +) + +// paramIdentitySpec mounts one path item twice and lists a path-level and an +// operation-level parameter named id. The shared item is declared after its two +// uses, and /b before /a, so a derivation that depended on order would show. +const paramIdentitySpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /b: + $ref: "#/components/pathItems/Shared" + /a: + $ref: "#/components/pathItems/Shared" + /items/{id}: + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + get: + operationId: getItem + parameters: + - {name: id, in: query, schema: {type: string}} + responses: {"200": {description: ok}} + post: + operationId: postItem + responses: {"200": {description: ok}} +components: + pathItems: + Shared: + parameters: + - {name: limit, in: query, schema: {type: integer}} + get: + responses: {"200": {description: ok}} +` + +// paramIDs returns the IDs of op's parameters, keyed by the location each one +// binds. +func paramIDs(op ir.Operation) map[ir.HTTPLocation]ir.ParamID { + out := map[ir.HTTPLocation]ir.ParamID{} + for _, pb := range op.Bindings.HTTP[0].ParamBindings { + out[pb.Location] = pb.Param + } + return out +} + +func TestParams_SameNameInTwoLocationsGetsDistinctIDs(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, paramIdentitySpec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FindOp(t, doc, "getItem") + + require.Len(t, op.Params, 2) + assert.NotEqual(t, op.Params[0].ID, op.Params[1].ID, "id in the path and id in the query are two parameters") + ids := paramIDs(op) + assert.Equal(t, ir.ParamID("param/openapi/paths/~1items~1{id}/get/parameters/id/path"), ids[ir.HTTPLocationPath]) + assert.Equal(t, ir.ParamID("param/openapi/paths/~1items~1{id}/get/parameters/id/query"), ids[ir.HTTPLocationQuery]) + assert.ElementsMatch(t, []ir.ParamID{op.Params[0].ID, op.Params[1].ID}, []ir.ParamID{ids[ir.HTTPLocationPath], ids[ir.HTTPLocationQuery]}, + "each binding names the ID of its own parameter") +} + +func TestParams_IDsFollowNameNotPosition(t *testing.T) { + t.Parallel() + byName := func(first, second string) map[string]ir.ParamID { + doc, diags := parseFull(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /items: + get: + operationId: getItem + parameters: + - {name: `+first+`, in: query, schema: {type: string}} + - {name: `+second+`, in: query, schema: {type: string}} + responses: {"200": {description: ok}} +`) + openapitest.RequireNoErrorDiags(t, diags) + out := map[string]ir.ParamID{} + for _, p := range openapitest.FindOp(t, doc, "getItem").Params { + out[p.Name.Source] = p.ID + } + return out + } + ba, ab := byName("b", "a"), byName("a", "b") + assert.Equal(t, ab, ba, "reordering a parameter list renames nothing") + assert.Equal(t, ir.ParamID("param/openapi/paths/~1items/get/parameters/a/query"), ba["a"]) +} + +func TestParams_PathItemParamIsScopedPerOperation(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, paramIdentitySpec) + openapitest.RequireNoErrorDiags(t, diags) + get, post := openapitest.FindOp(t, doc, "getItem"), openapitest.FindOp(t, doc, "postItem") + + require.Len(t, post.Params, 1) + assert.Equal(t, ir.ParamID("param/openapi/paths/~1items~1{id}/post/parameters/id/path"), post.Params[0].ID) + assert.NotContains(t, []ir.ParamID{get.Params[0].ID, get.Params[1].ID}, post.Params[0].ID, + "a path-item parameter copied into two operations is two parameters") + + var shared []ir.ParamID + for _, g := range doc.Services[0].Groups { + for _, o := range g.Operations { + if len(o.Params) == 1 && o.Params[0].Name.Source == "limit" { + shared = append(shared, o.Params[0].ID) + } + } + } + assert.ElementsMatch(t, []ir.ParamID{ + "param/openapi/paths/~1a/get/parameters/limit/query", + "param/openapi/paths/~1b/get/parameters/limit/query", + }, shared, "a path item mounted twice roots each mount's parameters at that mount") +} + +// repeatSpec returns a document whose one operation, GET /x, merges pathLevel +// and opLevel, written as YAML flow entries. Limit and Limit2 are two +// components holding the same name and location. +func repeatSpec(pathLevel, opLevel []string) string { + list := func(indent string, entries []string) string { + if len(entries) == 0 { + return "" + } + out := indent + "parameters:\n" + for _, e := range entries { + out += indent + " - " + e + "\n" + } + return out + } + return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n /x:\n" + + list(" ", pathLevel) + + " get:\n operationId: g\n" + + list(" ", opLevel) + + " responses: {\"200\": {description: ok}}\n" + + "components:\n parameters:\n" + + " Limit: {name: limit, in: query, schema: {type: integer}}\n" + + " Limit2: {name: limit, in: query, schema: {type: string}}\n" +} + +// TestParams_RepeatedNameAndLocationKeepsEveryParameter covers a merged list +// holding one name and location twice. OpenAPI forbids it, but the bundled +// parser checks only entries written inline, so a $ref'd repeat reaches the +// lowering. Each occurrence is kept under an ID of its own, the first holding +// the bare one, and the repeat is reported at its own list entry. +func TestParams_RepeatedNameAndLocationKeepsEveryParameter(t *testing.T) { + t.Parallel() + const ( + inline = "{name: limit, in: query, schema: {type: boolean}}" + limit = `{$ref: "#/components/parameters/Limit"}` + limit2 = `{$ref: "#/components/parameters/Limit2"}` + ) + tests := []struct { + name string + pathLevel, opLevel []string + first, repeat string + }{ + {name: "one component twice", opLevel: []string{limit, limit}, + first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, + {name: "a component, then an inline entry", opLevel: []string{limit, inline}, + first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, + {name: "an inline entry, then a component", opLevel: []string{inline, limit}, + first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, + {name: "two components holding one slot", opLevel: []string{limit, limit2}, + first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, + {name: "two inline entries", opLevel: []string{inline, inline}, + first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, + {name: "a pair on the path item", pathLevel: []string{limit, limit2}, + first: "/paths/~1x/parameters/0", repeat: "/paths/~1x/parameters/1"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, repeatSpec(tc.pathLevel, tc.opLevel)) + op := openapitest.FindOp(t, doc, "g") + + require.Len(t, op.Params, 2, "neither occurrence is dropped") + assert.Equal(t, ir.ParamID("param/openapi/paths/~1x/get/parameters/limit/query"), op.Params[0].ID, + "the first in list order holds the bare ID") + assert.Equal(t, ir.ParamID("param/openapi/paths/~1x/get/parameters/limit/query/2"), op.Params[1].ID) + bindings := op.Bindings.HTTP[0].ParamBindings + require.Len(t, bindings, 2) + assert.Equal(t, []ir.ParamID{op.Params[0].ID, op.Params[1].ID}, + []ir.ParamID{bindings[0].Param, bindings[1].Param}, "each binding names its own parameter") + + assert.Equal(t, []string{diag.DuplicateParameter + " at " + tc.repeat}, errorFindings(diags), + "the repeat is one error, whatever spells its entries") + msg := openapitest.DiagMessageAt(t, diags, diag.DuplicateParameter, ir.SeverityError, tc.repeat) + assert.Contains(t, msg, tc.first, "the report names the entry the repeat collides with") + }) + } +} + +// TestParams_RepeatsAreNumberedInListOrder holds the occurrence number to the +// entry's place among the parameters holding its slot, and each report to the +// first of them, not to the occurrence just before. +func TestParams_RepeatsAreNumberedInListOrder(t *testing.T) { + t.Parallel() + const limit = `{$ref: "#/components/parameters/Limit"}` + doc, diags := parseFull(t, repeatSpec(nil, []string{ + limit, "{name: id, in: query, schema: {type: string}}", limit, limit, + })) + op := openapitest.FindOp(t, doc, "g") + + const base = "param/openapi/paths/~1x/get/parameters/limit/query" + got := make([]ir.ParamID, 0, len(op.Params)) + for _, p := range op.Params { + got = append(got, p.ID) + } + assert.Equal(t, []ir.ParamID{base, "param/openapi/paths/~1x/get/parameters/id/query", base + "/2", base + "/3"}, got) + for _, repeat := range []string{"/paths/~1x/get/parameters/2", "/paths/~1x/get/parameters/3"} { + msg := openapitest.DiagMessageAt(t, diags, diag.DuplicateParameter, ir.SeverityError, repeat) + assert.Contains(t, msg, "/paths/~1x/get/parameters/0") + } + assert.Equal(t, 2, openapitest.CountDiagsAt(diags, diag.DuplicateParameter, ir.SeverityError)) +} + +// TestParams_RepeatOnAPathItemMountedTwiceIsReportedOnce covers a repeat inside +// a path item two paths $ref. Each mount's operation numbers its own copy, but +// the defect is one written list entry, so it is one report. +func TestParams_RepeatOnAPathItemMountedTwiceIsReportedOnce(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: {$ref: "#/components/pathItems/Shared"} + /b: {$ref: "#/components/pathItems/Shared"} +components: + parameters: + Limit: {name: limit, in: query, schema: {type: integer}} + pathItems: + Shared: + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Limit" + get: + responses: {"200": {description: ok}} +`) + var got []ir.ParamID + for _, g := range doc.Services[0].Groups { + for _, o := range g.Operations { + for _, p := range o.Params { + got = append(got, p.ID) + } + } + } + assert.ElementsMatch(t, []ir.ParamID{ + "param/openapi/paths/~1a/get/parameters/limit/query", + "param/openapi/paths/~1a/get/parameters/limit/query/2", + "param/openapi/paths/~1b/get/parameters/limit/query", + "param/openapi/paths/~1b/get/parameters/limit/query/2", + }, got) + assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateParameter, ir.SeverityError)) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DuplicateParameter, "/components/pathItems/Shared/parameters/1")) +} + +// TestParams_RepeatInAnotherDocumentIsReportedOnce covers the two places a +// repeat is reported, each at the entry where its list is written. A list the +// source writes is load's, even when its entries $ref another document; a list +// another document writes, which load's walk of the source does not reach, is +// the lowering's, at the entry it lowered, in that document. Each repeat is +// reported once. +func TestParams_RepeatInAnotherDocumentIsReportedOnce(t *testing.T) { + t.Parallel() + dir := t.TempDir() + const other = `X: + parameters: + - {name: limit, in: query, schema: {type: integer}} + - {name: limit, in: query, schema: {type: string}} + get: + parameters: + - {name: q, in: query, schema: {type: string}} + - {name: q, in: header, schema: {type: string}} + - {name: q, in: query, schema: {type: string}} + responses: {"200": {description: ok}} +Limit: {name: limit, in: query, schema: {type: integer}} +` + const root = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: {$ref: "./other.yaml#/X"} + /y: + get: + parameters: + - $ref: "./other.yaml#/Limit" + - $ref: "./other.yaml#/Limit" + responses: {"200": {description: ok}} +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(other), 0o600)) + path := filepath.Join(dir, "root.yaml") + require.NoError(t, os.WriteFile(path, []byte(root), 0o600)) + + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: path, Data: []byte(root)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + + require.Len(t, doc.Sources, 2) + assert.Equal(t, filepath.Join(dir, "other.yaml"), doc.Sources[1].Path) + repeat := func(source int, pointer jsontext.Pointer, name, in string, first jsontext.Pointer) ir.Diagnostic { + return ir.Diagnostic{Severity: ir.SeverityError, Code: diag.DuplicateParameter, + Message: fmt.Sprintf("parameter %q in %q repeats the entry at %s", name, in, first), + Provenance: ir.Provenance{Source: source, Pointer: pointer}} + } + var got []ir.Diagnostic + for _, d := range diags { + if d.Code == diag.DuplicateParameter { + got = append(got, d) + } + } + slices.SortFunc(got, func(x, y ir.Diagnostic) int { + return strings.Compare(string(x.Provenance.Pointer), string(y.Provenance.Pointer)) + }) + want := []ir.Diagnostic{ + repeat(1, "/X/get/parameters/2", "q", "query", "/X/get/parameters/0"), + repeat(1, "/X/parameters/1", "limit", "query", "/X/parameters/0"), + repeat(0, "/paths/~1y/get/parameters/1", "limit", "query", "/paths/~1y/get/parameters/0"), + } + assert.Empty(t, cmp.Diff(want, got)) +} + +// TestParameters_PathItemSharedAcrossOperationsInternsOnce is the fix's core +// scenario (issue #36): a path-item-level parameter merged into two sibling +// operations must intern its schema once, at the path item's own pointer — +// not once per operation under a pointer fabricated from that operation's +// unrelated parameter count. +func TestParameters_PathItemSharedAcrossOperationsInternsOnce(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /pets/{petId}: + parameters: + - name: petId + in: path + required: true + schema: {type: string, enum: [a, b]} + get: + operationId: getPet + responses: {"200": {description: ok}} + delete: + operationId: deletePet + parameters: + - {name: force, in: query, schema: {type: boolean}} + responses: {"200": {description: ok}} +`) + doc, _, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + getPet := openapitest.FindOp(t, doc, "getPet") + deletePet := openapitest.FindOp(t, doc, "deletePet") + require.Len(t, getPet.Params, 1, "get inherits only the shared path-item parameter") + require.Len(t, deletePet.Params, 2, "delete gets its own force plus the shared path-item parameter") + + wantID := ir.TypeID("t/anon/paths/~1pets~1{petId}/parameters/0/schema") + assert.Equal(t, wantID, getPet.Params[0].Type.Target, "get resolves the shared path-item schema") + + byName := openapitest.IndexBy(deletePet.Params, func(p ir.Parameter) string { return p.Name.Source }) + assert.Equal(t, wantID, byName["petId"].Type.Target, "delete resolves the same shared schema, not a copy") + + typeDef, ok := doc.Types[wantID] + require.True(t, ok, "the shared schema is registered under the path item's own pointer") + assert.Equal(t, jsontext.Pointer("/paths/~1pets~1{petId}/parameters/0/schema"), typeDef.Common().Provenance.Pointer) + + _, fabricatedGet := doc.Types[ir.TypeID("t/anon/paths/~1pets~1{petId}/get/parameters/0/schema")] + _, fabricatedDelete := doc.Types[ir.TypeID("t/anon/paths/~1pets~1{petId}/delete/parameters/1/schema")] + assert.False(t, fabricatedGet, "no fabricated per-operation ID for get") + assert.False(t, fabricatedDelete, "no fabricated per-operation ID for delete") +} + +// TestParameters_PathItemSchemaIDStableAcrossOperationParamChanges guards the +// specific regression from issue #36: adding an unrelated operation-level +// parameter must not shift the TypeID already handed out for a sibling +// path-item-level parameter's schema. +func TestParameters_PathItemSchemaIDStableAcrossOperationParamChanges(t *testing.T) { + t.Parallel() + before := openapitest.PathsSpec(` /pets/{petId}: + parameters: + - name: petId + in: path + required: true + schema: {type: string, enum: [a, b]} + get: + operationId: getPet + responses: {"200": {description: ok}} +`) + after := openapitest.PathsSpec(` /pets/{petId}: + parameters: + - name: petId + in: path + required: true + schema: {type: string, enum: [a, b]} + get: + operationId: getPet + parameters: + - {name: verbose, in: query, schema: {type: boolean}} + responses: {"200": {description: ok}} +`) + _, svcBefore, diagsBefore := compileService(t, before) + openapitest.RequireNoErrorDiags(t, diagsBefore) + _, svcAfter, diagsAfter := compileService(t, after) + openapitest.RequireNoErrorDiags(t, diagsAfter) + + opBefore := openapitest.FirstOp(t, svcBefore) + opAfter := openapitest.FirstOp(t, svcAfter) + require.Len(t, opBefore.Params, 1) + require.Len(t, opAfter.Params, 2, "the unrelated verbose parameter adds a second entry") + + byName := openapitest.IndexBy(opAfter.Params, func(p ir.Parameter) string { return p.Name.Source }) + assert.Equal(t, opBefore.Params[0].Type.Target, byName["petId"].Type.Target, + "an unrelated operation-level parameter must not shift the shared path-item schema's ID") +} + +// TestParameters_WebhookPathItemParameterPointer covers the same merge through +// lowerWebhooks: a webhook path-item-level parameter must hoist at the webhook +// path item's own pointer. +func TestParameters_WebhookPathItemParameterPointer(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +webhooks: + petEvent: + parameters: + - name: kind + in: query + schema: {type: string, enum: [created, deleted]} + post: + operationId: onPetEvent + responses: {"200": {description: ok}} +` + doc, _, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FindOp(t, doc, "onPetEvent") + require.Len(t, op.Params, 1) + + wantID := ir.TypeID("t/anon/webhooks/petEvent/parameters/0/schema") + assert.Equal(t, wantID, op.Params[0].Type.Target) + typeDef, ok := doc.Types[wantID] + require.True(t, ok) + assert.Equal(t, jsontext.Pointer("/webhooks/petEvent/parameters/0/schema"), typeDef.Common().Provenance.Pointer) +} + +// TestParameters_CallbackPathItemParameterPointer covers the same merge +// through lowerCallbackOps: a callback path-item-level parameter must hoist at +// the callback path item's own pointer, nested under the parent operation and +// callback expression. +func TestParameters_CallbackPathItemParameterPointer(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /subscribe: + post: + operationId: subscribe + callbacks: + onEvent: + '{$request.body#/callbackUrl}': + parameters: + - name: token + in: query + schema: {type: string, enum: [a, b]} + post: + operationId: onEvent + responses: {"200": {description: ok}} + responses: {"200": {description: ok}} +`) + doc, _, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + cbOp := openapitest.FindOp(t, doc, "onEvent") + require.Len(t, cbOp.Params, 1) + + wantPointer := "/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1callbackUrl}/parameters/0/schema" + wantID := ir.TypeID("t/anon" + wantPointer) + assert.Equal(t, wantID, cbOp.Params[0].Type.Target) + typeDef, ok := doc.Types[wantID] + require.True(t, ok) + assert.Equal(t, jsontext.Pointer(wantPointer), typeDef.Common().Provenance.Pointer) +} + +// TestParameters_ShadowedPathItemParamUsesOperationPointer covers the other +// side of the merge: when the operation redeclares a path-item parameter, the +// operation's own schema must be used and lowered at the operation's own +// pointer — the shadowed path-item schema is never lowered at all. +func TestParameters_ShadowedPathItemParamUsesOperationPointer(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /pets/{petId}: + parameters: + - name: petId + in: path + required: true + schema: {type: string, enum: [a, b]} + get: + operationId: getPet + parameters: + - name: petId + in: path + required: true + schema: {type: string, enum: [c, d]} + responses: {"200": {description: ok}} +`) + doc, svc, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.Len(t, op.Params, 1, "the operation parameter shadows the path-item one; no duplicate") + + wantID := ir.TypeID("t/anon/paths/~1pets~1{petId}/get/parameters/0/schema") + assert.Equal(t, wantID, op.Params[0].Type.Target, "the operation's own schema wins, at the operation's pointer") + + enum, ok := doc.Types[wantID].(*ir.Enum) + require.True(t, ok) + require.Len(t, enum.Members, 2) + assert.Equal(t, "c", enum.Members[0].Value.Str, "the operation-level schema's own values are used") + + _, atPathLevel := doc.Types[ir.TypeID("t/anon/paths/~1pets~1{petId}/parameters/0/schema")] + assert.False(t, atPathLevel, "the shadowed path-item schema is never lowered") +} + +// TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder is the hand-rolled two-order +// diff for GitHub #610. A use-site summary or description is neither part of the +// type registry nor a diagnostic, so the in-package order-invariance oracle does +// not reach it. One component referenced by two operations with different +// overrides, declared in both orders, must give each mount its own docs. +func TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder(t *testing.T) { + t.Parallel() + const first = ` /a: + get: + operationId: a + parameters: [{$ref: '#/components/parameters/Limit', description: from a}] + responses: {"200": {description: ok}} +` + const second = ` /b: + get: + operationId: b + parameters: [{$ref: '#/components/parameters/Limit', description: from b}] + responses: {"200": {description: ok}} +` + projection := func(paths string) map[string]string { + t.Helper() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: +` + paths + `components: + parameters: + Limit: {name: limit, in: query, description: declared, schema: {type: integer}} +` + doc, _, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + out := map[string]string{} + for _, name := range []string{"a", "b"} { + p, ok := paramBySource(openapitest.FindOp(t, doc, name), "limit") + require.True(t, ok) + out[name] = p.Docs.Description + } + return out + } + assert.Empty(t, cmp.Diff(projection(first+second), projection(second+first)), + "each mount keeps its own override whichever was lowered first") +} diff --git a/compilers/openapi/internal/operation/params_querystring_test.go b/compilers/openapi/internal/operation/params_querystring_test.go new file mode 100644 index 00000000..0642735f --- /dev/null +++ b/compilers/openapi/internal/operation/params_querystring_test.go @@ -0,0 +1,186 @@ +package operation_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" +) + +// TestParams_QueryStringLocation pins the whole of a querystring binding's +// declared serialization: the 3.2 location, and the media type from content +// carrying it alone. Style and explode are not legal keywords there, so the +// binding takes neither rather than the query defaults (GitHub #334). +func TestParams_QueryStringLocation(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpecVer("3.2.0", ` /q: + get: + operationId: q + parameters: + - name: qs + in: querystring + content: + application/x-www-form-urlencoded: + schema: {type: object, properties: {page: {type: string}}} + responses: + "200": {description: ok} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FindOp(t, doc, "q") + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + + qs := op.Bindings.HTTP[0].ParamBindings[0] + assert.Equal(t, ir.HTTPLocationQuerystring, qs.Location) + assert.Equal(t, "application/x-www-form-urlencoded", qs.ContentType) + assert.Empty(t, qs.Style, "3.2 binds the query string from content and forbids style there") + assert.Nil(t, qs.Explode, "and explode with it: it qualifies a style, and there is none") +} + +// TestParams_QueryStringDeclaredStyleIsKeptAndReported pins the other half of +// the same rule: the compiler stopped inventing a style at that location, it did +// not start erasing one. A document declaring style there breaks 3.2's rule and +// is warned of, as explode is; what it declared still lowers, because dropping +// declared content is an emitter's call rather than a compiler's. +func TestParams_QueryStringDeclaredStyleIsKeptAndReported(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpecVer("3.2.0", ` /q: + get: + operationId: q + parameters: + - name: qs + in: querystring + style: form + explode: false + content: + application/x-www-form-urlencoded: + schema: {type: object} + responses: + "200": {description: ok} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + for _, keyword := range []string{"style", "explode"} { + assert.Equal(t, "parameter field "+keyword+" is not allowed for in=querystring; lowered as declared", + openapitest.DiagMessageAt(t, diags, diag.InvalidLocationKeyword, ir.SeverityWarning, + "/paths/~1q/get/parameters/0/"+keyword)) + } + op := openapitest.FindOp(t, doc, "q") + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + + qs := op.Bindings.HTTP[0].ParamBindings[0] + require.Equal(t, ir.HTTPLocationQuerystring, qs.Location, + "the keywords below are only news at the location that forbids them") + assert.Equal(t, "form", qs.Style, "the declared style lowers as declared") + require.NotNil(t, qs.Explode) + assert.False(t, *qs.Explode, "and so does the explode qualifying it") +} + +// TestParams_QueryStringDeclaredExplodeAloneIsKeptAndReported covers the half +// of that rule the style case above cannot see, because it declares both +// keywords: an explode written without a style beside it. +// +// Suppressing the invented style must not take a declared explode with it. 3.2 +// forbids explode at this location as it forbids style, and erasing it would be +// the same silent drop the invented style was, in the other direction. See +// diag.InvalidLocationKeyword's GoDoc for why the report is a warning. +func TestParams_QueryStringDeclaredExplodeAloneIsKeptAndReported(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpecVer("3.2.0", ` /q: + get: + operationId: q + parameters: + - name: qs + in: querystring + explode: false + content: + application/x-www-form-urlencoded: + schema: {type: object} + responses: + "200": {description: ok} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FindOp(t, doc, "q") + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + + qs := op.Bindings.HTTP[0].ParamBindings[0] + require.Equal(t, ir.HTTPLocationQuerystring, qs.Location, + "the keywords below are only news at the location that forbids them") + assert.Empty(t, qs.Style, "no style is invented at this location") + require.NotNil(t, qs.Explode, "but the declared explode is not dropped with it") + assert.False(t, *qs.Explode) + + assert.Equal(t, + "parameter field explode is not allowed for in=querystring; lowered as declared", + openapitest.DiagMessageAt(t, diags, diag.InvalidLocationKeyword, ir.SeverityWarning, + "/paths/~1q/get/parameters/0/explode")) +} + +// TestParams_QueryStringDeclaredAllowReservedIsKeptAndReported is the third +// keyword 3.2 forbids at this location alongside style and explode; see +// diag.InvalidLocationKeyword's GoDoc for why the report is a warning. +func TestParams_QueryStringDeclaredAllowReservedIsKeptAndReported(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpecVer("3.2.0", ` /q: + get: + operationId: q + parameters: + - name: qs + in: querystring + allowReserved: true + content: + application/x-www-form-urlencoded: + schema: {type: object} + responses: + "200": {description: ok} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FindOp(t, doc, "q") + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + + qs := op.Bindings.HTTP[0].ParamBindings[0] + require.Equal(t, ir.HTTPLocationQuerystring, qs.Location, + "the keyword below is only news at the location that forbids it") + assert.True(t, qs.AllowReserved, "the declared allowReserved lowers as declared") + + assert.Equal(t, + "parameter field allowReserved is not allowed for in=querystring; lowered as declared", + openapitest.DiagMessageAt(t, diags, diag.InvalidLocationKeyword, ir.SeverityWarning, + "/paths/~1q/get/parameters/0/allowReserved")) +} + +// TestParams_QueryStringUndeclaredKeywordsAreNotReported is the control for +// both cases above: a querystring binding that declares neither keyword raises +// diag.InvalidLocationKeyword at neither pointer, so the diagnostic tracks the +// keyword's presence in the document rather than firing for every parameter at +// this location. +func TestParams_QueryStringUndeclaredKeywordsAreNotReported(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.PathsSpecVer("3.2.0", ` /q: + get: + operationId: q + parameters: + - name: qs + in: querystring + content: + application/x-www-form-urlencoded: + schema: {type: object} + responses: + "200": {description: ok} +`)) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FindOp(t, doc, "q") + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + assert.False(t, openapitest.HasDiag(diags, diag.InvalidLocationKeyword)) +} diff --git a/compilers/openapi/internal/operation/params_schema_test.go b/compilers/openapi/internal/operation/params_schema_test.go new file mode 100644 index 00000000..0d6097b0 --- /dev/null +++ b/compilers/openapi/internal/operation/params_schema_test.go @@ -0,0 +1,576 @@ +package operation_test + +import ( + "encoding/json/jsontext" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" +) + +func TestParams_SchemaConstraints(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /people: + get: + operationId: listPeople + parameters: + - {name: age, in: query, schema: {type: integer, maximum: 120}} + responses: {"200": {description: ok}} +`) + _, svc, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.Len(t, op.Params, 1) + c := op.Params[0].Constraints + require.NotNil(t, c, "param scalar constraints land via annotation.Constraints") + require.NotNil(t, c.Max) + assert.Equal(t, ir.BigVal("120"), *c.Max, "numeric bound read at full precision") +} + +// TestParams_SchemaAnnotationsReachTheParameter asserts a parameter schema's +// annotations land on the ir.Parameter that carries the position. Only its +// constraints used to survive; the rest were dropped with no diagnostic even +// though Parameter has Docs, Examples and Unmodeled (GitHub #116). +func TestParams_SchemaAnnotationsReachTheParameter(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, openapitest.PathsSpec( + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - name: q\n in: query\n schema: "+openapitest.InlineProbeBody+"\n"+ + " responses: {\"204\": {description: ok}}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + p := openapitest.FirstOp(t, svc).Params[0] + openapitest.AssertProbeDocsKept(t, p.Docs) + assert.NotNil(t, p.Deprecation) + openapitest.AssertProbeExample(t, p.Examples) + assert.Contains(t, p.Unmodeled, "openapi:x-vendor") + assert.Contains(t, p.Unmodeled, "openapi:not") + require.NotNil(t, p.Constraints) + require.NotNil(t, p.Constraints.MaxLength) + assert.Equal(t, int64(3), *p.Constraints.MaxLength) +} + +// TestParams_SchemaAnnotationsSurviveARefNamingTheSchema pins the parameter +// half of the pointer collision. A $ref can name a parameter's schema pointer, +// which hoists a node there — and components lower before paths, so that node +// always exists by the time the parameter is reached. Reading it as the +// declaration's home cost every parameter in this shape all of its schema +// annotations, in the only order there is. +func TestParams_SchemaAnnotationsSurviveARefNamingTheSchema(t *testing.T) { + t.Parallel() + doc, svc, diags := compileService(t, + "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - name: q\n in: query\n schema: "+openapitest.InlineProbeBody+"\n"+ + " responses: {\"204\": {description: ok}}\n"+ + "components:\n schemas:\n"+ + " Outsider: {$ref: '#/paths/~1x/get/parameters/0/schema'}\n") + openapitest.RequireNoErrorDiags(t, diags) + + p := openapitest.FirstOp(t, svc).Params[0] + assert.Equal(t, ir.TypeID("t/prim/string"), p.Type.Target, + "the parameter's own type is unchanged by the outside reference") + openapitest.AssertProbeDocsKept(t, p.Docs) + assert.NotNil(t, p.Deprecation) + assert.Contains(t, p.Unmodeled, "openapi:x-vendor") + assert.Contains(t, p.Unmodeled, "openapi:not") + + sc, ok := doc.Types["t/anon/paths/~1x/get/parameters/0/schema"].(*ir.Scalar) + require.True(t, ok, "and the referenced pointer still names the schema written there") + openapitest.AssertProbeDocsKept(t, sc.Docs) +} + +// paramRefInheritSpec gives every parameter the same referent and varies only +// what each writes beside its own $ref: Q declares one of each inheritable +// keyword plus a constraint, and Hop is a bare hop on the way to it. +const paramRefInheritSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: + get: + operationId: g + parameters: + - {name: bare, in: query, schema: {$ref: '#/components/schemas/Q'}} + - name: override + in: query + schema: + $ref: '#/components/schemas/Q' + description: USE_SITE + default: use-site + deprecated: false + - {name: chained, in: query, schema: {$ref: '#/components/schemas/Hop'}} + responses: {"200": {description: ok}} +components: + schemas: + Q: {type: string, description: REFERENT, deprecated: true, default: referent, maxLength: 9} + Hop: {$ref: '#/components/schemas/Q'} +` + +// paramsOf indexes an operation's parameters by source name. +func paramsOf(t *testing.T, svc ir.Service) map[string]ir.Parameter { + t.Helper() + return openapitest.IndexBy(openapitest.FirstOp(t, svc).Params, func(p ir.Parameter) string { return p.Name.Source }) +} + +// TestParams_RefSchemaInheritsFromItsReferent covers the referent-fallback half +// of the $ref rule at a parameter (GitHub #131). A parameter whose schema is a +// bare $ref used to carry nothing at all — no description, no deprecation, no +// default — because the position never resolved the target it was holding. +func TestParams_RefSchemaInheritsFromItsReferent(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramRefInheritSpec) + openapitest.RequireNoErrorDiags(t, diags) + + p := paramsOf(t, svc)["bare"] + assert.Equal(t, "REFERENT", p.Docs.Description, "the referent's description reaches the parameter") + assert.NotNil(t, p.Deprecation, "and its deprecation") + require.NotNil(t, p.Default, "and its default") + assert.Equal(t, "referent", p.Default.Str) + assert.Nil(t, p.Constraints, + "but never its constraints: fillPropertyConstraints keeps those use-site-only too") +} + +// TestParams_RefSchemaUseSiteWinsOverItsReferent pins the half that already +// worked, so closing the fallback cannot quietly invert the precedence: what is +// written beside the $ref describes this use of the type and outranks the target. +func TestParams_RefSchemaUseSiteWinsOverItsReferent(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramRefInheritSpec) + openapitest.RequireNoErrorDiags(t, diags) + + p := paramsOf(t, svc)["override"] + assert.Equal(t, "USE_SITE", p.Docs.Description, "the use-site description wins") + require.NotNil(t, p.Default) + assert.Equal(t, "use-site", p.Default.Str, "and the use-site default") + assert.Nil(t, p.Deprecation, "and an explicit deprecated: false suppresses the referent's true") +} + +// TestParams_RefSchemaInheritsThroughARefChain pins which resolution the +// fallback must use. Hop writes nothing but its own $ref, so a one-hop referent +// (annotation.At's) would leave the parameter with nothing; only following the +// chain to its end reaches Q. +func TestParams_RefSchemaInheritsThroughARefChain(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramRefInheritSpec) + openapitest.RequireNoErrorDiags(t, diags) + + p := paramsOf(t, svc)["chained"] + assert.Equal(t, "REFERENT", p.Docs.Description, "one hop reaches Hop, which declares nothing") + assert.NotNil(t, p.Deprecation) + require.NotNil(t, p.Default) + assert.Equal(t, "referent", p.Default.Str) +} + +const paramXMLSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: + get: + operationId: g + parameters: + - {name: q, in: query, schema: {type: string, xml: {name: XQ}}} + - name: c + in: query + content: + application/xml: + schema: {type: string, xml: {name: XCS}} + responses: {"200": {description: ok}} +` + +// TestParams_SchemaXMLHintsKeptUnderUnmodeled covers the one annotation +// ir.Parameter has no field for (GitHub #124), which used to be dropped with no +// diagnostic at all. It is not inert here: the content-style case binds +// application/xml, the media type OpenAPI §4.8.26 conditions xml on, and the +// binding records that content type. +func TestParams_SchemaXMLHintsKeptUnderUnmodeled(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramXMLSpec) + openapitest.RequireNoErrorDiags(t, diags) + params := paramsOf(t, svc) + + cases := []struct { + param, schemaPtr, want string + }{ + {"q", "/paths/~1x/get/parameters/0/schema", `{"name":"XQ"}`}, + {"c", "/paths/~1x/get/parameters/1/content/application~1xml/schema", `{"name":"XCS"}`}, + } + for _, tc := range cases { + t.Run(tc.param, func(t *testing.T) { + t.Parallel() + entry, ok := params[tc.param].Unmodeled["openapi:xml"] + require.True(t, ok, "an xml hint with no Parameter field is kept raw, not dropped") + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + assert.JSONEq(t, tc.want, string(entry.Value), "kept verbatim") + assert.Equal(t, jsontext.Pointer(tc.schemaPtr+"/xml"), entry.Provenance.Pointer, + "located at the xml keyword itself") + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, tc.schemaPtr+"/xml"), + "xml hints", "and announced once, at the same keyword the entry locates") + }) + } +} + +// TestParams_SchemaXMLHintsStayOnAnOwnedNode is the other half of one home per +// declaration: a parameter schema that hoisted a node of its own already has a +// structural XML field, so preserving a second copy on the parameter would give +// one declaration two homes that can drift. +func TestParams_SchemaXMLHintsStayOnAnOwnedNode(t *testing.T) { + t.Parallel() + doc, svc, diags := compileService(t, openapitest.PathsSpec( + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - {name: obj, in: query, schema: {type: object, xml: {name: XOBJ}}}\n"+ + " responses: {\"204\": {description: ok}}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + p := paramsOf(t, svc)["obj"] + node, ok := doc.Types[p.Type.Target] + require.True(t, ok, "the object schema owns a node of its own") + require.NotNil(t, node.Common().XML) + assert.Equal(t, "XOBJ", node.Common().XML.Name) + assert.NotContains(t, p.Unmodeled, "openapi:xml", "so the parameter keeps no second copy") + assert.Equal(t, 0, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "and nothing is announced as homeless") +} + +const paramRedactionSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: + get: + operationId: g + parameters: + - {name: q, in: query, schema: {type: string, format: password}} + - name: c + in: query + content: + text/plain: + schema: {type: string, format: password} + - {name: beside, in: query, schema: {$ref: '#/components/schemas/Plain', format: password}} + - {name: ref, in: query, schema: {$ref: '#/components/schemas/Pw'}} + - {name: pick, in: query, schema: {type: string, enum: [a, b], format: password}} + - {name: id, in: query, schema: {type: string, format: uuid}} + responses: {"200": {description: ok}} +components: + schemas: + Pw: {type: string, format: password} + Plain: {type: string} +` + +// TestParams_SchemaRedactionKeptUnderUnmodeled covers the one carrier with no +// redaction field: ir.Parameter has neither Property.Secret nor a node of its +// own, so a `format: password` its schema writes is kept verbatim and announced +// rather than dropped (GitHub #578), at the keyword itself, inline, beside a +// $ref and in a content-style parameter alike. +func TestParams_SchemaRedactionKeptUnderUnmodeled(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramRedactionSpec) + openapitest.RequireNoErrorDiags(t, diags) + params := paramsOf(t, svc) + + for _, tc := range []struct{ param, schemaPtr string }{ + {"q", "/paths/~1x/get/parameters/0/schema"}, + {"c", "/paths/~1x/get/parameters/1/content/text~1plain/schema"}, + {"beside", "/paths/~1x/get/parameters/2/schema"}, + } { + t.Run(tc.param, func(t *testing.T) { + t.Parallel() + entry, ok := params[tc.param].Unmodeled["openapi:format"] + require.True(t, ok, "a redaction with no Parameter field is kept raw, not dropped") + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + assert.JSONEq(t, `"password"`, string(entry.Value), "kept verbatim") + assert.Equal(t, jsontext.Pointer(tc.schemaPtr+"/format"), entry.Provenance.Pointer, + "located at the format keyword itself") + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, tc.schemaPtr+"/format"), + "redaction", "and announced once, at the same keyword the entry locates") + }) + } + assert.Equal(t, 3, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "one announcement per parameter that keeps it, and none for the others") +} + +// TestParams_SchemaRedactionStaysOnANode is the other half: a parameter whose +// type is a Sensitive node, its own or a referent's, keeps no second copy, and +// one writing a format that names a shape keeps nothing. +func TestParams_SchemaRedactionStaysOnANode(t *testing.T) { + t.Parallel() + doc, svc, diags := compileService(t, paramRedactionSpec) + openapitest.RequireNoErrorDiags(t, diags) + params := paramsOf(t, svc) + + id, ok := params["id"] + require.True(t, ok) + assert.NotContains(t, id.Unmodeled, "openapi:format", "a format naming a shape is no redaction") + for _, name := range []string{"ref", "pick"} { + p, ok := params[name] + require.True(t, ok, name) + node, ok := doc.Types[p.Type.Target] + require.True(t, ok, "%s: its type is a node", name) + assert.True(t, node.Common().Sensitive, "%s: the node holds the redaction", name) + assert.NotContains(t, p.Unmodeled, "openapi:format", "%s: so the parameter keeps no copy", name) + } +} + +const paramVisibilitySpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: + get: + operationId: g + parameters: + - {name: ro, in: query, schema: {type: string, default: dq, readOnly: true}} + - {name: wo, in: query, schema: {type: string, writeOnly: true}} + - name: c + in: query + content: + application/xml: + schema: {type: string, readOnly: true} + responses: {"200": {description: ok}} +` + +// TestParams_SchemaVisibilityKeptUnderUnmodeled covers the residue keywords a +// parameter has no field for. The rule +// docs/compilers/openapi.md#use-bound-annotations lowers readOnly/writeOnly to +// a Visibility, which only ir.Property carries, so at a parameter both reached +// no field and were dropped with no diagnostic at all — the silent loss this +// compiler exists to make impossible. +func TestParams_SchemaVisibilityKeptUnderUnmodeled(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramVisibilitySpec) + openapitest.RequireNoErrorDiags(t, diags) + params := paramsOf(t, svc) + + cases := []struct{ param, keyword, schemaPtr string }{ + {"ro", "readOnly", "/paths/~1x/get/parameters/0/schema"}, + {"wo", "writeOnly", "/paths/~1x/get/parameters/1/schema"}, + {"c", "readOnly", "/paths/~1x/get/parameters/2/content/application~1xml/schema"}, + } + for _, tc := range cases { + t.Run(tc.param, func(t *testing.T) { + t.Parallel() + at := tc.schemaPtr + "/" + tc.keyword + entry, ok := params[tc.param].Unmodeled["openapi:"+tc.keyword] + require.True(t, ok, "%s has no ir.Parameter field, so it is kept raw, not dropped", tc.keyword) + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + assert.JSONEq(t, `true`, string(entry.Value), "kept verbatim") + assert.Equal(t, jsontext.Pointer(at), entry.Provenance.Pointer, "located at the keyword itself") + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, at), + tc.keyword+" has no ir.Parameter home", "and announced once") + }) + } +} + +// TestParams_SchemaDefaultIsNotAlsoKeptVerbatim is the other side of that list: +// `default` is a residue keyword too, but Parameter.Default is a real home for +// it, so a verbatim copy beside it would give one declaration two homes. +func TestParams_SchemaDefaultIsNotAlsoKeptVerbatim(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, paramVisibilitySpec) + openapitest.RequireNoErrorDiags(t, diags) + + p := paramsOf(t, svc)["ro"] + require.NotNil(t, p.Default, "the default lands in its own field") + assert.Equal(t, "dq", p.Default.Str) + assert.NotContains(t, p.Unmodeled, "openapi:default", "and is not restated verbatim beside it") + for _, d := range diags { + assert.NotEqual(t, jsontext.Pointer("/paths/~1x/get/parameters/0/schema/default"), d.Provenance.Pointer, + "nor announced as homeless; got %+v", d) + } +} + +// TestParams_OwnAnnotationsWinOverTheSchema checks the precedence a parameter +// shares with a header: the parameter object describes this input, its schema +// describes the type, and the more specific of the two wins. +func TestParams_OwnAnnotationsWinOverTheSchema(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, openapitest.PathsSpec( + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - name: q\n in: query\n description: PARAM\n"+ + " x-scope: param\n schema: {type: string, description: SCHEMA, x-scope: schema}\n"+ + " responses: {\"204\": {description: ok}}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + p := openapitest.FirstOp(t, svc).Params[0] + assert.Equal(t, "PARAM", p.Docs.Description, "the parameter's own description wins") + raw, ok := p.Unmodeled["openapi:x-scope"] + require.True(t, ok) + assert.JSONEq(t, `"param"`, string(raw.Value), "and its own extension overlays the schema's") +} + +// TestParams_SchemaVisibilityKeptWhenTheSchemaOwnsANode pins the half of the +// parameter-visibility rescue that the own-node guard used to swallow. A +// parameter whose schema is an object, an enum or an array hoists a node, and +// neither home held the keywords: recordDeclarationResidue skips the position +// because a parameter is an annotation.HomeCarrier, and ir.Parameter has no +// Visibility field, so they were dropped for exactly the shapes that own a +// node. +func TestParams_SchemaVisibilityKeptWhenTheSchemaOwnsANode(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.PathsSpec(` /x: + get: + parameters: + - {name: obj, in: query, schema: {type: object, properties: {a: {type: string}}, readOnly: true}} + - {name: arr, in: query, schema: {type: array, items: {type: string}, writeOnly: true}} + - {name: enm, in: query, schema: {type: string, enum: [a, b], readOnly: true}} + responses: {"200": {description: ok}} +`)) + op := openapitest.FindOp(t, doc, "") + require.Len(t, op.Params, 3) + + for i, want := range []string{"openapi:readOnly", "openapi:writeOnly", "openapi:readOnly"} { + param := op.Params[i] + entry, ok := param.Unmodeled[want] + require.True(t, ok, "%s: %s kept on the carrier that has no field for it", param.Name.Source, want) + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + openapitest.AssertInfoDiagAt(t, diags, string(entry.Provenance.Pointer)) + } +} + +// TestParams_RefSiteKeywordsAreKeptOnTheParameter covers the third +// annotation.HomeCarrier position of the $ref-sibling census (GitHub #283). +// +// A parameter whose schema is a $ref resolves straight to the target, so the +// keywords written beside that $ref have no node of their own and no field on +// ir.Parameter either — exactly the shape a property and a header are in, and +// they are kept the same way: verbatim on the carrier, announced at the schema +// position that wrote them. +func TestParams_RefSiteKeywordsAreKeptOnTheParameter(t *testing.T) { + t.Parallel() + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + + "paths:\n /x:\n get:\n operationId: g\n parameters:\n" + + " - {name: q, in: query, schema: {$ref: '#/components/schemas/Base', format: email}}\n" + + " - {name: r, in: query, schema: {$ref: '#/components/schemas/Base', minLength: 3}}\n" + + " responses: {\"200\": {description: ok}}\n" + + "components:\n schemas:\n Base: {type: string}\n" + _, svc, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + params := openapitest.IndexBy(openapitest.FirstOp(t, svc).Params, func(p ir.Parameter) string { return p.Name.Source }) + q, ok := params["q"] + require.True(t, ok) + entry, ok := q.Unmodeled["openapi:format"] + require.True(t, ok, "an alias over the target has no Encoding field, so the format is kept") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `"email"`, string(entry.Value)) + openapitest.AssertInfoDiagAt(t, diags, "/paths/~1x/get/parameters/0/schema") + + r, ok := params["r"] + require.True(t, ok) + assert.Empty(t, r.Unmodeled, "a bound at the same position always reached ir.Parameter.Constraints") + require.NotNil(t, r.Constraints) + assert.Equal(t, int64(3), *r.Constraints.MinLength) +} + +// TestParams_CoDeclaredBoundsReachTheParameter covers the parameter carrier for +// a 2020-12 side that declares both of its bound keywords. Each is a keyword the +// source wrote and ir.Constraints has a field for each, so both reach the +// constraints the parameter holds and nothing is kept beside them — at the one +// carrier ir.Parameter owns rather than a node. +// +// The two rows swap which keyword is the tighter across the same magnitudes. A +// reader holding one bound per side answers both rows alike, which is what hid a +// change to the looser keyword from a consumer diffing two revisions (GitHub +// #425). +func TestParams_CoDeclaredBoundsReachTheParameter(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, openapitest.PathsSpec( + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - {name: low, in: query, schema: {type: integer, minimum: 10, exclusiveMinimum: 0}}\n"+ + " - {name: high, in: query, schema: {type: integer, minimum: 0, exclusiveMinimum: 10}}\n"+ + " - {name: plain, in: query, schema: {type: integer, minimum: 10}}\n"+ + " responses: {\"204\": {description: ok}}\n")) + openapitest.RequireNoErrorDiags(t, diags) + params := paramsOf(t, svc) + + cases := []struct { + param, wantMin, wantExclMin string + }{ + {param: "low", wantMin: "10", wantExclMin: "0"}, + {param: "high", wantMin: "0", wantExclMin: "10"}, + } + for _, tc := range cases { + t.Run(tc.param, func(t *testing.T) { + t.Parallel() + c := params[tc.param].Constraints + require.NotNil(t, c, "both bounds reach the parameter's constraints") + require.NotNil(t, c.Min) + require.NotNil(t, c.ExclusiveMin) + assert.Equal(t, tc.wantMin, c.Min.String(), "minimum as written") + assert.Equal(t, tc.wantExclMin, c.ExclusiveMin.String(), "exclusiveMinimum as written, beside it") + assert.Empty(t, params[tc.param].Unmodeled, "with a field apiece there is nothing left to keep") + }) + } + + assert.Empty(t, params["plain"].Unmodeled, + "and a side writing one keyword has it in a field, so nothing is restated beside it") +} + +// TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter covers the one +// bound keyword that still reaches no field, at the parameter carrier. A 3.0 +// exclusiveMinimum modifies the minimum beside it, so one written without a +// minimum modifies nothing and has no bound to become; dropping it would lose a +// declared keyword silently, so the parameter keeps it verbatim. +func TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter(t *testing.T) { + t.Parallel() + _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.0.3", + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - {name: bare, in: query, schema: {type: integer, exclusiveMinimum: true}}\n"+ + " responses: {\"204\": {description: ok}}\n")) + params := paramsOf(t, svc) + + entry, ok := params["bare"].Unmodeled["openapi:exclusiveMinimum"] + require.True(t, ok, "kept beside the constraints it did not reach; got %v", params["bare"].Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, "true", string(entry.Value)) + assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/schema/exclusiveMinimum"), entry.Provenance.Pointer, + "located at the keyword itself") + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, + "/paths/~1x/get/parameters/0/schema"), + "bounds nothing", "and reading it is what reports on it") +} + +// TestParams_RefSiteDocsOverrideTheDeclaration pins the parameter half of +// GitHub #610: a Reference Object's summary and description describe this use of +// the component and override the declaration's own description field by field. +// One component is referenced twice — once with both siblings, once with neither +// — so the override is shown to be per-mount rather than a mutation of the +// declaration. +func TestParams_RefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + parameters: [{$ref: '#/components/parameters/Limit', summary: S, description: D}] + responses: {"200": {description: ok}} + /b: + get: + operationId: b + parameters: [{$ref: '#/components/parameters/Limit'}] + responses: {"200": {description: ok}} +components: + parameters: + Limit: {name: limit, in: query, description: declared, schema: {type: integer}} +` + doc, _, diags := compileService(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + withSiblings, ok := paramBySource(openapitest.FindOp(t, doc, "a"), "limit") + require.True(t, ok) + assert.Equal(t, "S", withSiblings.Docs.Summary) + assert.Equal(t, "D", withSiblings.Docs.Description) + + without, ok := paramBySource(openapitest.FindOp(t, doc, "b"), "limit") + require.True(t, ok) + assert.Empty(t, without.Docs.Summary, "no sibling means no summary is invented") + assert.Equal(t, "declared", without.Docs.Description, + "the other mount keeps the declaration's own description") +} diff --git a/compilers/openapi/internal/operation/params_test.go b/compilers/openapi/internal/operation/params_test.go index 926203ae..5a256a63 100644 --- a/compilers/openapi/internal/operation/params_test.go +++ b/compilers/openapi/internal/operation/params_test.go @@ -2,22 +2,13 @@ package operation_test import ( "encoding/json/jsontext" - "fmt" - "os" - "path/filepath" - "slices" - "strings" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/engine/reader" "github.com/dexpace/morphic/ir" ) @@ -113,25 +104,6 @@ func TestParams_UnconvertibleExampleDiagnosed(t *testing.T) { assert.Contains(t, d.Message, "example:") } -func TestParams_SchemaConstraints(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpec(` /people: - get: - operationId: listPeople - parameters: - - {name: age, in: query, schema: {type: integer, maximum: 120}} - responses: {"200": {description: ok}} -`) - _, svc, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FirstOp(t, svc) - require.Len(t, op.Params, 1) - c := op.Params[0].Constraints - require.NotNil(t, c, "param scalar constraints land via annotation.Constraints") - require.NotNil(t, c.Max) - assert.Equal(t, ir.BigVal("120"), *c.Max, "numeric bound read at full precision") -} - const paramSpec = `openapi: 3.1.0 info: {title: T, version: "1"} paths: @@ -192,180 +164,6 @@ func TestParams_AllLocationsAndStyles(t *testing.T) { assert.True(t, openapitest.HasDiag(diags, diag.NumericPrecision), "malformed param constraint warns") } -// TestParams_QueryStringLocation pins the whole of a querystring binding's -// declared serialization: the 3.2 location, and the media type from content -// carrying it alone. Style and explode are not legal keywords there, so the -// binding takes neither rather than the query defaults (GitHub #334). -func TestParams_QueryStringLocation(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpecVer("3.2.0", ` /q: - get: - operationId: q - parameters: - - name: qs - in: querystring - content: - application/x-www-form-urlencoded: - schema: {type: object, properties: {page: {type: string}}} - responses: - "200": {description: ok} -`) - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FindOp(t, doc, "q") - require.Len(t, op.Bindings.HTTP, 1) - require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) - - qs := op.Bindings.HTTP[0].ParamBindings[0] - assert.Equal(t, ir.HTTPLocationQuerystring, qs.Location) - assert.Equal(t, "application/x-www-form-urlencoded", qs.ContentType) - assert.Empty(t, qs.Style, "3.2 binds the query string from content and forbids style there") - assert.Nil(t, qs.Explode, "and explode with it: it qualifies a style, and there is none") -} - -// TestParams_QueryStringDeclaredStyleIsKeptAndReported pins the other half of -// the same rule: the compiler stopped inventing a style at that location, it did -// not start erasing one. A document declaring style there breaks 3.2's rule and -// is warned of, as explode is; what it declared still lowers, because dropping -// declared content is an emitter's call rather than a compiler's. -func TestParams_QueryStringDeclaredStyleIsKeptAndReported(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpecVer("3.2.0", ` /q: - get: - operationId: q - parameters: - - name: qs - in: querystring - style: form - explode: false - content: - application/x-www-form-urlencoded: - schema: {type: object} - responses: - "200": {description: ok} -`) - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - for _, keyword := range []string{"style", "explode"} { - assert.Equal(t, "parameter field "+keyword+" is not allowed for in=querystring; lowered as declared", - openapitest.DiagMessageAt(t, diags, diag.InvalidLocationKeyword, ir.SeverityWarning, - "/paths/~1q/get/parameters/0/"+keyword)) - } - op := openapitest.FindOp(t, doc, "q") - require.Len(t, op.Bindings.HTTP, 1) - require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) - - qs := op.Bindings.HTTP[0].ParamBindings[0] - require.Equal(t, ir.HTTPLocationQuerystring, qs.Location, - "the keywords below are only news at the location that forbids them") - assert.Equal(t, "form", qs.Style, "the declared style lowers as declared") - require.NotNil(t, qs.Explode) - assert.False(t, *qs.Explode, "and so does the explode qualifying it") -} - -// TestParams_QueryStringDeclaredExplodeAloneIsKeptAndReported covers the half -// of that rule the style case above cannot see, because it declares both -// keywords: an explode written without a style beside it. -// -// Suppressing the invented style must not take a declared explode with it. 3.2 -// forbids explode at this location as it forbids style, and erasing it would be -// the same silent drop the invented style was, in the other direction. See -// diag.InvalidLocationKeyword's GoDoc for why the report is a warning. -func TestParams_QueryStringDeclaredExplodeAloneIsKeptAndReported(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpecVer("3.2.0", ` /q: - get: - operationId: q - parameters: - - name: qs - in: querystring - explode: false - content: - application/x-www-form-urlencoded: - schema: {type: object} - responses: - "200": {description: ok} -`) - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FindOp(t, doc, "q") - require.Len(t, op.Bindings.HTTP, 1) - require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) - - qs := op.Bindings.HTTP[0].ParamBindings[0] - require.Equal(t, ir.HTTPLocationQuerystring, qs.Location, - "the keywords below are only news at the location that forbids them") - assert.Empty(t, qs.Style, "no style is invented at this location") - require.NotNil(t, qs.Explode, "but the declared explode is not dropped with it") - assert.False(t, *qs.Explode) - - assert.Equal(t, - "parameter field explode is not allowed for in=querystring; lowered as declared", - openapitest.DiagMessageAt(t, diags, diag.InvalidLocationKeyword, ir.SeverityWarning, - "/paths/~1q/get/parameters/0/explode")) -} - -// TestParams_QueryStringDeclaredAllowReservedIsKeptAndReported is the third -// keyword 3.2 forbids at this location alongside style and explode; see -// diag.InvalidLocationKeyword's GoDoc for why the report is a warning. -func TestParams_QueryStringDeclaredAllowReservedIsKeptAndReported(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpecVer("3.2.0", ` /q: - get: - operationId: q - parameters: - - name: qs - in: querystring - allowReserved: true - content: - application/x-www-form-urlencoded: - schema: {type: object} - responses: - "200": {description: ok} -`) - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FindOp(t, doc, "q") - require.Len(t, op.Bindings.HTTP, 1) - require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) - - qs := op.Bindings.HTTP[0].ParamBindings[0] - require.Equal(t, ir.HTTPLocationQuerystring, qs.Location, - "the keyword below is only news at the location that forbids it") - assert.True(t, qs.AllowReserved, "the declared allowReserved lowers as declared") - - assert.Equal(t, - "parameter field allowReserved is not allowed for in=querystring; lowered as declared", - openapitest.DiagMessageAt(t, diags, diag.InvalidLocationKeyword, ir.SeverityWarning, - "/paths/~1q/get/parameters/0/allowReserved")) -} - -// TestParams_QueryStringUndeclaredKeywordsAreNotReported is the control for -// both cases above: a querystring binding that declares neither keyword raises -// diag.InvalidLocationKeyword at neither pointer, so the diagnostic tracks the -// keyword's presence in the document rather than firing for every parameter at -// this location. -func TestParams_QueryStringUndeclaredKeywordsAreNotReported(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, openapitest.PathsSpecVer("3.2.0", ` /q: - get: - operationId: q - parameters: - - name: qs - in: querystring - content: - application/x-www-form-urlencoded: - schema: {type: object} - responses: - "200": {description: ok} -`)) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FindOp(t, doc, "q") - require.Len(t, op.Bindings.HTTP, 1) - require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) - assert.False(t, openapitest.HasDiag(diags, diag.InvalidLocationKeyword)) -} - const componentParamRefSpec = `openapi: 3.1.0 info: {title: T, version: "1"} paths: @@ -513,406 +311,6 @@ func TestParams_ContentStyleComponentRefInternsOnce(t *testing.T) { assert.Equal(t, "application/json", getA.Bindings.HTTP[0].ParamBindings[0].ContentType) } -// TestParams_SchemaAnnotationsReachTheParameter asserts a parameter schema's -// annotations land on the ir.Parameter that carries the position. Only its -// constraints used to survive; the rest were dropped with no diagnostic even -// though Parameter has Docs, Examples and Unmodeled (GitHub #116). -func TestParams_SchemaAnnotationsReachTheParameter(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, openapitest.PathsSpec( - " /x:\n get:\n operationId: g\n parameters:\n"+ - " - name: q\n in: query\n schema: "+openapitest.InlineProbeBody+"\n"+ - " responses: {\"204\": {description: ok}}\n")) - openapitest.RequireNoErrorDiags(t, diags) - - p := openapitest.FirstOp(t, svc).Params[0] - openapitest.AssertProbeDocsKept(t, p.Docs) - assert.NotNil(t, p.Deprecation) - openapitest.AssertProbeExample(t, p.Examples) - assert.Contains(t, p.Unmodeled, "openapi:x-vendor") - assert.Contains(t, p.Unmodeled, "openapi:not") - require.NotNil(t, p.Constraints) - require.NotNil(t, p.Constraints.MaxLength) - assert.Equal(t, int64(3), *p.Constraints.MaxLength) -} - -// TestParams_SchemaAnnotationsSurviveARefNamingTheSchema pins the parameter -// half of the pointer collision. A $ref can name a parameter's schema pointer, -// which hoists a node there — and components lower before paths, so that node -// always exists by the time the parameter is reached. Reading it as the -// declaration's home cost every parameter in this shape all of its schema -// annotations, in the only order there is. -func TestParams_SchemaAnnotationsSurviveARefNamingTheSchema(t *testing.T) { - t.Parallel() - doc, svc, diags := compileService(t, - "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ - " /x:\n get:\n operationId: g\n parameters:\n"+ - " - name: q\n in: query\n schema: "+openapitest.InlineProbeBody+"\n"+ - " responses: {\"204\": {description: ok}}\n"+ - "components:\n schemas:\n"+ - " Outsider: {$ref: '#/paths/~1x/get/parameters/0/schema'}\n") - openapitest.RequireNoErrorDiags(t, diags) - - p := openapitest.FirstOp(t, svc).Params[0] - assert.Equal(t, ir.TypeID("t/prim/string"), p.Type.Target, - "the parameter's own type is unchanged by the outside reference") - openapitest.AssertProbeDocsKept(t, p.Docs) - assert.NotNil(t, p.Deprecation) - assert.Contains(t, p.Unmodeled, "openapi:x-vendor") - assert.Contains(t, p.Unmodeled, "openapi:not") - - sc, ok := doc.Types["t/anon/paths/~1x/get/parameters/0/schema"].(*ir.Scalar) - require.True(t, ok, "and the referenced pointer still names the schema written there") - openapitest.AssertProbeDocsKept(t, sc.Docs) -} - -// paramRefInheritSpec gives every parameter the same referent and varies only -// what each writes beside its own $ref: Q declares one of each inheritable -// keyword plus a constraint, and Hop is a bare hop on the way to it. -const paramRefInheritSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /x: - get: - operationId: g - parameters: - - {name: bare, in: query, schema: {$ref: '#/components/schemas/Q'}} - - name: override - in: query - schema: - $ref: '#/components/schemas/Q' - description: USE_SITE - default: use-site - deprecated: false - - {name: chained, in: query, schema: {$ref: '#/components/schemas/Hop'}} - responses: {"200": {description: ok}} -components: - schemas: - Q: {type: string, description: REFERENT, deprecated: true, default: referent, maxLength: 9} - Hop: {$ref: '#/components/schemas/Q'} -` - -// paramsOf indexes an operation's parameters by source name. -func paramsOf(t *testing.T, svc ir.Service) map[string]ir.Parameter { - t.Helper() - return openapitest.IndexBy(openapitest.FirstOp(t, svc).Params, func(p ir.Parameter) string { return p.Name.Source }) -} - -// TestParams_RefSchemaInheritsFromItsReferent covers the referent-fallback half -// of the $ref rule at a parameter (GitHub #131). A parameter whose schema is a -// bare $ref used to carry nothing at all — no description, no deprecation, no -// default — because the position never resolved the target it was holding. -func TestParams_RefSchemaInheritsFromItsReferent(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramRefInheritSpec) - openapitest.RequireNoErrorDiags(t, diags) - - p := paramsOf(t, svc)["bare"] - assert.Equal(t, "REFERENT", p.Docs.Description, "the referent's description reaches the parameter") - assert.NotNil(t, p.Deprecation, "and its deprecation") - require.NotNil(t, p.Default, "and its default") - assert.Equal(t, "referent", p.Default.Str) - assert.Nil(t, p.Constraints, - "but never its constraints: fillPropertyConstraints keeps those use-site-only too") -} - -// TestParams_RefSchemaUseSiteWinsOverItsReferent pins the half that already -// worked, so closing the fallback cannot quietly invert the precedence: what is -// written beside the $ref describes this use of the type and outranks the target. -func TestParams_RefSchemaUseSiteWinsOverItsReferent(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramRefInheritSpec) - openapitest.RequireNoErrorDiags(t, diags) - - p := paramsOf(t, svc)["override"] - assert.Equal(t, "USE_SITE", p.Docs.Description, "the use-site description wins") - require.NotNil(t, p.Default) - assert.Equal(t, "use-site", p.Default.Str, "and the use-site default") - assert.Nil(t, p.Deprecation, "and an explicit deprecated: false suppresses the referent's true") -} - -// TestParams_RefSchemaInheritsThroughARefChain pins which resolution the -// fallback must use. Hop writes nothing but its own $ref, so a one-hop referent -// (annotation.At's) would leave the parameter with nothing; only following the -// chain to its end reaches Q. -func TestParams_RefSchemaInheritsThroughARefChain(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramRefInheritSpec) - openapitest.RequireNoErrorDiags(t, diags) - - p := paramsOf(t, svc)["chained"] - assert.Equal(t, "REFERENT", p.Docs.Description, "one hop reaches Hop, which declares nothing") - assert.NotNil(t, p.Deprecation) - require.NotNil(t, p.Default) - assert.Equal(t, "referent", p.Default.Str) -} - -const paramXMLSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /x: - get: - operationId: g - parameters: - - {name: q, in: query, schema: {type: string, xml: {name: XQ}}} - - name: c - in: query - content: - application/xml: - schema: {type: string, xml: {name: XCS}} - responses: {"200": {description: ok}} -` - -// TestParams_SchemaXMLHintsKeptUnderUnmodeled covers the one annotation -// ir.Parameter has no field for (GitHub #124), which used to be dropped with no -// diagnostic at all. It is not inert here: the content-style case binds -// application/xml, the media type OpenAPI §4.8.26 conditions xml on, and the -// binding records that content type. -func TestParams_SchemaXMLHintsKeptUnderUnmodeled(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramXMLSpec) - openapitest.RequireNoErrorDiags(t, diags) - params := paramsOf(t, svc) - - cases := []struct { - param, schemaPtr, want string - }{ - {"q", "/paths/~1x/get/parameters/0/schema", `{"name":"XQ"}`}, - {"c", "/paths/~1x/get/parameters/1/content/application~1xml/schema", `{"name":"XCS"}`}, - } - for _, tc := range cases { - t.Run(tc.param, func(t *testing.T) { - t.Parallel() - entry, ok := params[tc.param].Unmodeled["openapi:xml"] - require.True(t, ok, "an xml hint with no Parameter field is kept raw, not dropped") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, tc.want, string(entry.Value), "kept verbatim") - assert.Equal(t, jsontext.Pointer(tc.schemaPtr+"/xml"), entry.Provenance.Pointer, - "located at the xml keyword itself") - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, tc.schemaPtr+"/xml"), - "xml hints", "and announced once, at the same keyword the entry locates") - }) - } -} - -// TestParams_SchemaXMLHintsStayOnAnOwnedNode is the other half of one home per -// declaration: a parameter schema that hoisted a node of its own already has a -// structural XML field, so preserving a second copy on the parameter would give -// one declaration two homes that can drift. -func TestParams_SchemaXMLHintsStayOnAnOwnedNode(t *testing.T) { - t.Parallel() - doc, svc, diags := compileService(t, openapitest.PathsSpec( - " /x:\n get:\n operationId: g\n parameters:\n"+ - " - {name: obj, in: query, schema: {type: object, xml: {name: XOBJ}}}\n"+ - " responses: {\"204\": {description: ok}}\n")) - openapitest.RequireNoErrorDiags(t, diags) - - p := paramsOf(t, svc)["obj"] - node, ok := doc.Types[p.Type.Target] - require.True(t, ok, "the object schema owns a node of its own") - require.NotNil(t, node.Common().XML) - assert.Equal(t, "XOBJ", node.Common().XML.Name) - assert.NotContains(t, p.Unmodeled, "openapi:xml", "so the parameter keeps no second copy") - assert.Equal(t, 0, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "and nothing is announced as homeless") -} - -const paramRedactionSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /x: - get: - operationId: g - parameters: - - {name: q, in: query, schema: {type: string, format: password}} - - name: c - in: query - content: - text/plain: - schema: {type: string, format: password} - - {name: beside, in: query, schema: {$ref: '#/components/schemas/Plain', format: password}} - - {name: ref, in: query, schema: {$ref: '#/components/schemas/Pw'}} - - {name: pick, in: query, schema: {type: string, enum: [a, b], format: password}} - - {name: id, in: query, schema: {type: string, format: uuid}} - responses: {"200": {description: ok}} -components: - schemas: - Pw: {type: string, format: password} - Plain: {type: string} -` - -// TestParams_SchemaRedactionKeptUnderUnmodeled covers the one carrier with no -// redaction field: ir.Parameter has neither Property.Secret nor a node of its -// own, so a `format: password` its schema writes is kept verbatim and announced -// rather than dropped (GitHub #578), at the keyword itself, inline, beside a -// $ref and in a content-style parameter alike. -func TestParams_SchemaRedactionKeptUnderUnmodeled(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramRedactionSpec) - openapitest.RequireNoErrorDiags(t, diags) - params := paramsOf(t, svc) - - for _, tc := range []struct{ param, schemaPtr string }{ - {"q", "/paths/~1x/get/parameters/0/schema"}, - {"c", "/paths/~1x/get/parameters/1/content/text~1plain/schema"}, - {"beside", "/paths/~1x/get/parameters/2/schema"}, - } { - t.Run(tc.param, func(t *testing.T) { - t.Parallel() - entry, ok := params[tc.param].Unmodeled["openapi:format"] - require.True(t, ok, "a redaction with no Parameter field is kept raw, not dropped") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `"password"`, string(entry.Value), "kept verbatim") - assert.Equal(t, jsontext.Pointer(tc.schemaPtr+"/format"), entry.Provenance.Pointer, - "located at the format keyword itself") - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, tc.schemaPtr+"/format"), - "redaction", "and announced once, at the same keyword the entry locates") - }) - } - assert.Equal(t, 3, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "one announcement per parameter that keeps it, and none for the others") -} - -// TestParams_SchemaRedactionStaysOnANode is the other half: a parameter whose -// type is a Sensitive node, its own or a referent's, keeps no second copy, and -// one writing a format that names a shape keeps nothing. -func TestParams_SchemaRedactionStaysOnANode(t *testing.T) { - t.Parallel() - doc, svc, diags := compileService(t, paramRedactionSpec) - openapitest.RequireNoErrorDiags(t, diags) - params := paramsOf(t, svc) - - id, ok := params["id"] - require.True(t, ok) - assert.NotContains(t, id.Unmodeled, "openapi:format", "a format naming a shape is no redaction") - for _, name := range []string{"ref", "pick"} { - p, ok := params[name] - require.True(t, ok, name) - node, ok := doc.Types[p.Type.Target] - require.True(t, ok, "%s: its type is a node", name) - assert.True(t, node.Common().Sensitive, "%s: the node holds the redaction", name) - assert.NotContains(t, p.Unmodeled, "openapi:format", "%s: so the parameter keeps no copy", name) - } -} - -const paramVisibilitySpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /x: - get: - operationId: g - parameters: - - {name: ro, in: query, schema: {type: string, default: dq, readOnly: true}} - - {name: wo, in: query, schema: {type: string, writeOnly: true}} - - name: c - in: query - content: - application/xml: - schema: {type: string, readOnly: true} - responses: {"200": {description: ok}} -` - -// TestParams_SchemaVisibilityKeptUnderUnmodeled covers the residue keywords a -// parameter has no field for. The rule -// docs/compilers/openapi.md#use-bound-annotations lowers readOnly/writeOnly to -// a Visibility, which only ir.Property carries, so at a parameter both reached -// no field and were dropped with no diagnostic at all — the silent loss this -// compiler exists to make impossible. -func TestParams_SchemaVisibilityKeptUnderUnmodeled(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramVisibilitySpec) - openapitest.RequireNoErrorDiags(t, diags) - params := paramsOf(t, svc) - - cases := []struct{ param, keyword, schemaPtr string }{ - {"ro", "readOnly", "/paths/~1x/get/parameters/0/schema"}, - {"wo", "writeOnly", "/paths/~1x/get/parameters/1/schema"}, - {"c", "readOnly", "/paths/~1x/get/parameters/2/content/application~1xml/schema"}, - } - for _, tc := range cases { - t.Run(tc.param, func(t *testing.T) { - t.Parallel() - at := tc.schemaPtr + "/" + tc.keyword - entry, ok := params[tc.param].Unmodeled["openapi:"+tc.keyword] - require.True(t, ok, "%s has no ir.Parameter field, so it is kept raw, not dropped", tc.keyword) - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `true`, string(entry.Value), "kept verbatim") - assert.Equal(t, jsontext.Pointer(at), entry.Provenance.Pointer, "located at the keyword itself") - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, at), - tc.keyword+" has no ir.Parameter home", "and announced once") - }) - } -} - -// TestParams_SchemaDefaultIsNotAlsoKeptVerbatim is the other side of that list: -// `default` is a residue keyword too, but Parameter.Default is a real home for -// it, so a verbatim copy beside it would give one declaration two homes. -func TestParams_SchemaDefaultIsNotAlsoKeptVerbatim(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, paramVisibilitySpec) - openapitest.RequireNoErrorDiags(t, diags) - - p := paramsOf(t, svc)["ro"] - require.NotNil(t, p.Default, "the default lands in its own field") - assert.Equal(t, "dq", p.Default.Str) - assert.NotContains(t, p.Unmodeled, "openapi:default", "and is not restated verbatim beside it") - for _, d := range diags { - assert.NotEqual(t, jsontext.Pointer("/paths/~1x/get/parameters/0/schema/default"), d.Provenance.Pointer, - "nor announced as homeless; got %+v", d) - } -} - -// TestParams_OwnAnnotationsWinOverTheSchema checks the precedence a parameter -// shares with a header: the parameter object describes this input, its schema -// describes the type, and the more specific of the two wins. -func TestParams_OwnAnnotationsWinOverTheSchema(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, openapitest.PathsSpec( - " /x:\n get:\n operationId: g\n parameters:\n"+ - " - name: q\n in: query\n description: PARAM\n"+ - " x-scope: param\n schema: {type: string, description: SCHEMA, x-scope: schema}\n"+ - " responses: {\"204\": {description: ok}}\n")) - openapitest.RequireNoErrorDiags(t, diags) - - p := openapitest.FirstOp(t, svc).Params[0] - assert.Equal(t, "PARAM", p.Docs.Description, "the parameter's own description wins") - raw, ok := p.Unmodeled["openapi:x-scope"] - require.True(t, ok) - assert.JSONEq(t, `"param"`, string(raw.Value), "and its own extension overlays the schema's") -} - -// TestParams_SchemaVisibilityKeptWhenTheSchemaOwnsANode pins the half of the -// parameter-visibility rescue that the own-node guard used to swallow. A -// parameter whose schema is an object, an enum or an array hoists a node, and -// neither home held the keywords: recordDeclarationResidue skips the position -// because a parameter is an annotation.HomeCarrier, and ir.Parameter has no -// Visibility field, so they were dropped for exactly the shapes that own a -// node. -func TestParams_SchemaVisibilityKeptWhenTheSchemaOwnsANode(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, openapitest.PathsSpec(` /x: - get: - parameters: - - {name: obj, in: query, schema: {type: object, properties: {a: {type: string}}, readOnly: true}} - - {name: arr, in: query, schema: {type: array, items: {type: string}, writeOnly: true}} - - {name: enm, in: query, schema: {type: string, enum: [a, b], readOnly: true}} - responses: {"200": {description: ok}} -`)) - op := openapitest.FindOp(t, doc, "") - require.Len(t, op.Params, 3) - - for i, want := range []string{"openapi:readOnly", "openapi:writeOnly", "openapi:readOnly"} { - param := op.Params[i] - entry, ok := param.Unmodeled[want] - require.True(t, ok, "%s: %s kept on the carrier that has no field for it", param.Name.Source, want) - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - openapitest.AssertInfoDiagAt(t, diags, string(entry.Provenance.Pointer)) - } -} - // TestParams_AllowEmptyValueKept pins the last unread Parameter field. Style, // explode, allowReserved and a content-style media type all reach // ir.HTTPParamBinding; allowEmptyValue reached nothing, so a document declaring @@ -997,701 +395,3 @@ func TestParams_ReservedHeaderNamesAreReported(t *testing.T) { }) } } - -// TestParams_RefSiteKeywordsAreKeptOnTheParameter covers the third -// annotation.HomeCarrier position of the $ref-sibling census (GitHub #283). -// -// A parameter whose schema is a $ref resolves straight to the target, so the -// keywords written beside that $ref have no node of their own and no field on -// ir.Parameter either — exactly the shape a property and a header are in, and -// they are kept the same way: verbatim on the carrier, announced at the schema -// position that wrote them. -func TestParams_RefSiteKeywordsAreKeptOnTheParameter(t *testing.T) { - t.Parallel() - spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + - "paths:\n /x:\n get:\n operationId: g\n parameters:\n" + - " - {name: q, in: query, schema: {$ref: '#/components/schemas/Base', format: email}}\n" + - " - {name: r, in: query, schema: {$ref: '#/components/schemas/Base', minLength: 3}}\n" + - " responses: {\"200\": {description: ok}}\n" + - "components:\n schemas:\n Base: {type: string}\n" - _, svc, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - params := openapitest.IndexBy(openapitest.FirstOp(t, svc).Params, func(p ir.Parameter) string { return p.Name.Source }) - q, ok := params["q"] - require.True(t, ok) - entry, ok := q.Unmodeled["openapi:format"] - require.True(t, ok, "an alias over the target has no Encoding field, so the format is kept") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, `"email"`, string(entry.Value)) - openapitest.AssertInfoDiagAt(t, diags, "/paths/~1x/get/parameters/0/schema") - - r, ok := params["r"] - require.True(t, ok) - assert.Empty(t, r.Unmodeled, "a bound at the same position always reached ir.Parameter.Constraints") - require.NotNil(t, r.Constraints) - assert.Equal(t, int64(3), *r.Constraints.MinLength) -} - -// TestParams_CoDeclaredBoundsReachTheParameter covers the parameter carrier for -// a 2020-12 side that declares both of its bound keywords. Each is a keyword the -// source wrote and ir.Constraints has a field for each, so both reach the -// constraints the parameter holds and nothing is kept beside them — at the one -// carrier ir.Parameter owns rather than a node. -// -// The two rows swap which keyword is the tighter across the same magnitudes. A -// reader holding one bound per side answers both rows alike, which is what hid a -// change to the looser keyword from a consumer diffing two revisions (GitHub -// #425). -func TestParams_CoDeclaredBoundsReachTheParameter(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, openapitest.PathsSpec( - " /x:\n get:\n operationId: g\n parameters:\n"+ - " - {name: low, in: query, schema: {type: integer, minimum: 10, exclusiveMinimum: 0}}\n"+ - " - {name: high, in: query, schema: {type: integer, minimum: 0, exclusiveMinimum: 10}}\n"+ - " - {name: plain, in: query, schema: {type: integer, minimum: 10}}\n"+ - " responses: {\"204\": {description: ok}}\n")) - openapitest.RequireNoErrorDiags(t, diags) - params := paramsOf(t, svc) - - cases := []struct { - param, wantMin, wantExclMin string - }{ - {param: "low", wantMin: "10", wantExclMin: "0"}, - {param: "high", wantMin: "0", wantExclMin: "10"}, - } - for _, tc := range cases { - t.Run(tc.param, func(t *testing.T) { - t.Parallel() - c := params[tc.param].Constraints - require.NotNil(t, c, "both bounds reach the parameter's constraints") - require.NotNil(t, c.Min) - require.NotNil(t, c.ExclusiveMin) - assert.Equal(t, tc.wantMin, c.Min.String(), "minimum as written") - assert.Equal(t, tc.wantExclMin, c.ExclusiveMin.String(), "exclusiveMinimum as written, beside it") - assert.Empty(t, params[tc.param].Unmodeled, "with a field apiece there is nothing left to keep") - }) - } - - assert.Empty(t, params["plain"].Unmodeled, - "and a side writing one keyword has it in a field, so nothing is restated beside it") -} - -// TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter covers the one -// bound keyword that still reaches no field, at the parameter carrier. A 3.0 -// exclusiveMinimum modifies the minimum beside it, so one written without a -// minimum modifies nothing and has no bound to become; dropping it would lose a -// declared keyword silently, so the parameter keeps it verbatim. -func TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter(t *testing.T) { - t.Parallel() - _, svc, diags := compileService(t, openapitest.PathsSpecVer("3.0.3", - " /x:\n get:\n operationId: g\n parameters:\n"+ - " - {name: bare, in: query, schema: {type: integer, exclusiveMinimum: true}}\n"+ - " responses: {\"204\": {description: ok}}\n")) - params := paramsOf(t, svc) - - entry, ok := params["bare"].Unmodeled["openapi:exclusiveMinimum"] - require.True(t, ok, "kept beside the constraints it did not reach; got %v", params["bare"].Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, "true", string(entry.Value)) - assert.Equal(t, jsontext.Pointer("/paths/~1x/get/parameters/0/schema/exclusiveMinimum"), entry.Provenance.Pointer, - "located at the keyword itself") - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, - "/paths/~1x/get/parameters/0/schema"), - "bounds nothing", "and reading it is what reports on it") -} - -// paramBySource returns the operation's logical parameter with the given source -// name. -func paramBySource(op ir.Operation, name string) (ir.Parameter, bool) { - for _, p := range op.Params { - if p.Name.Source == name { - return p, true - } - } - return ir.Parameter{}, false -} - -// TestParams_RefSiteDocsOverrideTheDeclaration pins the parameter half of -// GitHub #610: a Reference Object's summary and description describe this use of -// the component and override the declaration's own description field by field. -// One component is referenced twice — once with both siblings, once with neither -// — so the override is shown to be per-mount rather than a mutation of the -// declaration. -func TestParams_RefSiteDocsOverrideTheDeclaration(t *testing.T) { - t.Parallel() - spec := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - get: - operationId: a - parameters: [{$ref: '#/components/parameters/Limit', summary: S, description: D}] - responses: {"200": {description: ok}} - /b: - get: - operationId: b - parameters: [{$ref: '#/components/parameters/Limit'}] - responses: {"200": {description: ok}} -components: - parameters: - Limit: {name: limit, in: query, description: declared, schema: {type: integer}} -` - doc, _, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - withSiblings, ok := paramBySource(openapitest.FindOp(t, doc, "a"), "limit") - require.True(t, ok) - assert.Equal(t, "S", withSiblings.Docs.Summary) - assert.Equal(t, "D", withSiblings.Docs.Description) - - without, ok := paramBySource(openapitest.FindOp(t, doc, "b"), "limit") - require.True(t, ok) - assert.Empty(t, without.Docs.Summary, "no sibling means no summary is invented") - assert.Equal(t, "declared", without.Docs.Description, - "the other mount keeps the declaration's own description") -} - -// paramIdentitySpec mounts one path item twice and lists a path-level and an -// operation-level parameter named id. The shared item is declared after its two -// uses, and /b before /a, so a derivation that depended on order would show. -const paramIdentitySpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /b: - $ref: "#/components/pathItems/Shared" - /a: - $ref: "#/components/pathItems/Shared" - /items/{id}: - parameters: - - {name: id, in: path, required: true, schema: {type: string}} - get: - operationId: getItem - parameters: - - {name: id, in: query, schema: {type: string}} - responses: {"200": {description: ok}} - post: - operationId: postItem - responses: {"200": {description: ok}} -components: - pathItems: - Shared: - parameters: - - {name: limit, in: query, schema: {type: integer}} - get: - responses: {"200": {description: ok}} -` - -// paramIDs returns the IDs of op's parameters, keyed by the location each one -// binds. -func paramIDs(op ir.Operation) map[ir.HTTPLocation]ir.ParamID { - out := map[ir.HTTPLocation]ir.ParamID{} - for _, pb := range op.Bindings.HTTP[0].ParamBindings { - out[pb.Location] = pb.Param - } - return out -} - -func TestParams_SameNameInTwoLocationsGetsDistinctIDs(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, paramIdentitySpec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FindOp(t, doc, "getItem") - - require.Len(t, op.Params, 2) - assert.NotEqual(t, op.Params[0].ID, op.Params[1].ID, "id in the path and id in the query are two parameters") - ids := paramIDs(op) - assert.Equal(t, ir.ParamID("param/openapi/paths/~1items~1{id}/get/parameters/id/path"), ids[ir.HTTPLocationPath]) - assert.Equal(t, ir.ParamID("param/openapi/paths/~1items~1{id}/get/parameters/id/query"), ids[ir.HTTPLocationQuery]) - assert.ElementsMatch(t, []ir.ParamID{op.Params[0].ID, op.Params[1].ID}, []ir.ParamID{ids[ir.HTTPLocationPath], ids[ir.HTTPLocationQuery]}, - "each binding names the ID of its own parameter") -} - -func TestParams_IDsFollowNameNotPosition(t *testing.T) { - t.Parallel() - byName := func(first, second string) map[string]ir.ParamID { - doc, diags := parseFull(t, `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /items: - get: - operationId: getItem - parameters: - - {name: `+first+`, in: query, schema: {type: string}} - - {name: `+second+`, in: query, schema: {type: string}} - responses: {"200": {description: ok}} -`) - openapitest.RequireNoErrorDiags(t, diags) - out := map[string]ir.ParamID{} - for _, p := range openapitest.FindOp(t, doc, "getItem").Params { - out[p.Name.Source] = p.ID - } - return out - } - ba, ab := byName("b", "a"), byName("a", "b") - assert.Equal(t, ab, ba, "reordering a parameter list renames nothing") - assert.Equal(t, ir.ParamID("param/openapi/paths/~1items/get/parameters/a/query"), ba["a"]) -} - -func TestParams_PathItemParamIsScopedPerOperation(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, paramIdentitySpec) - openapitest.RequireNoErrorDiags(t, diags) - get, post := openapitest.FindOp(t, doc, "getItem"), openapitest.FindOp(t, doc, "postItem") - - require.Len(t, post.Params, 1) - assert.Equal(t, ir.ParamID("param/openapi/paths/~1items~1{id}/post/parameters/id/path"), post.Params[0].ID) - assert.NotContains(t, []ir.ParamID{get.Params[0].ID, get.Params[1].ID}, post.Params[0].ID, - "a path-item parameter copied into two operations is two parameters") - - var shared []ir.ParamID - for _, g := range doc.Services[0].Groups { - for _, o := range g.Operations { - if len(o.Params) == 1 && o.Params[0].Name.Source == "limit" { - shared = append(shared, o.Params[0].ID) - } - } - } - assert.ElementsMatch(t, []ir.ParamID{ - "param/openapi/paths/~1a/get/parameters/limit/query", - "param/openapi/paths/~1b/get/parameters/limit/query", - }, shared, "a path item mounted twice roots each mount's parameters at that mount") -} - -// repeatSpec returns a document whose one operation, GET /x, merges pathLevel -// and opLevel, written as YAML flow entries. Limit and Limit2 are two -// components holding the same name and location. -func repeatSpec(pathLevel, opLevel []string) string { - list := func(indent string, entries []string) string { - if len(entries) == 0 { - return "" - } - out := indent + "parameters:\n" - for _, e := range entries { - out += indent + " - " + e + "\n" - } - return out - } - return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n /x:\n" + - list(" ", pathLevel) + - " get:\n operationId: g\n" + - list(" ", opLevel) + - " responses: {\"200\": {description: ok}}\n" + - "components:\n parameters:\n" + - " Limit: {name: limit, in: query, schema: {type: integer}}\n" + - " Limit2: {name: limit, in: query, schema: {type: string}}\n" -} - -// TestParams_RepeatedNameAndLocationKeepsEveryParameter covers a merged list -// holding one name and location twice. OpenAPI forbids it, but the bundled -// parser checks only entries written inline, so a $ref'd repeat reaches the -// lowering. Each occurrence is kept under an ID of its own, the first holding -// the bare one, and the repeat is reported at its own list entry. -func TestParams_RepeatedNameAndLocationKeepsEveryParameter(t *testing.T) { - t.Parallel() - const ( - inline = "{name: limit, in: query, schema: {type: boolean}}" - limit = `{$ref: "#/components/parameters/Limit"}` - limit2 = `{$ref: "#/components/parameters/Limit2"}` - ) - tests := []struct { - name string - pathLevel, opLevel []string - first, repeat string - }{ - {name: "one component twice", opLevel: []string{limit, limit}, - first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, - {name: "a component, then an inline entry", opLevel: []string{limit, inline}, - first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, - {name: "an inline entry, then a component", opLevel: []string{inline, limit}, - first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, - {name: "two components holding one slot", opLevel: []string{limit, limit2}, - first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, - {name: "two inline entries", opLevel: []string{inline, inline}, - first: "/paths/~1x/get/parameters/0", repeat: "/paths/~1x/get/parameters/1"}, - {name: "a pair on the path item", pathLevel: []string{limit, limit2}, - first: "/paths/~1x/parameters/0", repeat: "/paths/~1x/parameters/1"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, repeatSpec(tc.pathLevel, tc.opLevel)) - op := openapitest.FindOp(t, doc, "g") - - require.Len(t, op.Params, 2, "neither occurrence is dropped") - assert.Equal(t, ir.ParamID("param/openapi/paths/~1x/get/parameters/limit/query"), op.Params[0].ID, - "the first in list order holds the bare ID") - assert.Equal(t, ir.ParamID("param/openapi/paths/~1x/get/parameters/limit/query/2"), op.Params[1].ID) - bindings := op.Bindings.HTTP[0].ParamBindings - require.Len(t, bindings, 2) - assert.Equal(t, []ir.ParamID{op.Params[0].ID, op.Params[1].ID}, - []ir.ParamID{bindings[0].Param, bindings[1].Param}, "each binding names its own parameter") - - assert.Equal(t, []string{diag.DuplicateParameter + " at " + tc.repeat}, errorFindings(diags), - "the repeat is one error, whatever spells its entries") - msg := openapitest.DiagMessageAt(t, diags, diag.DuplicateParameter, ir.SeverityError, tc.repeat) - assert.Contains(t, msg, tc.first, "the report names the entry the repeat collides with") - }) - } -} - -// TestParams_RepeatsAreNumberedInListOrder holds the occurrence number to the -// entry's place among the parameters holding its slot, and each report to the -// first of them, not to the occurrence just before. -func TestParams_RepeatsAreNumberedInListOrder(t *testing.T) { - t.Parallel() - const limit = `{$ref: "#/components/parameters/Limit"}` - doc, diags := parseFull(t, repeatSpec(nil, []string{ - limit, "{name: id, in: query, schema: {type: string}}", limit, limit, - })) - op := openapitest.FindOp(t, doc, "g") - - const base = "param/openapi/paths/~1x/get/parameters/limit/query" - got := make([]ir.ParamID, 0, len(op.Params)) - for _, p := range op.Params { - got = append(got, p.ID) - } - assert.Equal(t, []ir.ParamID{base, "param/openapi/paths/~1x/get/parameters/id/query", base + "/2", base + "/3"}, got) - for _, repeat := range []string{"/paths/~1x/get/parameters/2", "/paths/~1x/get/parameters/3"} { - msg := openapitest.DiagMessageAt(t, diags, diag.DuplicateParameter, ir.SeverityError, repeat) - assert.Contains(t, msg, "/paths/~1x/get/parameters/0") - } - assert.Equal(t, 2, openapitest.CountDiagsAt(diags, diag.DuplicateParameter, ir.SeverityError)) -} - -// TestParams_RepeatOnAPathItemMountedTwiceIsReportedOnce covers a repeat inside -// a path item two paths $ref. Each mount's operation numbers its own copy, but -// the defect is one written list entry, so it is one report. -func TestParams_RepeatOnAPathItemMountedTwiceIsReportedOnce(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: {$ref: "#/components/pathItems/Shared"} - /b: {$ref: "#/components/pathItems/Shared"} -components: - parameters: - Limit: {name: limit, in: query, schema: {type: integer}} - pathItems: - Shared: - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Limit" - get: - responses: {"200": {description: ok}} -`) - var got []ir.ParamID - for _, g := range doc.Services[0].Groups { - for _, o := range g.Operations { - for _, p := range o.Params { - got = append(got, p.ID) - } - } - } - assert.ElementsMatch(t, []ir.ParamID{ - "param/openapi/paths/~1a/get/parameters/limit/query", - "param/openapi/paths/~1a/get/parameters/limit/query/2", - "param/openapi/paths/~1b/get/parameters/limit/query", - "param/openapi/paths/~1b/get/parameters/limit/query/2", - }, got) - assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateParameter, ir.SeverityError)) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DuplicateParameter, "/components/pathItems/Shared/parameters/1")) -} - -// TestParams_RepeatInAnotherDocumentIsReportedOnce covers the two places a -// repeat is reported, each at the entry where its list is written. A list the -// source writes is load's, even when its entries $ref another document; a list -// another document writes, which load's walk of the source does not reach, is -// the lowering's, at the entry it lowered, in that document. Each repeat is -// reported once. -func TestParams_RepeatInAnotherDocumentIsReportedOnce(t *testing.T) { - t.Parallel() - dir := t.TempDir() - const other = `X: - parameters: - - {name: limit, in: query, schema: {type: integer}} - - {name: limit, in: query, schema: {type: string}} - get: - parameters: - - {name: q, in: query, schema: {type: string}} - - {name: q, in: header, schema: {type: string}} - - {name: q, in: query, schema: {type: string}} - responses: {"200": {description: ok}} -Limit: {name: limit, in: query, schema: {type: integer}} -` - const root = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /x: {$ref: "./other.yaml#/X"} - /y: - get: - parameters: - - $ref: "./other.yaml#/Limit" - - $ref: "./other.yaml#/Limit" - responses: {"200": {description: ok}} -` - require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(other), 0o600)) - path := filepath.Join(dir, "root.yaml") - require.NoError(t, os.WriteFile(path, []byte(root), 0o600)) - - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: path, Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - - require.Len(t, doc.Sources, 2) - assert.Equal(t, filepath.Join(dir, "other.yaml"), doc.Sources[1].Path) - repeat := func(source int, pointer jsontext.Pointer, name, in string, first jsontext.Pointer) ir.Diagnostic { - return ir.Diagnostic{Severity: ir.SeverityError, Code: diag.DuplicateParameter, - Message: fmt.Sprintf("parameter %q in %q repeats the entry at %s", name, in, first), - Provenance: ir.Provenance{Source: source, Pointer: pointer}} - } - var got []ir.Diagnostic - for _, d := range diags { - if d.Code == diag.DuplicateParameter { - got = append(got, d) - } - } - slices.SortFunc(got, func(x, y ir.Diagnostic) int { - return strings.Compare(string(x.Provenance.Pointer), string(y.Provenance.Pointer)) - }) - want := []ir.Diagnostic{ - repeat(1, "/X/get/parameters/2", "q", "query", "/X/get/parameters/0"), - repeat(1, "/X/parameters/1", "limit", "query", "/X/parameters/0"), - repeat(0, "/paths/~1y/get/parameters/1", "limit", "query", "/paths/~1y/get/parameters/0"), - } - assert.Empty(t, cmp.Diff(want, got)) -} - -// TestParameters_PathItemSharedAcrossOperationsInternsOnce is the fix's core -// scenario (issue #36): a path-item-level parameter merged into two sibling -// operations must intern its schema once, at the path item's own pointer — -// not once per operation under a pointer fabricated from that operation's -// unrelated parameter count. -func TestParameters_PathItemSharedAcrossOperationsInternsOnce(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpec(` /pets/{petId}: - parameters: - - name: petId - in: path - required: true - schema: {type: string, enum: [a, b]} - get: - operationId: getPet - responses: {"200": {description: ok}} - delete: - operationId: deletePet - parameters: - - {name: force, in: query, schema: {type: boolean}} - responses: {"200": {description: ok}} -`) - doc, _, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - getPet := openapitest.FindOp(t, doc, "getPet") - deletePet := openapitest.FindOp(t, doc, "deletePet") - require.Len(t, getPet.Params, 1, "get inherits only the shared path-item parameter") - require.Len(t, deletePet.Params, 2, "delete gets its own force plus the shared path-item parameter") - - wantID := ir.TypeID("t/anon/paths/~1pets~1{petId}/parameters/0/schema") - assert.Equal(t, wantID, getPet.Params[0].Type.Target, "get resolves the shared path-item schema") - - byName := openapitest.IndexBy(deletePet.Params, func(p ir.Parameter) string { return p.Name.Source }) - assert.Equal(t, wantID, byName["petId"].Type.Target, "delete resolves the same shared schema, not a copy") - - typeDef, ok := doc.Types[wantID] - require.True(t, ok, "the shared schema is registered under the path item's own pointer") - assert.Equal(t, jsontext.Pointer("/paths/~1pets~1{petId}/parameters/0/schema"), typeDef.Common().Provenance.Pointer) - - _, fabricatedGet := doc.Types[ir.TypeID("t/anon/paths/~1pets~1{petId}/get/parameters/0/schema")] - _, fabricatedDelete := doc.Types[ir.TypeID("t/anon/paths/~1pets~1{petId}/delete/parameters/1/schema")] - assert.False(t, fabricatedGet, "no fabricated per-operation ID for get") - assert.False(t, fabricatedDelete, "no fabricated per-operation ID for delete") -} - -// TestParameters_PathItemSchemaIDStableAcrossOperationParamChanges guards the -// specific regression from issue #36: adding an unrelated operation-level -// parameter must not shift the TypeID already handed out for a sibling -// path-item-level parameter's schema. -func TestParameters_PathItemSchemaIDStableAcrossOperationParamChanges(t *testing.T) { - t.Parallel() - before := openapitest.PathsSpec(` /pets/{petId}: - parameters: - - name: petId - in: path - required: true - schema: {type: string, enum: [a, b]} - get: - operationId: getPet - responses: {"200": {description: ok}} -`) - after := openapitest.PathsSpec(` /pets/{petId}: - parameters: - - name: petId - in: path - required: true - schema: {type: string, enum: [a, b]} - get: - operationId: getPet - parameters: - - {name: verbose, in: query, schema: {type: boolean}} - responses: {"200": {description: ok}} -`) - _, svcBefore, diagsBefore := compileService(t, before) - openapitest.RequireNoErrorDiags(t, diagsBefore) - _, svcAfter, diagsAfter := compileService(t, after) - openapitest.RequireNoErrorDiags(t, diagsAfter) - - opBefore := openapitest.FirstOp(t, svcBefore) - opAfter := openapitest.FirstOp(t, svcAfter) - require.Len(t, opBefore.Params, 1) - require.Len(t, opAfter.Params, 2, "the unrelated verbose parameter adds a second entry") - - byName := openapitest.IndexBy(opAfter.Params, func(p ir.Parameter) string { return p.Name.Source }) - assert.Equal(t, opBefore.Params[0].Type.Target, byName["petId"].Type.Target, - "an unrelated operation-level parameter must not shift the shared path-item schema's ID") -} - -// TestParameters_WebhookPathItemParameterPointer covers the same merge through -// lowerWebhooks: a webhook path-item-level parameter must hoist at the webhook -// path item's own pointer. -func TestParameters_WebhookPathItemParameterPointer(t *testing.T) { - t.Parallel() - spec := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: {} -webhooks: - petEvent: - parameters: - - name: kind - in: query - schema: {type: string, enum: [created, deleted]} - post: - operationId: onPetEvent - responses: {"200": {description: ok}} -` - doc, _, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FindOp(t, doc, "onPetEvent") - require.Len(t, op.Params, 1) - - wantID := ir.TypeID("t/anon/webhooks/petEvent/parameters/0/schema") - assert.Equal(t, wantID, op.Params[0].Type.Target) - typeDef, ok := doc.Types[wantID] - require.True(t, ok) - assert.Equal(t, jsontext.Pointer("/webhooks/petEvent/parameters/0/schema"), typeDef.Common().Provenance.Pointer) -} - -// TestParameters_CallbackPathItemParameterPointer covers the same merge -// through lowerCallbackOps: a callback path-item-level parameter must hoist at -// the callback path item's own pointer, nested under the parent operation and -// callback expression. -func TestParameters_CallbackPathItemParameterPointer(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpec(` /subscribe: - post: - operationId: subscribe - callbacks: - onEvent: - '{$request.body#/callbackUrl}': - parameters: - - name: token - in: query - schema: {type: string, enum: [a, b]} - post: - operationId: onEvent - responses: {"200": {description: ok}} - responses: {"200": {description: ok}} -`) - doc, _, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - cbOp := openapitest.FindOp(t, doc, "onEvent") - require.Len(t, cbOp.Params, 1) - - wantPointer := "/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1callbackUrl}/parameters/0/schema" - wantID := ir.TypeID("t/anon" + wantPointer) - assert.Equal(t, wantID, cbOp.Params[0].Type.Target) - typeDef, ok := doc.Types[wantID] - require.True(t, ok) - assert.Equal(t, jsontext.Pointer(wantPointer), typeDef.Common().Provenance.Pointer) -} - -// TestParameters_ShadowedPathItemParamUsesOperationPointer covers the other -// side of the merge: when the operation redeclares a path-item parameter, the -// operation's own schema must be used and lowered at the operation's own -// pointer — the shadowed path-item schema is never lowered at all. -func TestParameters_ShadowedPathItemParamUsesOperationPointer(t *testing.T) { - t.Parallel() - spec := openapitest.PathsSpec(` /pets/{petId}: - parameters: - - name: petId - in: path - required: true - schema: {type: string, enum: [a, b]} - get: - operationId: getPet - parameters: - - name: petId - in: path - required: true - schema: {type: string, enum: [c, d]} - responses: {"200": {description: ok}} -`) - doc, svc, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - op := openapitest.FirstOp(t, svc) - require.Len(t, op.Params, 1, "the operation parameter shadows the path-item one; no duplicate") - - wantID := ir.TypeID("t/anon/paths/~1pets~1{petId}/get/parameters/0/schema") - assert.Equal(t, wantID, op.Params[0].Type.Target, "the operation's own schema wins, at the operation's pointer") - - enum, ok := doc.Types[wantID].(*ir.Enum) - require.True(t, ok) - require.Len(t, enum.Members, 2) - assert.Equal(t, "c", enum.Members[0].Value.Str, "the operation-level schema's own values are used") - - _, atPathLevel := doc.Types[ir.TypeID("t/anon/paths/~1pets~1{petId}/parameters/0/schema")] - assert.False(t, atPathLevel, "the shadowed path-item schema is never lowered") -} - -// TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder is the hand-rolled two-order -// diff for GitHub #610. A use-site summary or description is neither part of the -// type registry nor a diagnostic, so the in-package order-invariance oracle does -// not reach it. One component referenced by two operations with different -// overrides, declared in both orders, must give each mount its own docs. -func TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder(t *testing.T) { - t.Parallel() - const first = ` /a: - get: - operationId: a - parameters: [{$ref: '#/components/parameters/Limit', description: from a}] - responses: {"200": {description: ok}} -` - const second = ` /b: - get: - operationId: b - parameters: [{$ref: '#/components/parameters/Limit', description: from b}] - responses: {"200": {description: ok}} -` - projection := func(paths string) map[string]string { - t.Helper() - spec := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: -` + paths + `components: - parameters: - Limit: {name: limit, in: query, description: declared, schema: {type: integer}} -` - doc, _, diags := compileService(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - out := map[string]string{} - for _, name := range []string{"a", "b"} { - p, ok := paramBySource(openapitest.FindOp(t, doc, name), "limit") - require.True(t, ok) - out[name] = p.Docs.Description - } - return out - } - assert.Empty(t, cmp.Diff(projection(first+second), projection(second+first)), - "each mount keeps its own override whichever was lowered first") -} diff --git a/compilers/openapi/internal/operation/pathitemref_collisions_test.go b/compilers/openapi/internal/operation/pathitemref_collisions_test.go new file mode 100644 index 00000000..77e162ea --- /dev/null +++ b/compilers/openapi/internal/operation/pathitemref_collisions_test.go @@ -0,0 +1,206 @@ +package operation_test + +import ( + "encoding/json/jsontext" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" +) + +// pathItemRefCollisionSpec declares the same constructs on both sides of one +// $ref: the method key, the summary, the servers, an x-* name, an undeclared key +// and an item parameter pair, typed apart so the one lowered names its side. +const pathItemRefCollisionSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + summary: use summary + description: use description + servers: [{url: https://use.example}] + x-shared: use + bogusBoth: {responses: {"200": {description: USE}}} + parameters: + - {name: dup, in: query, schema: {type: string}} + get: + operationId: useGet + responses: {"200": {description: OK}} + /b: + summary: ref summary + description: ref description + servers: [{url: https://ref.example}] + x-shared: ref + x-refOnly: ref + bogusBoth: {responses: {"200": {description: REF}}} + parameters: + - {name: dup, in: query, schema: {type: integer}} + get: + operationId: refGet + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_CollisionIsLoudAndUseSiteWins pins the collision rule +// GitHub #577 settles: OpenAPI leaves the result of a field written beside a +// $ref undefined, so the use site's declaration wins every construct, as a +// $ref-adjacent schema sibling's does, and the losing one is reported rather +// than dropped in silence. +func TestPathItemRefSiblings_CollisionIsLoudAndUseSiteWins(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefCollisionSpec) + openapitest.RequireNoErrorDiags(t, diags) + + // One finding per colliding construct, each sited at the use site's own key + // and naming the referent's. + for _, at := range []string{ + "/paths/~1a/get", + "/paths/~1a/summary", + "/paths/~1a/description", + "/paths/~1a/servers", + "/paths/~1a/x-shared", + "/paths/~1a/bogusBoth", + "/paths/~1a/parameters/0", + } { + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, at), + "%s is reported at the use site's own key", at) + } + message := openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, + ir.SeverityWarning, "/paths/~1a/get") + assert.Contains(t, message, "/paths/~1b/get", "the referent's position is named too") + + // The use site's declaration is the one lowered, and the referent's get is + // not lowered at this mount at all. + getA := opByPath(t, doc, "GET", "/a") + assert.Equal(t, "useGet", getA.Name.Source) + assert.Equal(t, jsontext.Pointer("/paths/~1a/get"), getA.Provenance.Pointer) + getB := opByPath(t, doc, "GET", "/b") + assert.Equal(t, "refGet", getB.Name.Source, "the referent still lowers at its own path") + + summary, ok := getA.Unmodeled["openapi:pathItemSummary"] + require.True(t, ok) + assert.JSONEq(t, `"use summary"`, string(summary.Value), "the use site's summary survives") + servers, ok := getA.Unmodeled["openapi:servers"] + require.True(t, ok) + assert.JSONEq(t, `[{"url":"https://use.example"}]`, string(servers.Value)) + ext, ok := getA.Unmodeled["openapi:pathItem/x-shared"] + require.True(t, ok) + assert.JSONEq(t, `"use"`, string(ext.Value)) + only, ok := getA.Unmodeled["openapi:pathItem/x-refOnly"] + require.True(t, ok, "an extension only the referent declares is kept beside the one it cedes") + assert.Equal(t, jsontext.Pointer("/paths/~1b/x-refOnly"), only.Provenance.Pointer) + bogus, ok := getA.Unmodeled["openapi:pathItem/bogusBoth"] + require.True(t, ok) + assert.JSONEq(t, `{"responses":{"200":{"description":"USE"}}}`, string(bogus.Value), + "an undeclared key is the use site's too, as every other construct is") + assert.Equal(t, jsontext.Pointer("/paths/~1a/bogusBoth"), bogus.Provenance.Pointer) + assert.False(t, openapitest.HasDiag(diags, diag.UnknownKeyEntryTaken), + "the referent's key is not written, so nothing is left to say it was dropped") + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, ir.SeverityWarning, + "/paths/~1a/parameters/0"), `the parameter "dup" in query is declared here`, "a parameter is named as written") + require.Len(t, getA.Params, 1, "the referent's parameter is not lowered beside the use site's") + assert.Equal(t, ir.TypeID("t/prim/string"), getA.Params[0].Type.Target, "the use site's is the one kept") + assert.Equal(t, jsontext.Pointer("/paths/~1a/parameters/0"), getA.Params[0].Provenance.Pointer) + + // One operation at the mount, so no OpID is claimed twice. + assert.False(t, openapitest.HasDiag(diags, diag.DuplicateOperationID)) + assert.False(t, openapitest.HasDiag(diags, diag.ConflictingOperationID)) +} + +// pathItemRefOpIDSpec has the operation written beside the $ref repeat the +// referent's operationId, which is two declarations of one id rather than one +// declaration mounted twice (GitHub #502). +const pathItemRefOpIDSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + get: + operationId: sameId + responses: {"200": {description: OK}} + /b: + get: + operationId: sameId + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_RepeatedOpIDIsAConflict pins which of the two +// operationId findings a repeated id reaches: the sibling's operation is a +// declaration of its own, so repeating the referent's id is the document writing +// the id twice, not one declaration mounted twice. +func TestPathItemRefSiblings_RepeatedOpIDIsAConflict(t *testing.T) { + t.Parallel() + _, diags := parseFull(t, pathItemRefOpIDSpec) + + assert.True(t, openapitest.HasDiag(diags, diag.ConflictingOperationID), + "two declarations writing one id is a conflict") + assert.False(t, openapitest.HasDiag(diags, diag.DuplicateOperationID), + "and not a remount: the sibling operation is not the referent's declaration") +} + +// pathItemRefCollisionReversedSpec is pathItemRefCollisionSpec with the two +// paths declared the other way round, for the two-order diff. +const pathItemRefCollisionReversedSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /b: + summary: ref summary + description: ref description + servers: [{url: https://ref.example}] + x-shared: ref + x-refOnly: ref + bogusBoth: {responses: {"200": {description: REF}}} + parameters: + - {name: dup, in: query, schema: {type: integer}} + get: + operationId: refGet + responses: {"200": {description: OK}} + /a: + $ref: '#/paths/~1b' + summary: use summary + description: use description + servers: [{url: https://use.example}] + x-shared: use + bogusBoth: {responses: {"200": {description: USE}}} + parameters: + - {name: dup, in: query, schema: {type: string}} + get: + operationId: useGet + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_CollisionIsOrderIndependent pins that what a colliding +// mount reports depends on the document and not on the order its two paths were +// declared in: the two fixtures differ only in that order, so the findings must +// be the same set of (code, pointer) pairs. +func TestPathItemRefSiblings_CollisionIsOrderIndependent(t *testing.T) { + t.Parallel() + forwardDoc, forward := parseFull(t, pathItemRefCollisionSpec) + reversedDoc, reversed := parseFull(t, pathItemRefCollisionReversedSpec) + + assert.Equal(t, collisionFindings(t, forward), collisionFindings(t, reversed), + "a collision is a property of the document, not of the order it was written in") + for _, at := range []struct{ method, uri string }{{"GET", "/a"}, {"GET", "/b"}} { + want, got := opByPath(t, forwardDoc, at.method, at.uri), opByPath(t, reversedDoc, at.method, at.uri) + assert.Empty(t, cmp.Diff(want, got), "what %s %s lowers to, the winners included, follows no order", at.method, at.uri) + } +} + +// collisionFindings returns the colliding constructs' findings as sorted +// "code pointer" strings, which is what must not depend on declaration order. +func collisionFindings(t *testing.T, diags []ir.Diagnostic) []string { + t.Helper() + var out []string + for _, d := range diags { + if d.Code == diag.PathItemRefCollision { + out = append(out, d.Code+" "+string(d.Provenance.Pointer)) + } + } + slices.Sort(out) + return out +} diff --git a/compilers/openapi/internal/operation/pathitemref_fields_test.go b/compilers/openapi/internal/operation/pathitemref_fields_test.go new file mode 100644 index 00000000..27fc1bb0 --- /dev/null +++ b/compilers/openapi/internal/operation/pathitemref_fields_test.go @@ -0,0 +1,231 @@ +package operation_test + +import ( + "encoding/json/jsontext" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/engine/reader" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irverify" +) + +// pathItemRefChainSpec chains /a to /b to /c, with fields beside both $refs. +const pathItemRefChainSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + summary: from a + get: + operationId: getFromA + responses: {"200": {description: OK}} + /b: + $ref: '#/paths/~1c' + summary: from b + servers: [{url: https://b.example}] + get: + operationId: getFromB + responses: {"200": {description: OK}} + put: + operationId: putFromB + responses: {"200": {description: OK}} + /c: + get: + operationId: getFromC + responses: {"200": {description: OK}} + post: + operationId: postFromC + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_AChainCarriesTheFieldsBesideEachRef pins a chain: the +// path item a $ref names is that item with the fields beside its own $ref, so +// /a mounts /b's put and /c's post, takes /b's servers, and keeps its own get +// and summary over both deeper ones, each loser named once. +func TestPathItemRefSiblings_AChainCarriesTheFieldsBesideEachRef(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefChainSpec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, irverify.Verify(doc)) + + for method, decl := range map[string]jsontext.Pointer{ + "GET": "/paths/~1a/get", "PUT": "/paths/~1b/put", "POST": "/paths/~1c/post", + } { + op := opByPath(t, doc, method, "/a") + assert.Equal(t, decl, op.Provenance.Pointer, "%s at /a is declared where the chain writes it", method) + summary, ok := op.Unmodeled["openapi:pathItemSummary"] + require.True(t, ok) + assert.JSONEq(t, `"from a"`, string(summary.Value)) + servers, ok := op.Unmodeled["openapi:servers"] + require.True(t, ok, "%s at /a carries the servers beside /b's $ref", method) + assert.JSONEq(t, `[{"url":"https://b.example"}]`, string(servers.Value)) + } + var collided []string + for _, d := range diags { + if d.Code == diag.PathItemRefCollision { + _, other, _ := strings.Cut(d.Message, "and again at ") + other, _, _ = strings.Cut(other, " in the item") + collided = append(collided, string(d.Provenance.Pointer)+" "+other) + } + } + slices.Sort(collided) + assert.Equal(t, []string{ + "/paths/~1a/get /paths/~1b/get", + "/paths/~1a/get /paths/~1c/get", + "/paths/~1a/summary /paths/~1b/summary", + "/paths/~1b/get /paths/~1c/get", + }, collided, "each declaration a nearer one takes over is named once, at the one kept") +} + +// TestPathItemRefSiblings_FieldsAnotherDocumentWritesAreReported pins the chain +// this compiler cannot read all of: the fields beside a $ref in another +// document are not modelled, and saying so is what keeps them from being +// dropped in silence. +func TestPathItemRefSiblings_FieldsAnotherDocumentWritesAreReported(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(`openapi: 3.1.0 +info: {title: O, version: "1"} +paths: + /x: + $ref: '#/components/pathItems/P' + put: {responses: {"200": {description: OK}}} +components: + pathItems: + P: + get: {responses: {"200": {description: OK}}} +`), 0o600)) + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(`openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: {$ref: './other.yaml#/paths/~1x'} +`)}}, compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + + opByPath(t, doc, "GET", "/a") + message := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/paths/~1a") + assert.Contains(t, message, "./other.yaml#/paths/~1x", "the reference reaching the unread fields is named") + assert.Contains(t, message, "in another document") + assert.Contains(t, message, "not lowered") +} + +// TestPathItemRefSiblings_FieldsTheWalkDoesNotReachAreReported pins the other +// chain this compiler cannot read all of: a hop in this document at a position +// no object its walk holds, here under an x-* key, is not modelled either, and +// the report says where it is rather than calling it another document's. +func TestPathItemRefSiblings_FieldsTheWalkDoesNotReachAreReported(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +x-shared: + item: + $ref: '#/paths/~1b' + put: {operationId: putShared, responses: {"200": {description: OK}}} +paths: + /a: {$ref: '#/x-shared/item'} + /b: + get: {operationId: getB, responses: {"200": {description: OK}}} +`) + openapitest.RequireNoErrorDiags(t, diags) + + assert.Equal(t, "getB", opByPath(t, doc, "GET", "/a").Name.Source, "the item the chain ends at still mounts") + message := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/paths/~1a") + assert.Contains(t, message, "#/x-shared/item", "the reference reaching the unread fields is named") + assert.Contains(t, message, "does not reach") + assert.NotContains(t, message, "another document", "the hop is this document's") +} + +// pathItemRefCallbackSiblingSpec writes an expression beside a Callback +// Object's $ref, which OpenAPI says to ignore: a Reference Object's other +// fields are not the callback's. +const pathItemRefCallbackSiblingSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /one: + post: + operationId: one + callbacks: + cb: + $ref: '%s#/components/callbacks/CB' + '{$request.body#/url}': + $ref: '#/components/pathItems/T' + patch: + operationId: ignoredSibling + responses: {"200": {description: OK}} + responses: {"200": {description: OK}} +components: + pathItems: + T: + post: {responses: {"200": {description: OK}}} + callbacks: + CB: + '{$request.body#/url}': + post: + operationId: cbPost + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_AnExpressionBesideACallbackRefIsIgnored pins where +// the fields beside a $ref are read from: the source, at the $ref that writes +// them, never at a position another document's content was mounted at, so an +// expression beside a Callback Object's $ref is ignored however the callback +// resolves. +func TestPathItemRefSiblings_AnExpressionBesideACallbackRefIsIgnored(t *testing.T) { + t.Parallel() + dir := t.TempDir() + other := strings.Replace(pathItemRefCallbackSiblingSpec, "%s", "", 1) + require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(other), 0o600)) + for _, at := range []string{"", "./other.yaml"} { + doc, _, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(strings.Replace(pathItemRefCallbackSiblingSpec, "%s", at, 1))}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + openapitest.FindOp(t, doc, "cbPost") + for _, g := range doc.Services[0].Groups { + for _, op := range g.Operations { + assert.NotEqual(t, "ignoredSibling", op.Name.Source, "callback %q", at) + } + } + } +} + +// TestPathItemRefSiblings_MalformedFieldsAreReported pins that the fields +// beside a $ref are validated as an inline item's are: a value of the wrong +// type is an error where it is written, not accepted in silence. +func TestPathItemRefSiblings_MalformedFieldsAreReported(t *testing.T) { + t.Parallel() + _, diags := parseFull(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + put: + deprecated: notabool + parameters: [{name: q, in: query, required: maybe, schema: {type: string}}] + responses: {"200": {description: OK}} + /b: + get: {responses: {"200": {description: OK}}} +`) + var lines []int + for _, d := range diags { + if d.Code == diag.Validation+"/validation-type-mismatch" { + lines = append(lines, d.Provenance.Position.Line) + } + } + slices.Sort(lines) + assert.Equal(t, []int{7, 8}, lines, "each value of the wrong type is reported where it is written") +} diff --git a/compilers/openapi/internal/operation/pathitemref_references_test.go b/compilers/openapi/internal/operation/pathitemref_references_test.go new file mode 100644 index 00000000..a6ca93a2 --- /dev/null +++ b/compilers/openapi/internal/operation/pathitemref_references_test.go @@ -0,0 +1,810 @@ +package operation_test + +import ( + "encoding/json/jsontext" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/ids" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/engine/reader" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irverify" +) + +// pathItemRefSiblingsExternalFixture's path item carries a $ref plus siblings, +// and the operation written beside the $ref writes a parameter reference that +// leaves the document for the target fixture beside it +// (resolve_target_path_item_sibling.yaml). +const pathItemRefSiblingsExternalFixture = "../../../../testdata/openapi/resolve_main_path_item_sibling.yaml" + +// TestPathItemRefSiblings_ExternalRefFollowsTheCompilesPolicy pins that a +// reference written beside a $ref follows the compile's external-reference +// policy, as every reference the load phase resolves does. With the option the +// target file's component lands on the IR at the sibling operation; without it +// the reference is reported unresolved once, where it is written. +func TestPathItemRefSiblings_ExternalRefFollowsTheCompilesPolicy(t *testing.T) { + t.Parallel() + data, err := os.ReadFile(pathItemRefSiblingsExternalFixture) + require.NoError(t, err) + + compiled := func(allow bool) (*ir.Document, []ir.Diagnostic) { + t.Helper() + opts := compilers.Options{FormatOptions: openapi.Options{AllowExternalRefs: allow}} + if allow { + opts.Reader = reader.Disk{} + } + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: pathItemRefSiblingsExternalFixture, Data: append([]byte(nil), data...)}}, opts) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, diags + } + + allowed, allowedDiags := compiled(true) + openapitest.RequireNoErrorDiags(t, allowedDiags) + getA := opByPath(t, allowed, "GET", "/a") + require.Len(t, getA.Params, 1, "the reference beside the $ref follows into the second file") + assert.Equal(t, "extSibling", getA.Params[0].Name.Source) + + refused, refusedDiags := compiled(false) + assert.Equal(t, []string{"/paths/~1a/get/parameters/0"}, unresolvedAt(refusedDiags), + "without the option the sibling's external reference is reported once, where it is written") + refusedA := opByPath(t, refused, "GET", "/a") + assert.Empty(t, refusedA.Params, "and no unresolved parameter reaches the IR") +} + +// crossDocumentTarget is a path item in another document whose item-level +// parameter's schema names that document's own S, which the referring +// document also declares, differently. +const crossDocumentTarget = `openapi: 3.1.0 +info: {title: Target, version: "1"} +paths: + /b: + parameters: + - {name: inl, in: header, schema: {$ref: '#/components/schemas/S'}} + post: + operationId: postB + responses: {"200": {description: OK}} +components: + schemas: + S: {type: integer} +` + +// crossDocumentUse refers to crossDocumentTarget's path item and writes an +// item parameter and an operation with one of its own beside the $ref. +const crossDocumentUse = `openapi: 3.1.0 +info: {title: Main, version: "1"} +paths: + /a: + $ref: './target.yaml#/paths/~1b' + parameters: + - {$ref: '#/components/parameters/P'} + get: + operationId: getA + parameters: + - {$ref: '#/components/parameters/Q'} + responses: {"200": {description: OK}} +components: + parameters: + P: {name: mainP, in: query, schema: {type: string}} + Q: {name: mainQ, in: query, schema: {type: string}} + schemas: + S: {type: string} +` + +// TestPathItemRefSiblings_EachSideReadsInItsOwnDocument pins the scope each +// side of a cross-document $ref lowers in, since every operation at the mount +// carries both sides' item parameters. Read in the source, the referent's +// parameter on the use site's operation bound the source's S; read in the +// referent's document, the use site's parameters lost the pointer of their +// declaration. +func TestPathItemRefSiblings_EachSideReadsInItsOwnDocument(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "target.yaml"), []byte(crossDocumentTarget), 0o600)) + use := filepath.Join(dir, "main.yaml") + doc, _, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: use, Data: []byte(crossDocumentUse)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + + postB, getA := opByPath(t, doc, "POST", "/a"), opByPath(t, doc, "GET", "/a") + referent := paramNamed(t, getA, "inl") + assert.Equal(t, paramNamed(t, postB, "inl").Type, referent.Type, + "the referent's parameter binds the same type on either side's operation") + assert.NotEqual(t, ir.TypeID("t/openapi/components/schemas/S"), referent.Type.Target, + "and never the source's S") + assert.Equal(t, jsontext.Pointer("/components/parameters/P"), paramNamed(t, postB, "mainP").Provenance.Pointer, + "the use site's parameter keeps its declaration's pointer on the referent's operation") + assert.Equal(t, jsontext.Pointer("/components/parameters/Q"), paramNamed(t, getA, "mainQ").Provenance.Pointer, + "and the use site's operation reads its own references in the source") +} + +// paramNamed returns op's parameter whose source name is name. +func paramNamed(t *testing.T, op ir.Operation, name string) ir.Parameter { + t.Helper() + for _, p := range op.Params { + if p.Name.Source == name { + return p + } + } + require.Failf(t, "parameter not found", "%s has no parameter %q", op.Name.Source, name) + return ir.Parameter{} +} + +// TestPathItemRefSiblings_ReferenceKindsResolve pins that a reference written +// inside the fields beside a $ref is resolved before it is lowered, which the +// load phase does with the rest of the document. +// +// It exercises every reference kind a path-item subtree reaches: a parameter, a +// request body, a response and a callback on the sibling operation, and a +// header, a link and an example on its response. A Link Object lowers to no +// node of its own, only to preserveResponseExtras' verbatim map, so its +// resolving half is TestPathItemRefSiblings_UnresolvedLinkIsReported +// (GitHub #275). +func TestPathItemRefSiblings_ReferenceKindsResolve(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefKindsSpec) + openapitest.RequireNoErrorDiags(t, diags) + + getA := opByPath(t, doc, "GET", "/a") + require.Len(t, getA.Params, 1, "a $ref'd parameter beside the $ref resolves") + assert.Equal(t, "refParam", getA.Params[0].Name.Source) + require.NotNil(t, getA.Request, "a $ref'd request body resolves") + assert.Equal(t, "application/json", getA.Request.Contents[0].MediaType) + + success := responseByStatus(t, getA, 200) + assert.Equal(t, 200, success.Conditions.StatusCodes[0].From, "a $ref'd response resolves") + + created := responseByStatus(t, getA, 201) + require.Len(t, created.Headers, 1, "a $ref'd header on a sibling response resolves") + assert.Equal(t, "X-Rate", created.Headers[0].WireName) + + require.NotNil(t, created.Payload, "the response written beside the $ref keeps its body") + require.NotEmpty(t, created.Payload.Contents) + examples := created.Payload.Contents[0].Examples + require.Len(t, examples, 1, "a $ref'd example on a sibling media type resolves") + assert.Equal(t, "refEx", examples[0].Name) + require.NotNil(t, examples[0].Value, "the referenced example's own value is lowered") + + links, ok := created.Unmodeled["openapi:links"] + require.True(t, ok, "the sibling response's $ref'd link is kept verbatim") + assert.Contains(t, string(links.Value), "RefL", + "a Link Object has no IR home, so the map reaches the IR under its own key") + + cbPost := openapitest.FindOp(t, doc, "cbPost") + assert.Equal(t, ir.OpID("op/openapi/paths/~1a/get/callbacks/onEvent/{$request.body#~1url}/post"), cbPost.ID, + "a $ref'd callback resolves, and its operation mounts under the operation beside the $ref") + require.Len(t, getA.Bindings.HTTP[0].Callbacks, 1) + assert.Equal(t, []ir.OpID{cbPost.ID}, getA.Bindings.HTTP[0].Callbacks[0].Operations) +} + +// responseByStatus returns the single success response an operation declares for +// code, requiring it to be there rather than indexing into a list that a second +// response would have made ambiguous. +func responseByStatus(t *testing.T, op ir.Operation, code int) ir.Response { + t.Helper() + for _, r := range op.Responses { + for _, sc := range r.Conditions.StatusCodes { + if sc.From == code && sc.To == code { + return r + } + } + } + t.Fatalf("operation %s declares no response for status %d", op.ID, code) + return ir.Response{} +} + +const pathItemRefKindsSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + get: + operationId: getA + parameters: + - {$ref: '#/components/parameters/RefP'} + requestBody: {$ref: '#/components/requestBodies/RefB'} + responses: + "200": {$ref: '#/components/responses/RefR'} + "201": + description: created + headers: + X-Rate: {$ref: '#/components/headers/RefH'} + content: + application/json: + schema: {type: string} + examples: + refEx: {$ref: '#/components/examples/RefE'} + links: + next: {$ref: '#/components/links/RefL'} + callbacks: + onEvent: {$ref: '#/components/callbacks/RefC'} + /b: + post: + operationId: postB + responses: {"200": {description: OK}} +components: + parameters: + RefP: {name: refParam, in: query, schema: {type: string}} + requestBodies: + RefB: + content: + application/json: {schema: {type: string}} + responses: + RefR: + description: ok + content: + application/json: {schema: {type: string}} + headers: + RefH: {schema: {type: integer}} + examples: + RefE: {value: {from: component}} + links: + RefL: {operationId: getA} + callbacks: + RefC: + '{$request.body#/url}': + post: + operationId: cbPost + responses: {"200": {description: OK}} +` + +// pathItemRefDanglingLinkSpec writes a $ref'd link on a sibling response whose +// target names nothing. +const pathItemRefDanglingLinkSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + get: + operationId: getA + responses: + "200": + description: ok + links: + next: {$ref: '#/components/links/Missing'} + /b: + post: + operationId: postB + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_UnresolvedLinkIsReported pins that resolution reaches +// a link reference written beside a $ref: a link whose declaration is +// absent is reported like any other unresolved reference there, which is what +// makes the resolving half of the kinds fixture above mean something. It is also +// the one reference kind whose resolution no IR field records — nothing reads a +// resolved link — so the diagnostic is the only evidence that it happened. +func TestPathItemRefSiblings_UnresolvedLinkIsReported(t *testing.T) { + t.Parallel() + _, diags := parseFull(t, pathItemRefDanglingLinkSpec) + assert.Equal(t, []string{"/paths/~1a/get/responses/200/links/next"}, unresolvedAt(diags), + "the link reference beside the $ref is resolved with the document and reported where it is written") +} + +// pathItemRefBrokenSpec writes a reference beside the $ref that names nothing. +const pathItemRefBrokenSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + parameters: + - {$ref: '#/components/parameters/Missing'} + get: + operationId: getA + responses: {"200": {description: OK}} + /b: + post: + operationId: postB + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_UnresolvedIsReported pins that a reference written +// beside a $ref that does not resolve is reported once, where it is written, +// rather than silently reaching the IR unresolved. +func TestPathItemRefSiblings_UnresolvedIsReported(t *testing.T) { + t.Parallel() + _, diags := parseFull(t, pathItemRefBrokenSpec) + + assert.Equal(t, []string{"/paths/~1a/parameters/0"}, unresolvedAt(diags), + "the failure is reported once, at the reference written beside the $ref") +} + +// unresolvedAt returns the pointer of every unresolved-ref finding, in the +// order they were reported. +func unresolvedAt(diags []ir.Diagnostic) []string { + var at []string + for _, d := range diags { + if d.Code == diag.UnresolvedRef { + at = append(at, string(d.Provenance.Pointer)) + } + } + return at +} + +// TestPathItemRefSiblings_AForeignReferentKeepsItsOwnPositions pins a use site +// writing item parameters beside a $ref to another document's path item: each +// parameter keeps the type its own schema declares, the referent's lowered +// where that document declares it. Where the bindings' table places no end, as +// for a key that document writes twice, the referent is lowered at the use +// site, under its "$ref", so the two still share no pointer. A finding names +// the referent's positions by the reference. +func TestPathItemRefSiblings_AForeignReferentKeepsItsOwnPositions(t *testing.T) { + t.Parallel() + const item = ` + parameters: + - {name: refP, in: header, schema: {type: object, properties: {fromRef: {type: integer}}}} + get: + operationId: refGet + responses: {"200": {description: OK}} + post: + operationId: postB + responses: {"200": {description: OK}} +` + for name, tc := range map[string]struct { + target, ref string + refType ir.TypeID + }{ + "declared once": { + target: "openapi: 3.1.0\ninfo: {title: Target, version: \"1\"}\npaths:\n /b:" + item, + ref: "./target.yaml#/paths/~1b", + refType: ids.In("target.yaml").AnonType("/paths/~1b/parameters/0/schema"), + }, + "a key written twice": { + target: "B:" + strings.ReplaceAll(item, "\n ", "\n ") + "B: {}\n", + ref: "./target.yaml#/B", + refType: ids.Document{}.AnonType("/paths/~1a/$ref/parameters/0/schema"), + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "target.yaml"), []byte(tc.target), 0o600)) + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(`openapi: 3.1.0 +info: {title: Main, version: "1"} +paths: + /a: + $ref: '` + tc.ref + `' + parameters: + - {name: useP, in: query, schema: {type: object, properties: {fromUse: {type: string}}}} + get: + operationId: useGet + responses: {"200": {description: OK}} +`)}}, compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + assert.Empty(t, irverify.Verify(doc)) + + for _, op := range []ir.Operation{opByPath(t, doc, "GET", "/a"), opByPath(t, doc, "POST", "/a")} { + assert.Equal(t, ids.Document{}.AnonType("/paths/~1a/parameters/0/schema"), paramNamed(t, op, "useP").Type.Target) + assert.Equal(t, tc.refType, paramNamed(t, op, "refP").Type.Target, + "the referent's parameter keeps its own schema, not the use site's at the same index") + } + message := openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, ir.SeverityWarning, "/paths/~1a/get") + assert.Contains(t, message, tc.ref+"/get", "another document's position is named by the reference") + }) + } +} + +// TestPathItemRefSiblings_AWholeDocumentReferentIsNamedByItsFragment pins how a +// finding names a position in a document a $ref takes whole, the common shape +// of a path split into its own file: with the empty fragment, so the pointer +// after it reads as one rather than as a file path. +func TestPathItemRefSiblings_AWholeDocumentReferentIsNamedByItsFragment(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "item.yaml"), []byte(`get: + operationId: itemGet + responses: {"200": {description: OK}} +`), 0o600)) + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(`openapi: 3.1.0 +info: {title: Main, version: "1"} +paths: + /a: + $ref: './item.yaml' + get: + operationId: useGet + responses: {"200": {description: OK}} +`)}}, compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + + message := openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, ir.SeverityWarning, "/paths/~1a/get") + assert.Contains(t, message, "again at ./item.yaml#/get in", "the position is the fragment of the document named") +} + +// TestPathItemRefSiblings_AnAliasDeclaresAtItsOwnPosition pins where an alias +// of a use site declares the fields beside its $ref: at the alias's own path, +// as an aliased inline item declares there, not where the load phase first met +// the mapping. +func TestPathItemRefSiblings_AnAliasDeclaresAtItsOwnPosition(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, item string }{ + {"beside a $ref", `{$ref: '#/paths/~1b', put: {operationId: putA, responses: {"200": {description: OK}}}}`}, + {"an inline item", `{put: {operationId: putA, responses: {"200": {description: OK}}}}`}, + } { + doc, diags := parseFull(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: &a `+tc.item+` + /x: *a + /b: + get: {operationId: getB, responses: {"200": {description: OK}}} +`) + openapitest.RequireNoErrorDiags(t, diags) + + assert.Equal(t, jsontext.Pointer("/paths/~1a/put"), opByPath(t, doc, "PUT", "/a").Provenance.Pointer, tc.name) + assert.Equal(t, jsontext.Pointer("/paths/~1x/put"), opByPath(t, doc, "PUT", "/x").Provenance.Pointer, + "%s: the alias declares at its own path", tc.name) + } +} + +// TestPathItemRefSiblings_AMergedRefIsNoReference pins the library's reading of +// a $ref a merge key supplies: none, so the item is inline, and nothing in it +// collides with itself. +func TestPathItemRefSiblings_AMergedRefIsNoReference(t *testing.T) { + t.Parallel() + _, diags := parseFull(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +x-base: &base {$ref: '#/paths/~1b'} +paths: + /a: + <<: *base + put: {responses: {"200": {description: OK}}} + /b: + get: {responses: {"200": {description: OK}}} +`) + assert.False(t, openapitest.HasDiag(diags, diag.PathItemRefCollision)) +} + +// besideRefPaths are /a and /b of every case below: /a writes get, put and its +// item parameters beside a $ref to /b, which declares a get and parameters too, +// so the resolver reads a pointer through /a's $ref to /b's, while /a's mount +// declares its own at the pointer the reference spells. /a is anchored for an +// alias to bring the same fields to a second path. +const besideRefPaths = ` /a: &a + $ref: '#/paths/~1b' + parameters: [{name: near, in: query, schema: {type: string}}] + get: + operationId: getA + requestBody: {content: {application/json: {schema: {type: object, properties: {near: {type: string}}}}}} + responses: {"200": {description: OK}} + put: + operationId: putA + requestBody: {content: {application/json: {schema: {type: object, properties: {nearPut: {type: string}}}}}} + responses: {"200": {description: OK}} + /b: + parameters: [{name: far, in: query, schema: {type: integer}}] + get: + operationId: getB + requestBody: {content: {application/json: {schema: {type: object, properties: {far: {type: string}}}}}} + responses: {"200": {description: OK}} + post: + operationId: postB + requestBody: {content: {application/json: {schema: {type: object, properties: {farPost: {type: string}}}}}} + responses: {"200": {description: OK}} +` + +// besideRefSpec returns a document holding besideRefPaths, then paths, then +// rest, whose top-level keys follow. +func besideRefSpec(paths, rest string) string { + return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + besideRefPaths + paths + rest +} + +// besideRefChain is /c taking its request body through a component that names +// /a's get request body, which the component walk resolves after the paths, so +// the reference resolves whichever path the document declares first. +const besideRefChain = ` /c: + post: + operationId: postC + requestBody: {$ref: '#/components/requestBodies/X'} + responses: {"200": {description: OK}} +` + +// TestPathItemRefSiblings_APointerPastTheRefIsRefusedInEitherOrder pins the +// refusal of a reference into the fields beside a $ref (GitHub #824). The +// resolver read #/paths/~1a/get/requestBody as /b's, and the mount declares +// /a's own at that pointer, so whichever lowered first gave both its type: /c's +// body took /a's schema when /a came first. Refused, neither order lowers one, +// both report it where it is written, and both keep X, which nothing lowers, +// verbatim. +func TestPathItemRefSiblings_APointerPastTheRefIsRefusedInEitherOrder(t *testing.T) { + t.Parallel() + const components = "components:\n requestBodies:\n X: {$ref: '#/paths/~1a/get/requestBody'}\n" + first, firstDiags := parseFull(t, besideRefSpec(besideRefChain, components)) + second, secondDiags := parseFull(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ + besideRefChain+besideRefPaths+components) + + for name, doc := range map[string]*ir.Document{"/a first": first, "/c first": second} { + postC := openapitest.FindOp(t, doc, "postC") + assert.Nil(t, postC.Request, "%s: /c takes no body through the refused reference", name) + getA := openapitest.FindOp(t, doc, "getA") + require.NotNil(t, getA.Request, name) + body, ok := doc.Types[getA.Request.Contents[0].Type.Target].(*ir.Model) + require.True(t, ok, name) + assert.Equal(t, "near", body.Properties[0].WireName, "%s: /a keeps its own body", name) + assert.Equal(t, ir.ReasonNoIRHome, doc.Unmodeled["openapi:components/requestBodies/X"].Reason, + "%s: the component only the refused chain passes through is kept", name) + } + assert.Empty(t, cmp.Diff(first.Types, second.Types), "the registry does not depend on the order") + for name, diags := range map[string][]ir.Diagnostic{"/a first": firstDiags, "/c first": secondDiags} { + errs := errorsAt(diags) + assert.Len(t, errs["/paths/~1c/post/requestBody"], 1, "%s: reported once at /c", name) + assert.Len(t, errs["/components/requestBodies/X"], 1, "%s: and once at the component", name) + } + assert.Contains(t, errorsAt(firstDiags)["/paths/~1c/post/requestBody"][0], "GitHub #824") +} + +// TestPathItemRefSiblings_AnAnswerWrittenThereIsTakenInEitherOrder pins the one +// answer the refusal takes. With external references allowed, the load phase +// holds the source for the resolver, so the chain through X finds the body /a's +// get writes, by a pointer or by the source's file name, and through /z, which +// aliases /a, a copy of it. It finds put's too, which only /a writes and which +// without them the resolver finds nowhere (GitHub #824). Each is the +// declaration the mount interns at the same pointer, so /c lowers it in both +// declaration orders with nothing reported. +func TestPathItemRefSiblings_AnAnswerWrittenThereIsTakenInEitherOrder(t *testing.T) { + t.Parallel() + compile := func(src string) (*ir.Document, []ir.Diagnostic) { + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "spec.yaml", Data: []byte(src)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, diags + } + for _, tc := range []struct{ ref, method, mount, wire string }{ + {"#/paths/~1a/get/requestBody", "GET", "/a", "near"}, + {"spec.yaml#/paths/~1a/get/requestBody", "GET", "/a", "near"}, + {"spec.yaml#/paths/~1z/get/requestBody", "GET", "/z", "near"}, + {"#/paths/~1a/put/requestBody", "PUT", "/a", "nearPut"}, + } { + ref := tc.ref + components := "components:\n requestBodies:\n X: {$ref: '" + ref + "'}\n" + first, firstDiags := compile(besideRefSpec(" /z: *a\n"+besideRefChain, components)) + second, secondDiags := compile("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + + besideRefChain + besideRefPaths + " /z: *a\n" + components) + + for name, doc := range map[string]*ir.Document{"/a first": first, "/c first": second} { + own := openapitest.BodyTarget(t, opByPath(t, doc, tc.method, tc.mount).Request) + body, ok := doc.Types[own].(*ir.Model) + require.True(t, ok, "%s %s", ref, name) + assert.Equal(t, tc.wire, body.Properties[0].WireName, "%s %s: %s keeps its own body", ref, name, tc.mount) + assert.Equal(t, own, openapitest.BodyTarget(t, openapitest.FindOp(t, doc, "postC").Request), + "%s %s: /c takes the same one", ref, name) + } + assert.Empty(t, cmp.Diff(first.Types, second.Types), "%s: the registry does not depend on the order", ref) + assert.Empty(t, errorsAt(firstDiags), ref) + assert.Empty(t, errorsAt(secondDiags), ref) + } +} + +// TestPathItemRefSiblings_AReferenceIntoTheFieldsBesideARefIsRefused pins the +// refusal on every route a reference reaches a pointer by: a $ref of each kind, +// a discriminator mapping value, a reference written inside the fields +// themselves, and an alias's second position. A "#/$defs/..." pointer written +// in those fields is one too, since its definition is read through the $ref. +// Each is one error at the reference saying why, and nothing interned for it. A +// pointer to a key nothing writes beside the $ref is left as main reads it, and +// one only those fields write fails in the resolver, reported once there. +func TestPathItemRefSiblings_AReferenceIntoTheFieldsBesideARefIsRefused(t *testing.T) { + t.Parallel() + const discriminated = ` requestBody: + content: + application/json: + schema: + oneOf: [{$ref: '#/components/schemas/Cat'}] + discriminator: {propertyName: k, mapping: {cat: '#/components/schemas/Cat', near: %s}} + responses: {"200": {description: OK}} +components: + schemas: + Cat: {type: object, properties: {k: {type: string}}} +` + const ( + refused = "refused" // reported here, at the reference, saying why + unresolved = "unresolved" // reported by the resolver, which found nothing + resolved = "resolved" // nothing to report + ) + // leaked reports whether /c lowered the construct the refused reference + // names, as the resolver read it. + bodyAt := func(method string) func(*ir.Document) bool { + return func(doc *ir.Document) bool { return opByPath(t, doc, method, "/c").Request != nil } + } + cases := []struct { + name, paths string + site jsontext.Pointer + want string + leaked func(*ir.Document) bool + }{ + {"schema", ` /c: + post: + requestBody: {content: {application/json: {schema: {$ref: '#/paths/~1a/get/requestBody/content/application~1json/schema'}}}} + responses: {"200": {description: OK}} +`, "/paths/~1c/post/requestBody/content/application~1json/schema", refused, + func(doc *ir.Document) bool { + return openapitest.BodyTarget(t, opByPath(t, doc, "POST", "/c").Request) != ir.PrimTypeID(ir.PrimAny) + }}, + {"a schema's $ref to a refused one", ` /c: + post: + requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/X'}}}} + responses: {"200": {description: OK}} +components: + schemas: + X: {$ref: '#/paths/~1a/get/requestBody/content/application~1json/schema'} +`, "/components/schemas/X", refused, func(doc *ir.Document) bool { + x, ok := doc.Types["t/openapi/components/schemas/X"].(*ir.Scalar) + return !ok || x.Base == nil || x.Base.Target != ir.PrimTypeID(ir.PrimAny) + }}, + {"parameter by index", ` /c: + post: + parameters: [{$ref: '#/paths/~1a/parameters/0'}] + responses: {"200": {description: OK}} +`, "/paths/~1c/post/parameters/0", refused, + func(doc *ir.Document) bool { return len(opByPath(t, doc, "POST", "/c").Params) > 0 }}, + {"mapping value", " /c:\n post:\n" + + strings.Replace(discriminated, "%s", "'#/paths/~1a/get/requestBody/content/application~1json/schema'", 1), + "/paths/~1c/post/requestBody/content/application~1json/schema/discriminator/mapping/near", refused, + func(doc *ir.Document) bool { return mapsTag(doc, "near") }}, + {"inside the fields", ` /c: + $ref: '#/paths/~1b' + get: + requestBody: {$ref: '#/paths/~1a/get/requestBody'} + responses: {"200": {description: OK}} +`, "/paths/~1c/get/requestBody", refused, bodyAt("GET")}, + {"a key not written beside", ` /c: + post: + requestBody: {$ref: '#/paths/~1a/post/requestBody'} + responses: {"200": {description: OK}} +`, "/paths/~1c/post/requestBody", resolved, nil}, + {"an alias's position", ` /d: *a + /c: + post: + requestBody: {$ref: '#/paths/~1d/get/requestBody'} + responses: {"200": {description: OK}} +`, "/paths/~1c/post/requestBody", refused, bodyAt("POST")}, + {"a key only written beside", ` /c: + post: + requestBody: {$ref: '#/paths/~1a/put/requestBody'} + responses: {"200": {description: OK}} +`, "/paths/~1c/post/requestBody", unresolved, bodyAt("POST")}, + } + for _, tc := range cases { + doc, diags := parseFull(t, besideRefSpec(tc.paths, "")) + errs := errorsAt(diags) + if tc.want == resolved { + assert.Empty(t, errs, "%s: resolved as main resolves it", tc.name) + continue + } + require.Len(t, errs[tc.site], 1, "%s: one report at the reference: %v", tc.name, errs) + assert.Len(t, errs, 1, "%s: and no other: %v", tc.name, errs) + assert.Equal(t, tc.want == refused, strings.Contains(errs[tc.site][0], "GitHub #824"), + "%s: %s", tc.name, errs[tc.site][0]) + assert.False(t, tc.leaked(doc), "%s: nothing is lowered for the reference", tc.name) + } +} + +// TestPathItemRefSiblings_ADefsPointerBesideARefIsRefused pins a "#/$defs/..." +// pointer written in the fields beside a $ref, for a $ref and for a mapping +// value. Its definitions are read from where it is written, so through /a's +// $ref: the mapping's took /b's Y, hoisted at /a's own position with /b's +// properties, and the $ref found none. Each is now one error saying why. +func TestPathItemRefSiblings_ADefsPointerBesideARefIsRefused(t *testing.T) { + t.Parallel() + const schema = ` schema: + $defs: {Y: {type: object, properties: {k: {type: string}, %s: {type: string}}}} + type: object + properties: {y: {$ref: '#/$defs/Y'}} + oneOf: [{$ref: '#/components/schemas/Cat'}] + discriminator: {propertyName: k, mapping: {cat: '#/components/schemas/Cat', y: '#/$defs/Y'}} +` + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + get: + requestBody: + content: + application/json: +` + strings.Replace(schema, "%s", "near", 1) + ` responses: {"200": {description: OK}} + /b: + get: + requestBody: + content: + application/json: +` + strings.Replace(schema, "%s", "far", 1) + ` responses: {"200": {description: OK}} +components: + schemas: + Cat: {type: object, properties: {k: {type: string}}} +` + doc, diags := parseFull(t, spec) + + const at = "/paths/~1a/get/requestBody/content/application~1json/schema" + errs := errorsAt(diags) + for _, site := range []jsontext.Pointer{at + "/properties/y", at + "/discriminator/mapping/y"} { + require.Len(t, errs[site], 1, "one report at %s: %v", site, errs) + assert.Contains(t, errs[site][0], "as is any definition it can name", site) + } + assert.NotContains(t, doc.Types, ir.TypeID("t/anon"+at+"/$defs/Y"), + "nothing is hoisted at /a's position from /b's definitions") +} + +// mapsTag reports whether a discriminator anywhere in doc maps tag. +func mapsTag(doc *ir.Document, tag string) bool { + for _, td := range doc.Types { + var d *ir.Discriminator + switch n := td.(type) { + case *ir.Union: + d = n.Discriminator + case *ir.Model: + d = n.Discriminator + default: + continue + } + if d == nil { + continue + } + if _, mapped := d.Mapping[tag]; mapped { + return true + } + } + return false +} + +// errorsAt returns the messages of the unresolved-ref errors in diags by +// position. +func errorsAt(diags []ir.Diagnostic) map[jsontext.Pointer][]string { + out := map[jsontext.Pointer][]string{} + for _, d := range diags { + if d.Severity == ir.SeverityError && d.Code == diag.UnresolvedRef { + out[d.Provenance.Pointer] = append(out[d.Provenance.Pointer], d.Message) + } + } + return out +} + +// TestPathItemRefSiblings_APointerIntoAnotherDocumentIsNotRefused pins that the +// refusal reads a pointer in the source only. /c names ext.yaml's /a, whose +// pointer spells a position /a here writes beside its $ref, so a check reading +// the pointer alone would refuse a reference the resolver read correctly. +func TestPathItemRefSiblings_APointerIntoAnotherDocumentIsNotRefused(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ext.yaml"), []byte(`openapi: 3.1.0 +info: {title: E, version: "1"} +paths: + /a: + get: + requestBody: {content: {application/json: {schema: {type: object, properties: {ext: {type: string}}}}}} + responses: {"200": {description: OK}} +`), 0o600)) + root := filepath.Join(dir, "root.yaml") + src := besideRefSpec(` /c: + post: + operationId: postC + requestBody: {$ref: 'ext.yaml#/paths/~1a/get/requestBody'} + responses: {"200": {description: OK}} +`, "") + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: root, Data: []byte(src)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err) + require.NotNil(t, doc) + + assert.Empty(t, errorsAt(diags)) + body, ok := doc.Types[openapitest.BodyTarget(t, openapitest.FindOp(t, doc, "postC").Request)].(*ir.Model) + require.True(t, ok, "/c lowers the other document's body") + assert.Equal(t, "ext", body.Properties[0].WireName) +} diff --git a/compilers/openapi/internal/operation/pathitemref_test.go b/compilers/openapi/internal/operation/pathitemref_test.go index 7555c9ef..6bb726ca 100644 --- a/compilers/openapi/internal/operation/pathitemref_test.go +++ b/compilers/openapi/internal/operation/pathitemref_test.go @@ -2,22 +2,14 @@ package operation_test import ( "encoding/json/jsontext" - "os" - "path/filepath" - "slices" "strings" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/compilers/openapi/internal/ids" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/engine/reader" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irverify" ) @@ -183,128 +175,6 @@ func TestPathItemRefSiblings_CallbackRoute(t *testing.T) { "and the census names the use site's undeclared key where it was written") } -// pathItemRefSiblingsExternalFixture's path item carries a $ref plus siblings, -// and the operation written beside the $ref writes a parameter reference that -// leaves the document for the target fixture beside it -// (resolve_target_path_item_sibling.yaml). -const pathItemRefSiblingsExternalFixture = "../../../../testdata/openapi/resolve_main_path_item_sibling.yaml" - -// TestPathItemRefSiblings_ExternalRefFollowsTheCompilesPolicy pins that a -// reference written beside a $ref follows the compile's external-reference -// policy, as every reference the load phase resolves does. With the option the -// target file's component lands on the IR at the sibling operation; without it -// the reference is reported unresolved once, where it is written. -func TestPathItemRefSiblings_ExternalRefFollowsTheCompilesPolicy(t *testing.T) { - t.Parallel() - data, err := os.ReadFile(pathItemRefSiblingsExternalFixture) - require.NoError(t, err) - - compiled := func(allow bool) (*ir.Document, []ir.Diagnostic) { - t.Helper() - opts := compilers.Options{FormatOptions: openapi.Options{AllowExternalRefs: allow}} - if allow { - opts.Reader = reader.Disk{} - } - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: pathItemRefSiblingsExternalFixture, Data: append([]byte(nil), data...)}}, opts) - require.NoError(t, err) - require.NotNil(t, doc) - return doc, diags - } - - allowed, allowedDiags := compiled(true) - openapitest.RequireNoErrorDiags(t, allowedDiags) - getA := opByPath(t, allowed, "GET", "/a") - require.Len(t, getA.Params, 1, "the reference beside the $ref follows into the second file") - assert.Equal(t, "extSibling", getA.Params[0].Name.Source) - - refused, refusedDiags := compiled(false) - assert.Equal(t, []string{"/paths/~1a/get/parameters/0"}, unresolvedAt(refusedDiags), - "without the option the sibling's external reference is reported once, where it is written") - refusedA := opByPath(t, refused, "GET", "/a") - assert.Empty(t, refusedA.Params, "and no unresolved parameter reaches the IR") -} - -// crossDocumentTarget is a path item in another document whose item-level -// parameter's schema names that document's own S, which the referring -// document also declares, differently. -const crossDocumentTarget = `openapi: 3.1.0 -info: {title: Target, version: "1"} -paths: - /b: - parameters: - - {name: inl, in: header, schema: {$ref: '#/components/schemas/S'}} - post: - operationId: postB - responses: {"200": {description: OK}} -components: - schemas: - S: {type: integer} -` - -// crossDocumentUse refers to crossDocumentTarget's path item and writes an -// item parameter and an operation with one of its own beside the $ref. -const crossDocumentUse = `openapi: 3.1.0 -info: {title: Main, version: "1"} -paths: - /a: - $ref: './target.yaml#/paths/~1b' - parameters: - - {$ref: '#/components/parameters/P'} - get: - operationId: getA - parameters: - - {$ref: '#/components/parameters/Q'} - responses: {"200": {description: OK}} -components: - parameters: - P: {name: mainP, in: query, schema: {type: string}} - Q: {name: mainQ, in: query, schema: {type: string}} - schemas: - S: {type: string} -` - -// TestPathItemRefSiblings_EachSideReadsInItsOwnDocument pins the scope each -// side of a cross-document $ref lowers in, since every operation at the mount -// carries both sides' item parameters. Read in the source, the referent's -// parameter on the use site's operation bound the source's S; read in the -// referent's document, the use site's parameters lost the pointer of their -// declaration. -func TestPathItemRefSiblings_EachSideReadsInItsOwnDocument(t *testing.T) { - t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "target.yaml"), []byte(crossDocumentTarget), 0o600)) - use := filepath.Join(dir, "main.yaml") - doc, _, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: use, Data: []byte(crossDocumentUse)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - - postB, getA := opByPath(t, doc, "POST", "/a"), opByPath(t, doc, "GET", "/a") - referent := paramNamed(t, getA, "inl") - assert.Equal(t, paramNamed(t, postB, "inl").Type, referent.Type, - "the referent's parameter binds the same type on either side's operation") - assert.NotEqual(t, ir.TypeID("t/openapi/components/schemas/S"), referent.Type.Target, - "and never the source's S") - assert.Equal(t, jsontext.Pointer("/components/parameters/P"), paramNamed(t, postB, "mainP").Provenance.Pointer, - "the use site's parameter keeps its declaration's pointer on the referent's operation") - assert.Equal(t, jsontext.Pointer("/components/parameters/Q"), paramNamed(t, getA, "mainQ").Provenance.Pointer, - "and the use site's operation reads its own references in the source") -} - -// paramNamed returns op's parameter whose source name is name. -func paramNamed(t *testing.T, op ir.Operation, name string) ir.Parameter { - t.Helper() - for _, p := range op.Params { - if p.Name.Source == name { - return p - } - } - require.Failf(t, "parameter not found", "%s has no parameter %q", op.Name.Source, name) - return ir.Parameter{} -} - // pathItemRefItemSpec declares item-level constructs beside a $ref: servers, an // x-* extension, an undeclared key and an item-level parameter, none of which // the reference wrapper models. @@ -387,489 +257,6 @@ func assertPathItemSiblingConstructs(t *testing.T, op ir.Operation, usePtr strin assert.Equal(t, jsontext.Pointer(usePtr+"/parameters/0"), op.Params[0].Provenance.Pointer) } -// TestPathItemRefSiblings_ReferenceKindsResolve pins that a reference written -// inside the fields beside a $ref is resolved before it is lowered, which the -// load phase does with the rest of the document. -// -// It exercises every reference kind a path-item subtree reaches: a parameter, a -// request body, a response and a callback on the sibling operation, and a -// header, a link and an example on its response. A Link Object lowers to no -// node of its own, only to preserveResponseExtras' verbatim map, so its -// resolving half is TestPathItemRefSiblings_UnresolvedLinkIsReported -// (GitHub #275). -func TestPathItemRefSiblings_ReferenceKindsResolve(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefKindsSpec) - openapitest.RequireNoErrorDiags(t, diags) - - getA := opByPath(t, doc, "GET", "/a") - require.Len(t, getA.Params, 1, "a $ref'd parameter beside the $ref resolves") - assert.Equal(t, "refParam", getA.Params[0].Name.Source) - require.NotNil(t, getA.Request, "a $ref'd request body resolves") - assert.Equal(t, "application/json", getA.Request.Contents[0].MediaType) - - success := responseByStatus(t, getA, 200) - assert.Equal(t, 200, success.Conditions.StatusCodes[0].From, "a $ref'd response resolves") - - created := responseByStatus(t, getA, 201) - require.Len(t, created.Headers, 1, "a $ref'd header on a sibling response resolves") - assert.Equal(t, "X-Rate", created.Headers[0].WireName) - - require.NotNil(t, created.Payload, "the response written beside the $ref keeps its body") - require.NotEmpty(t, created.Payload.Contents) - examples := created.Payload.Contents[0].Examples - require.Len(t, examples, 1, "a $ref'd example on a sibling media type resolves") - assert.Equal(t, "refEx", examples[0].Name) - require.NotNil(t, examples[0].Value, "the referenced example's own value is lowered") - - links, ok := created.Unmodeled["openapi:links"] - require.True(t, ok, "the sibling response's $ref'd link is kept verbatim") - assert.Contains(t, string(links.Value), "RefL", - "a Link Object has no IR home, so the map reaches the IR under its own key") - - cbPost := openapitest.FindOp(t, doc, "cbPost") - assert.Equal(t, ir.OpID("op/openapi/paths/~1a/get/callbacks/onEvent/{$request.body#~1url}/post"), cbPost.ID, - "a $ref'd callback resolves, and its operation mounts under the operation beside the $ref") - require.Len(t, getA.Bindings.HTTP[0].Callbacks, 1) - assert.Equal(t, []ir.OpID{cbPost.ID}, getA.Bindings.HTTP[0].Callbacks[0].Operations) -} - -// responseByStatus returns the single success response an operation declares for -// code, requiring it to be there rather than indexing into a list that a second -// response would have made ambiguous. -func responseByStatus(t *testing.T, op ir.Operation, code int) ir.Response { - t.Helper() - for _, r := range op.Responses { - for _, sc := range r.Conditions.StatusCodes { - if sc.From == code && sc.To == code { - return r - } - } - } - t.Fatalf("operation %s declares no response for status %d", op.ID, code) - return ir.Response{} -} - -const pathItemRefKindsSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - get: - operationId: getA - parameters: - - {$ref: '#/components/parameters/RefP'} - requestBody: {$ref: '#/components/requestBodies/RefB'} - responses: - "200": {$ref: '#/components/responses/RefR'} - "201": - description: created - headers: - X-Rate: {$ref: '#/components/headers/RefH'} - content: - application/json: - schema: {type: string} - examples: - refEx: {$ref: '#/components/examples/RefE'} - links: - next: {$ref: '#/components/links/RefL'} - callbacks: - onEvent: {$ref: '#/components/callbacks/RefC'} - /b: - post: - operationId: postB - responses: {"200": {description: OK}} -components: - parameters: - RefP: {name: refParam, in: query, schema: {type: string}} - requestBodies: - RefB: - content: - application/json: {schema: {type: string}} - responses: - RefR: - description: ok - content: - application/json: {schema: {type: string}} - headers: - RefH: {schema: {type: integer}} - examples: - RefE: {value: {from: component}} - links: - RefL: {operationId: getA} - callbacks: - RefC: - '{$request.body#/url}': - post: - operationId: cbPost - responses: {"200": {description: OK}} -` - -// pathItemRefDanglingLinkSpec writes a $ref'd link on a sibling response whose -// target names nothing. -const pathItemRefDanglingLinkSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - get: - operationId: getA - responses: - "200": - description: ok - links: - next: {$ref: '#/components/links/Missing'} - /b: - post: - operationId: postB - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_UnresolvedLinkIsReported pins that resolution reaches -// a link reference written beside a $ref: a link whose declaration is -// absent is reported like any other unresolved reference there, which is what -// makes the resolving half of the kinds fixture above mean something. It is also -// the one reference kind whose resolution no IR field records — nothing reads a -// resolved link — so the diagnostic is the only evidence that it happened. -func TestPathItemRefSiblings_UnresolvedLinkIsReported(t *testing.T) { - t.Parallel() - _, diags := parseFull(t, pathItemRefDanglingLinkSpec) - assert.Equal(t, []string{"/paths/~1a/get/responses/200/links/next"}, unresolvedAt(diags), - "the link reference beside the $ref is resolved with the document and reported where it is written") -} - -// pathItemRefCollisionSpec declares the same constructs on both sides of one -// $ref: the method key, the summary, the servers, an x-* name, an undeclared key -// and an item parameter pair, typed apart so the one lowered names its side. -const pathItemRefCollisionSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - summary: use summary - description: use description - servers: [{url: https://use.example}] - x-shared: use - bogusBoth: {responses: {"200": {description: USE}}} - parameters: - - {name: dup, in: query, schema: {type: string}} - get: - operationId: useGet - responses: {"200": {description: OK}} - /b: - summary: ref summary - description: ref description - servers: [{url: https://ref.example}] - x-shared: ref - x-refOnly: ref - bogusBoth: {responses: {"200": {description: REF}}} - parameters: - - {name: dup, in: query, schema: {type: integer}} - get: - operationId: refGet - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_CollisionIsLoudAndUseSiteWins pins the collision rule -// GitHub #577 settles: OpenAPI leaves the result of a field written beside a -// $ref undefined, so the use site's declaration wins every construct, as a -// $ref-adjacent schema sibling's does, and the losing one is reported rather -// than dropped in silence. -func TestPathItemRefSiblings_CollisionIsLoudAndUseSiteWins(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefCollisionSpec) - openapitest.RequireNoErrorDiags(t, diags) - - // One finding per colliding construct, each sited at the use site's own key - // and naming the referent's. - for _, at := range []string{ - "/paths/~1a/get", - "/paths/~1a/summary", - "/paths/~1a/description", - "/paths/~1a/servers", - "/paths/~1a/x-shared", - "/paths/~1a/bogusBoth", - "/paths/~1a/parameters/0", - } { - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, at), - "%s is reported at the use site's own key", at) - } - message := openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, - ir.SeverityWarning, "/paths/~1a/get") - assert.Contains(t, message, "/paths/~1b/get", "the referent's position is named too") - - // The use site's declaration is the one lowered, and the referent's get is - // not lowered at this mount at all. - getA := opByPath(t, doc, "GET", "/a") - assert.Equal(t, "useGet", getA.Name.Source) - assert.Equal(t, jsontext.Pointer("/paths/~1a/get"), getA.Provenance.Pointer) - getB := opByPath(t, doc, "GET", "/b") - assert.Equal(t, "refGet", getB.Name.Source, "the referent still lowers at its own path") - - summary, ok := getA.Unmodeled["openapi:pathItemSummary"] - require.True(t, ok) - assert.JSONEq(t, `"use summary"`, string(summary.Value), "the use site's summary survives") - servers, ok := getA.Unmodeled["openapi:servers"] - require.True(t, ok) - assert.JSONEq(t, `[{"url":"https://use.example"}]`, string(servers.Value)) - ext, ok := getA.Unmodeled["openapi:pathItem/x-shared"] - require.True(t, ok) - assert.JSONEq(t, `"use"`, string(ext.Value)) - only, ok := getA.Unmodeled["openapi:pathItem/x-refOnly"] - require.True(t, ok, "an extension only the referent declares is kept beside the one it cedes") - assert.Equal(t, jsontext.Pointer("/paths/~1b/x-refOnly"), only.Provenance.Pointer) - bogus, ok := getA.Unmodeled["openapi:pathItem/bogusBoth"] - require.True(t, ok) - assert.JSONEq(t, `{"responses":{"200":{"description":"USE"}}}`, string(bogus.Value), - "an undeclared key is the use site's too, as every other construct is") - assert.Equal(t, jsontext.Pointer("/paths/~1a/bogusBoth"), bogus.Provenance.Pointer) - assert.False(t, openapitest.HasDiag(diags, diag.UnknownKeyEntryTaken), - "the referent's key is not written, so nothing is left to say it was dropped") - assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, ir.SeverityWarning, - "/paths/~1a/parameters/0"), `the parameter "dup" in query is declared here`, "a parameter is named as written") - require.Len(t, getA.Params, 1, "the referent's parameter is not lowered beside the use site's") - assert.Equal(t, ir.TypeID("t/prim/string"), getA.Params[0].Type.Target, "the use site's is the one kept") - assert.Equal(t, jsontext.Pointer("/paths/~1a/parameters/0"), getA.Params[0].Provenance.Pointer) - - // One operation at the mount, so no OpID is claimed twice. - assert.False(t, openapitest.HasDiag(diags, diag.DuplicateOperationID)) - assert.False(t, openapitest.HasDiag(diags, diag.ConflictingOperationID)) -} - -// pathItemRefUnmountedSpec mounts no operation on either side of the reference: -// the use site writes item-level constructs and the referent declares none. -const pathItemRefUnmountedSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - servers: [{url: https://use.example}] - x-beside: use - /b: - summary: ref summary -` - -// TestPathItemRefSiblings_UnmountedKeepsSiblings pins the third carrier: an item -// that mounts no operation on either side has no operation to hold the use -// site's constructs, so they are kept on the nearest node that has an Unmodeled -// map and announced, exactly as a path item with no operations already was. -func TestPathItemRefSiblings_UnmountedKeepsSiblings(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefUnmountedSpec) - openapitest.RequireNoErrorDiags(t, diags) - - svc := doc.Services[0] - entry, ok := svc.Unmodeled["openapi:pathItem/paths/~1a/servers"] - require.True(t, ok, "the use site's servers are kept on the service") - assert.JSONEq(t, `[{"url":"https://use.example"}]`, string(entry.Value)) - ext, ok := svc.Unmodeled["openapi:pathItem/paths/~1a/x-beside"] - require.True(t, ok) - assert.JSONEq(t, `"use"`, string(ext.Value)) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/paths/~1a"), - "the preservation is announced at the use site's own pointer") -} - -// pathItemRefBrokenSpec writes a reference beside the $ref that names nothing. -const pathItemRefBrokenSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - parameters: - - {$ref: '#/components/parameters/Missing'} - get: - operationId: getA - responses: {"200": {description: OK}} - /b: - post: - operationId: postB - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_UnresolvedIsReported pins that a reference written -// beside a $ref that does not resolve is reported once, where it is written, -// rather than silently reaching the IR unresolved. -func TestPathItemRefSiblings_UnresolvedIsReported(t *testing.T) { - t.Parallel() - _, diags := parseFull(t, pathItemRefBrokenSpec) - - assert.Equal(t, []string{"/paths/~1a/parameters/0"}, unresolvedAt(diags), - "the failure is reported once, at the reference written beside the $ref") -} - -// pathItemRefUnmountedWebhookSpec mounts nothing on either side of a webhook's -// $ref, so the use site's constructs have no operation to ride on. -const pathItemRefUnmountedWebhookSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: {} -webhooks: - hooked: - $ref: '#/components/pathItems/Empty' - x-beside: use -components: - pathItems: - Empty: - summary: ref summary -` - -// TestPathItemRefSiblings_UnmountedWebhookKeepsSiblings pins the orphan carrier -// on the webhook route: the use site's constructs land on the service, under the -// use site's own scope, and the preservation is announced there. -func TestPathItemRefSiblings_UnmountedWebhookKeepsSiblings(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefUnmountedWebhookSpec) - openapitest.RequireNoErrorDiags(t, diags) - - entry, ok := doc.Services[0].Unmodeled["openapi:pathItem/webhooks/hooked/x-beside"] - require.True(t, ok, "the webhook's sibling x-* is kept on the service") - assert.JSONEq(t, `"use"`, string(entry.Value)) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/webhooks/hooked"), - "the preservation is announced at the use site's own pointer") -} - -// pathItemRefUnmountedCallbackSpec mounts nothing on either side of a callback -// expression's $ref. -const pathItemRefUnmountedCallbackSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /p: - post: - operationId: parent - callbacks: - onEvent: - '{$request.body#/url}': - $ref: '#/components/pathItems/Empty' - x-beside: use - responses: {"200": {description: OK}} -components: - pathItems: - Empty: - summary: ref summary -` - -// TestPathItemRefSiblings_UnmountedCallbackKeepsSiblings pins the orphan carrier -// on the callback route, which puts the kept constructs on the parent's HTTP -// binding, where the callback itself lives. -func TestPathItemRefSiblings_UnmountedCallbackKeepsSiblings(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefUnmountedCallbackSpec) - openapitest.RequireNoErrorDiags(t, diags) - - const expr = "{$request.body#~1url}" - parent := openapitest.FindOp(t, doc, "parent") - entry, ok := parent.Bindings.HTTP[0].Unmodeled["openapi:pathItem/paths/~1p/post/callbacks/onEvent/"+expr+"/x-beside"] - require.True(t, ok, "the expression's sibling x-* is kept on the parent's binding") - assert.JSONEq(t, `"use"`, string(entry.Value)) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, - "/paths/~1p/post/callbacks/onEvent/"+expr), - "the preservation is announced at the use site's own pointer") -} - -// pathItemRefOpIDSpec has the operation written beside the $ref repeat the -// referent's operationId, which is two declarations of one id rather than one -// declaration mounted twice (GitHub #502). -const pathItemRefOpIDSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - get: - operationId: sameId - responses: {"200": {description: OK}} - /b: - get: - operationId: sameId - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_RepeatedOpIDIsAConflict pins which of the two -// operationId findings a repeated id reaches: the sibling's operation is a -// declaration of its own, so repeating the referent's id is the document writing -// the id twice, not one declaration mounted twice. -func TestPathItemRefSiblings_RepeatedOpIDIsAConflict(t *testing.T) { - t.Parallel() - _, diags := parseFull(t, pathItemRefOpIDSpec) - - assert.True(t, openapitest.HasDiag(diags, diag.ConflictingOperationID), - "two declarations writing one id is a conflict") - assert.False(t, openapitest.HasDiag(diags, diag.DuplicateOperationID), - "and not a remount: the sibling operation is not the referent's declaration") -} - -// pathItemRefCollisionReversedSpec is pathItemRefCollisionSpec with the two -// paths declared the other way round, for the two-order diff. -const pathItemRefCollisionReversedSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /b: - summary: ref summary - description: ref description - servers: [{url: https://ref.example}] - x-shared: ref - x-refOnly: ref - bogusBoth: {responses: {"200": {description: REF}}} - parameters: - - {name: dup, in: query, schema: {type: integer}} - get: - operationId: refGet - responses: {"200": {description: OK}} - /a: - $ref: '#/paths/~1b' - summary: use summary - description: use description - servers: [{url: https://use.example}] - x-shared: use - bogusBoth: {responses: {"200": {description: USE}}} - parameters: - - {name: dup, in: query, schema: {type: string}} - get: - operationId: useGet - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_CollisionIsOrderIndependent pins that what a colliding -// mount reports depends on the document and not on the order its two paths were -// declared in: the two fixtures differ only in that order, so the findings must -// be the same set of (code, pointer) pairs. -func TestPathItemRefSiblings_CollisionIsOrderIndependent(t *testing.T) { - t.Parallel() - forwardDoc, forward := parseFull(t, pathItemRefCollisionSpec) - reversedDoc, reversed := parseFull(t, pathItemRefCollisionReversedSpec) - - assert.Equal(t, collisionFindings(t, forward), collisionFindings(t, reversed), - "a collision is a property of the document, not of the order it was written in") - for _, at := range []struct{ method, uri string }{{"GET", "/a"}, {"GET", "/b"}} { - want, got := opByPath(t, forwardDoc, at.method, at.uri), opByPath(t, reversedDoc, at.method, at.uri) - assert.Empty(t, cmp.Diff(want, got), "what %s %s lowers to, the winners included, follows no order", at.method, at.uri) - } -} - -// collisionFindings returns the colliding constructs' findings as sorted -// "code pointer" strings, which is what must not depend on declaration order. -func collisionFindings(t *testing.T, diags []ir.Diagnostic) []string { - t.Helper() - var out []string - for _, d := range diags { - if d.Code == diag.PathItemRefCollision { - out = append(out, d.Code+" "+string(d.Provenance.Pointer)) - } - } - slices.Sort(out) - return out -} - -// unresolvedAt returns the pointer of every unresolved-ref finding, in the -// order they were reported. -func unresolvedAt(diags []ir.Diagnostic) []string { - var at []string - for _, d := range diags { - if d.Code == diag.UnresolvedRef { - at = append(at, string(d.Provenance.Pointer)) - } - } - return at -} - // pathItemRefSharedCallbackSpec has two operations share one $ref'd callback // whose expression writes an operation beside its $ref, and a components path // item carrying such a callback inline mounted at a path and a webhook. @@ -934,395 +321,6 @@ func TestPathItemRefSiblings_ASharedCallbackMountsPerParent(t *testing.T) { }, puts, "one operation per parent, each declared where the expression writes it") } -// pathItemRefUnmountedCollisionSpec declares the same constructs on both sides -// of a $ref neither side mounts an operation for. -const pathItemRefUnmountedCollisionSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - summary: use summary - x-both: use - bogusBoth: {responses: {"200": {description: USE}}} - /b: - summary: ref summary - x-both: ref - bogusBoth: {responses: {"200": {description: REF}}} -` - -// TestPathItemRefSiblings_UnmountedCollisionKeepsTheUseSite pins precedence on -// the carrier an item mounting no operation uses: the use site keeps every -// construct there too, and a summary, which that carrier keeps from neither -// side, is reported as colliding nowhere. -func TestPathItemRefSiblings_UnmountedCollisionKeepsTheUseSite(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefUnmountedCollisionSpec) - openapitest.RequireNoErrorDiags(t, diags) - - svc := doc.Services[0] - for key, want := range map[string]string{ - "openapi:pathItem/paths/~1a/x-both": `"use"`, - "openapi:pathItem/paths/~1a/bogusBoth": `{"responses":{"200":{"description":"USE"}}}`, - } { - entry, ok := svc.Unmodeled[key] - require.True(t, ok, key) - assert.JSONEq(t, want, string(entry.Value), key) - assert.True(t, strings.HasPrefix(string(entry.Provenance.Pointer), "/paths/~1a/"), key) - } - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, "/paths/~1a/x-both")) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, "/paths/~1a/bogusBoth")) - assert.False(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, "/paths/~1a/summary"), - "neither summary is kept, so neither is the one kept") - assert.False(t, openapitest.HasDiag(diags, diag.UnknownKeyEntryTaken)) -} - -// pathItemRefChainSpec chains /a to /b to /c, with fields beside both $refs. -const pathItemRefChainSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - summary: from a - get: - operationId: getFromA - responses: {"200": {description: OK}} - /b: - $ref: '#/paths/~1c' - summary: from b - servers: [{url: https://b.example}] - get: - operationId: getFromB - responses: {"200": {description: OK}} - put: - operationId: putFromB - responses: {"200": {description: OK}} - /c: - get: - operationId: getFromC - responses: {"200": {description: OK}} - post: - operationId: postFromC - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_AChainCarriesTheFieldsBesideEachRef pins a chain: the -// path item a $ref names is that item with the fields beside its own $ref, so -// /a mounts /b's put and /c's post, takes /b's servers, and keeps its own get -// and summary over both deeper ones, each loser named once. -func TestPathItemRefSiblings_AChainCarriesTheFieldsBesideEachRef(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, pathItemRefChainSpec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, irverify.Verify(doc)) - - for method, decl := range map[string]jsontext.Pointer{ - "GET": "/paths/~1a/get", "PUT": "/paths/~1b/put", "POST": "/paths/~1c/post", - } { - op := opByPath(t, doc, method, "/a") - assert.Equal(t, decl, op.Provenance.Pointer, "%s at /a is declared where the chain writes it", method) - summary, ok := op.Unmodeled["openapi:pathItemSummary"] - require.True(t, ok) - assert.JSONEq(t, `"from a"`, string(summary.Value)) - servers, ok := op.Unmodeled["openapi:servers"] - require.True(t, ok, "%s at /a carries the servers beside /b's $ref", method) - assert.JSONEq(t, `[{"url":"https://b.example"}]`, string(servers.Value)) - } - var collided []string - for _, d := range diags { - if d.Code == diag.PathItemRefCollision { - _, other, _ := strings.Cut(d.Message, "and again at ") - other, _, _ = strings.Cut(other, " in the item") - collided = append(collided, string(d.Provenance.Pointer)+" "+other) - } - } - slices.Sort(collided) - assert.Equal(t, []string{ - "/paths/~1a/get /paths/~1b/get", - "/paths/~1a/get /paths/~1c/get", - "/paths/~1a/summary /paths/~1b/summary", - "/paths/~1b/get /paths/~1c/get", - }, collided, "each declaration a nearer one takes over is named once, at the one kept") -} - -// TestPathItemRefSiblings_FieldsAnotherDocumentWritesAreReported pins the chain -// this compiler cannot read all of: the fields beside a $ref in another -// document are not modelled, and saying so is what keeps them from being -// dropped in silence. -func TestPathItemRefSiblings_FieldsAnotherDocumentWritesAreReported(t *testing.T) { - t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(`openapi: 3.1.0 -info: {title: O, version: "1"} -paths: - /x: - $ref: '#/components/pathItems/P' - put: {responses: {"200": {description: OK}}} -components: - pathItems: - P: - get: {responses: {"200": {description: OK}}} -`), 0o600)) - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(`openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: {$ref: './other.yaml#/paths/~1x'} -`)}}, compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - - opByPath(t, doc, "GET", "/a") - message := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/paths/~1a") - assert.Contains(t, message, "./other.yaml#/paths/~1x", "the reference reaching the unread fields is named") - assert.Contains(t, message, "in another document") - assert.Contains(t, message, "not lowered") -} - -// TestPathItemRefSiblings_FieldsTheWalkDoesNotReachAreReported pins the other -// chain this compiler cannot read all of: a hop in this document at a position -// no object its walk holds, here under an x-* key, is not modelled either, and -// the report says where it is rather than calling it another document's. -func TestPathItemRefSiblings_FieldsTheWalkDoesNotReachAreReported(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, `openapi: 3.1.0 -info: {title: T, version: "1"} -x-shared: - item: - $ref: '#/paths/~1b' - put: {operationId: putShared, responses: {"200": {description: OK}}} -paths: - /a: {$ref: '#/x-shared/item'} - /b: - get: {operationId: getB, responses: {"200": {description: OK}}} -`) - openapitest.RequireNoErrorDiags(t, diags) - - assert.Equal(t, "getB", opByPath(t, doc, "GET", "/a").Name.Source, "the item the chain ends at still mounts") - message := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/paths/~1a") - assert.Contains(t, message, "#/x-shared/item", "the reference reaching the unread fields is named") - assert.Contains(t, message, "does not reach") - assert.NotContains(t, message, "another document", "the hop is this document's") -} - -// pathItemRefCallbackSiblingSpec writes an expression beside a Callback -// Object's $ref, which OpenAPI says to ignore: a Reference Object's other -// fields are not the callback's. -const pathItemRefCallbackSiblingSpec = `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /one: - post: - operationId: one - callbacks: - cb: - $ref: '%s#/components/callbacks/CB' - '{$request.body#/url}': - $ref: '#/components/pathItems/T' - patch: - operationId: ignoredSibling - responses: {"200": {description: OK}} - responses: {"200": {description: OK}} -components: - pathItems: - T: - post: {responses: {"200": {description: OK}}} - callbacks: - CB: - '{$request.body#/url}': - post: - operationId: cbPost - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_AnExpressionBesideACallbackRefIsIgnored pins where -// the fields beside a $ref are read from: the source, at the $ref that writes -// them, never at a position another document's content was mounted at, so an -// expression beside a Callback Object's $ref is ignored however the callback -// resolves. -func TestPathItemRefSiblings_AnExpressionBesideACallbackRefIsIgnored(t *testing.T) { - t.Parallel() - dir := t.TempDir() - other := strings.Replace(pathItemRefCallbackSiblingSpec, "%s", "", 1) - require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(other), 0o600)) - for _, at := range []string{"", "./other.yaml"} { - doc, _, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(strings.Replace(pathItemRefCallbackSiblingSpec, "%s", at, 1))}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - openapitest.FindOp(t, doc, "cbPost") - for _, g := range doc.Services[0].Groups { - for _, op := range g.Operations { - assert.NotEqual(t, "ignoredSibling", op.Name.Source, "callback %q", at) - } - } - } -} - -// TestPathItemRefSiblings_AForeignReferentKeepsItsOwnPositions pins a use site -// writing item parameters beside a $ref to another document's path item: each -// parameter keeps the type its own schema declares, the referent's lowered -// where that document declares it. Where the bindings' table places no end, as -// for a key that document writes twice, the referent is lowered at the use -// site, under its "$ref", so the two still share no pointer. A finding names -// the referent's positions by the reference. -func TestPathItemRefSiblings_AForeignReferentKeepsItsOwnPositions(t *testing.T) { - t.Parallel() - const item = ` - parameters: - - {name: refP, in: header, schema: {type: object, properties: {fromRef: {type: integer}}}} - get: - operationId: refGet - responses: {"200": {description: OK}} - post: - operationId: postB - responses: {"200": {description: OK}} -` - for name, tc := range map[string]struct { - target, ref string - refType ir.TypeID - }{ - "declared once": { - target: "openapi: 3.1.0\ninfo: {title: Target, version: \"1\"}\npaths:\n /b:" + item, - ref: "./target.yaml#/paths/~1b", - refType: ids.In("target.yaml").AnonType("/paths/~1b/parameters/0/schema"), - }, - "a key written twice": { - target: "B:" + strings.ReplaceAll(item, "\n ", "\n ") + "B: {}\n", - ref: "./target.yaml#/B", - refType: ids.Document{}.AnonType("/paths/~1a/$ref/parameters/0/schema"), - }, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "target.yaml"), []byte(tc.target), 0o600)) - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(`openapi: 3.1.0 -info: {title: Main, version: "1"} -paths: - /a: - $ref: '` + tc.ref + `' - parameters: - - {name: useP, in: query, schema: {type: object, properties: {fromUse: {type: string}}}} - get: - operationId: useGet - responses: {"200": {description: OK}} -`)}}, compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - assert.Empty(t, irverify.Verify(doc)) - - for _, op := range []ir.Operation{opByPath(t, doc, "GET", "/a"), opByPath(t, doc, "POST", "/a")} { - assert.Equal(t, ids.Document{}.AnonType("/paths/~1a/parameters/0/schema"), paramNamed(t, op, "useP").Type.Target) - assert.Equal(t, tc.refType, paramNamed(t, op, "refP").Type.Target, - "the referent's parameter keeps its own schema, not the use site's at the same index") - } - message := openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, ir.SeverityWarning, "/paths/~1a/get") - assert.Contains(t, message, tc.ref+"/get", "another document's position is named by the reference") - }) - } -} - -// TestPathItemRefSiblings_AWholeDocumentReferentIsNamedByItsFragment pins how a -// finding names a position in a document a $ref takes whole, the common shape -// of a path split into its own file: with the empty fragment, so the pointer -// after it reads as one rather than as a file path. -func TestPathItemRefSiblings_AWholeDocumentReferentIsNamedByItsFragment(t *testing.T) { - t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "item.yaml"), []byte(`get: - operationId: itemGet - responses: {"200": {description: OK}} -`), 0o600)) - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: filepath.Join(dir, "main.yaml"), Data: []byte(`openapi: 3.1.0 -info: {title: Main, version: "1"} -paths: - /a: - $ref: './item.yaml' - get: - operationId: useGet - responses: {"200": {description: OK}} -`)}}, compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - - message := openapitest.DiagMessageAt(t, diags, diag.PathItemRefCollision, ir.SeverityWarning, "/paths/~1a/get") - assert.Contains(t, message, "again at ./item.yaml#/get in", "the position is the fragment of the document named") -} - -// TestPathItemRefSiblings_AnAliasDeclaresAtItsOwnPosition pins where an alias -// of a use site declares the fields beside its $ref: at the alias's own path, -// as an aliased inline item declares there, not where the load phase first met -// the mapping. -func TestPathItemRefSiblings_AnAliasDeclaresAtItsOwnPosition(t *testing.T) { - t.Parallel() - for _, tc := range []struct{ name, item string }{ - {"beside a $ref", `{$ref: '#/paths/~1b', put: {operationId: putA, responses: {"200": {description: OK}}}}`}, - {"an inline item", `{put: {operationId: putA, responses: {"200": {description: OK}}}}`}, - } { - doc, diags := parseFull(t, `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: &a `+tc.item+` - /x: *a - /b: - get: {operationId: getB, responses: {"200": {description: OK}}} -`) - openapitest.RequireNoErrorDiags(t, diags) - - assert.Equal(t, jsontext.Pointer("/paths/~1a/put"), opByPath(t, doc, "PUT", "/a").Provenance.Pointer, tc.name) - assert.Equal(t, jsontext.Pointer("/paths/~1x/put"), opByPath(t, doc, "PUT", "/x").Provenance.Pointer, - "%s: the alias declares at its own path", tc.name) - } -} - -// TestPathItemRefSiblings_AMergedRefIsNoReference pins the library's reading of -// a $ref a merge key supplies: none, so the item is inline, and nothing in it -// collides with itself. -func TestPathItemRefSiblings_AMergedRefIsNoReference(t *testing.T) { - t.Parallel() - _, diags := parseFull(t, `openapi: 3.1.0 -info: {title: T, version: "1"} -x-base: &base {$ref: '#/paths/~1b'} -paths: - /a: - <<: *base - put: {responses: {"200": {description: OK}}} - /b: - get: {responses: {"200": {description: OK}}} -`) - assert.False(t, openapitest.HasDiag(diags, diag.PathItemRefCollision)) -} - -// TestPathItemRefSiblings_MalformedFieldsAreReported pins that the fields -// beside a $ref are validated as an inline item's are: a value of the wrong -// type is an error where it is written, not accepted in silence. -func TestPathItemRefSiblings_MalformedFieldsAreReported(t *testing.T) { - t.Parallel() - _, diags := parseFull(t, `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - put: - deprecated: notabool - parameters: [{name: q, in: query, required: maybe, schema: {type: string}}] - responses: {"200": {description: OK}} - /b: - get: {responses: {"200": {description: OK}}} -`) - var lines []int - for _, d := range diags { - if d.Code == diag.Validation+"/validation-type-mismatch" { - lines = append(lines, d.Provenance.Position.Line) - } - } - slices.Sort(lines) - assert.Equal(t, []int{7, 8}, lines, "each value of the wrong type is reported where it is written") -} - // pathItemRefOrderSpec mounts /b's operations at /a and adds its own beside the // $ref, one of them a repeat of /b's COPY. /c writes every operation /a ends up // with inline, in the order /b and /a write them. @@ -1381,341 +379,3 @@ func methodsAt(doc *ir.Document, uri string) []string { } return methods } - -// besideRefPaths are /a and /b of every case below: /a writes get, put and its -// item parameters beside a $ref to /b, which declares a get and parameters too, -// so the resolver reads a pointer through /a's $ref to /b's, while /a's mount -// declares its own at the pointer the reference spells. /a is anchored for an -// alias to bring the same fields to a second path. -const besideRefPaths = ` /a: &a - $ref: '#/paths/~1b' - parameters: [{name: near, in: query, schema: {type: string}}] - get: - operationId: getA - requestBody: {content: {application/json: {schema: {type: object, properties: {near: {type: string}}}}}} - responses: {"200": {description: OK}} - put: - operationId: putA - requestBody: {content: {application/json: {schema: {type: object, properties: {nearPut: {type: string}}}}}} - responses: {"200": {description: OK}} - /b: - parameters: [{name: far, in: query, schema: {type: integer}}] - get: - operationId: getB - requestBody: {content: {application/json: {schema: {type: object, properties: {far: {type: string}}}}}} - responses: {"200": {description: OK}} - post: - operationId: postB - requestBody: {content: {application/json: {schema: {type: object, properties: {farPost: {type: string}}}}}} - responses: {"200": {description: OK}} -` - -// besideRefSpec returns a document holding besideRefPaths, then paths, then -// rest, whose top-level keys follow. -func besideRefSpec(paths, rest string) string { - return "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + besideRefPaths + paths + rest -} - -// besideRefChain is /c taking its request body through a component that names -// /a's get request body, which the component walk resolves after the paths, so -// the reference resolves whichever path the document declares first. -const besideRefChain = ` /c: - post: - operationId: postC - requestBody: {$ref: '#/components/requestBodies/X'} - responses: {"200": {description: OK}} -` - -// TestPathItemRefSiblings_APointerPastTheRefIsRefusedInEitherOrder pins the -// refusal of a reference into the fields beside a $ref (GitHub #824). The -// resolver read #/paths/~1a/get/requestBody as /b's, and the mount declares -// /a's own at that pointer, so whichever lowered first gave both its type: /c's -// body took /a's schema when /a came first. Refused, neither order lowers one, -// both report it where it is written, and both keep X, which nothing lowers, -// verbatim. -func TestPathItemRefSiblings_APointerPastTheRefIsRefusedInEitherOrder(t *testing.T) { - t.Parallel() - const components = "components:\n requestBodies:\n X: {$ref: '#/paths/~1a/get/requestBody'}\n" - first, firstDiags := parseFull(t, besideRefSpec(besideRefChain, components)) - second, secondDiags := parseFull(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+ - besideRefChain+besideRefPaths+components) - - for name, doc := range map[string]*ir.Document{"/a first": first, "/c first": second} { - postC := openapitest.FindOp(t, doc, "postC") - assert.Nil(t, postC.Request, "%s: /c takes no body through the refused reference", name) - getA := openapitest.FindOp(t, doc, "getA") - require.NotNil(t, getA.Request, name) - body, ok := doc.Types[getA.Request.Contents[0].Type.Target].(*ir.Model) - require.True(t, ok, name) - assert.Equal(t, "near", body.Properties[0].WireName, "%s: /a keeps its own body", name) - assert.Equal(t, ir.ReasonNoIRHome, doc.Unmodeled["openapi:components/requestBodies/X"].Reason, - "%s: the component only the refused chain passes through is kept", name) - } - assert.Empty(t, cmp.Diff(first.Types, second.Types), "the registry does not depend on the order") - for name, diags := range map[string][]ir.Diagnostic{"/a first": firstDiags, "/c first": secondDiags} { - errs := errorsAt(diags) - assert.Len(t, errs["/paths/~1c/post/requestBody"], 1, "%s: reported once at /c", name) - assert.Len(t, errs["/components/requestBodies/X"], 1, "%s: and once at the component", name) - } - assert.Contains(t, errorsAt(firstDiags)["/paths/~1c/post/requestBody"][0], "GitHub #824") -} - -// TestPathItemRefSiblings_AnAnswerWrittenThereIsTakenInEitherOrder pins the one -// answer the refusal takes. With external references allowed, the load phase -// holds the source for the resolver, so the chain through X finds the body /a's -// get writes, by a pointer or by the source's file name, and through /z, which -// aliases /a, a copy of it. It finds put's too, which only /a writes and which -// without them the resolver finds nowhere (GitHub #824). Each is the -// declaration the mount interns at the same pointer, so /c lowers it in both -// declaration orders with nothing reported. -func TestPathItemRefSiblings_AnAnswerWrittenThereIsTakenInEitherOrder(t *testing.T) { - t.Parallel() - compile := func(src string) (*ir.Document, []ir.Diagnostic) { - doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "spec.yaml", Data: []byte(src)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - return doc, diags - } - for _, tc := range []struct{ ref, method, mount, wire string }{ - {"#/paths/~1a/get/requestBody", "GET", "/a", "near"}, - {"spec.yaml#/paths/~1a/get/requestBody", "GET", "/a", "near"}, - {"spec.yaml#/paths/~1z/get/requestBody", "GET", "/z", "near"}, - {"#/paths/~1a/put/requestBody", "PUT", "/a", "nearPut"}, - } { - ref := tc.ref - components := "components:\n requestBodies:\n X: {$ref: '" + ref + "'}\n" - first, firstDiags := compile(besideRefSpec(" /z: *a\n"+besideRefChain, components)) - second, secondDiags := compile("openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + - besideRefChain + besideRefPaths + " /z: *a\n" + components) - - for name, doc := range map[string]*ir.Document{"/a first": first, "/c first": second} { - own := openapitest.BodyTarget(t, opByPath(t, doc, tc.method, tc.mount).Request) - body, ok := doc.Types[own].(*ir.Model) - require.True(t, ok, "%s %s", ref, name) - assert.Equal(t, tc.wire, body.Properties[0].WireName, "%s %s: %s keeps its own body", ref, name, tc.mount) - assert.Equal(t, own, openapitest.BodyTarget(t, openapitest.FindOp(t, doc, "postC").Request), - "%s %s: /c takes the same one", ref, name) - } - assert.Empty(t, cmp.Diff(first.Types, second.Types), "%s: the registry does not depend on the order", ref) - assert.Empty(t, errorsAt(firstDiags), ref) - assert.Empty(t, errorsAt(secondDiags), ref) - } -} - -// TestPathItemRefSiblings_AReferenceIntoTheFieldsBesideARefIsRefused pins the -// refusal on every route a reference reaches a pointer by: a $ref of each kind, -// a discriminator mapping value, a reference written inside the fields -// themselves, and an alias's second position. A "#/$defs/..." pointer written -// in those fields is one too, since its definition is read through the $ref. -// Each is one error at the reference saying why, and nothing interned for it. A -// pointer to a key nothing writes beside the $ref is left as main reads it, and -// one only those fields write fails in the resolver, reported once there. -func TestPathItemRefSiblings_AReferenceIntoTheFieldsBesideARefIsRefused(t *testing.T) { - t.Parallel() - const discriminated = ` requestBody: - content: - application/json: - schema: - oneOf: [{$ref: '#/components/schemas/Cat'}] - discriminator: {propertyName: k, mapping: {cat: '#/components/schemas/Cat', near: %s}} - responses: {"200": {description: OK}} -components: - schemas: - Cat: {type: object, properties: {k: {type: string}}} -` - const ( - refused = "refused" // reported here, at the reference, saying why - unresolved = "unresolved" // reported by the resolver, which found nothing - resolved = "resolved" // nothing to report - ) - // leaked reports whether /c lowered the construct the refused reference - // names, as the resolver read it. - bodyAt := func(method string) func(*ir.Document) bool { - return func(doc *ir.Document) bool { return opByPath(t, doc, method, "/c").Request != nil } - } - cases := []struct { - name, paths string - site jsontext.Pointer - want string - leaked func(*ir.Document) bool - }{ - {"schema", ` /c: - post: - requestBody: {content: {application/json: {schema: {$ref: '#/paths/~1a/get/requestBody/content/application~1json/schema'}}}} - responses: {"200": {description: OK}} -`, "/paths/~1c/post/requestBody/content/application~1json/schema", refused, - func(doc *ir.Document) bool { - return openapitest.BodyTarget(t, opByPath(t, doc, "POST", "/c").Request) != ir.PrimTypeID(ir.PrimAny) - }}, - {"a schema's $ref to a refused one", ` /c: - post: - requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/X'}}}} - responses: {"200": {description: OK}} -components: - schemas: - X: {$ref: '#/paths/~1a/get/requestBody/content/application~1json/schema'} -`, "/components/schemas/X", refused, func(doc *ir.Document) bool { - x, ok := doc.Types["t/openapi/components/schemas/X"].(*ir.Scalar) - return !ok || x.Base == nil || x.Base.Target != ir.PrimTypeID(ir.PrimAny) - }}, - {"parameter by index", ` /c: - post: - parameters: [{$ref: '#/paths/~1a/parameters/0'}] - responses: {"200": {description: OK}} -`, "/paths/~1c/post/parameters/0", refused, - func(doc *ir.Document) bool { return len(opByPath(t, doc, "POST", "/c").Params) > 0 }}, - {"mapping value", " /c:\n post:\n" + - strings.Replace(discriminated, "%s", "'#/paths/~1a/get/requestBody/content/application~1json/schema'", 1), - "/paths/~1c/post/requestBody/content/application~1json/schema/discriminator/mapping/near", refused, - func(doc *ir.Document) bool { return mapsTag(doc, "near") }}, - {"inside the fields", ` /c: - $ref: '#/paths/~1b' - get: - requestBody: {$ref: '#/paths/~1a/get/requestBody'} - responses: {"200": {description: OK}} -`, "/paths/~1c/get/requestBody", refused, bodyAt("GET")}, - {"a key not written beside", ` /c: - post: - requestBody: {$ref: '#/paths/~1a/post/requestBody'} - responses: {"200": {description: OK}} -`, "/paths/~1c/post/requestBody", resolved, nil}, - {"an alias's position", ` /d: *a - /c: - post: - requestBody: {$ref: '#/paths/~1d/get/requestBody'} - responses: {"200": {description: OK}} -`, "/paths/~1c/post/requestBody", refused, bodyAt("POST")}, - {"a key only written beside", ` /c: - post: - requestBody: {$ref: '#/paths/~1a/put/requestBody'} - responses: {"200": {description: OK}} -`, "/paths/~1c/post/requestBody", unresolved, bodyAt("POST")}, - } - for _, tc := range cases { - doc, diags := parseFull(t, besideRefSpec(tc.paths, "")) - errs := errorsAt(diags) - if tc.want == resolved { - assert.Empty(t, errs, "%s: resolved as main resolves it", tc.name) - continue - } - require.Len(t, errs[tc.site], 1, "%s: one report at the reference: %v", tc.name, errs) - assert.Len(t, errs, 1, "%s: and no other: %v", tc.name, errs) - assert.Equal(t, tc.want == refused, strings.Contains(errs[tc.site][0], "GitHub #824"), - "%s: %s", tc.name, errs[tc.site][0]) - assert.False(t, tc.leaked(doc), "%s: nothing is lowered for the reference", tc.name) - } -} - -// TestPathItemRefSiblings_ADefsPointerBesideARefIsRefused pins a "#/$defs/..." -// pointer written in the fields beside a $ref, for a $ref and for a mapping -// value. Its definitions are read from where it is written, so through /a's -// $ref: the mapping's took /b's Y, hoisted at /a's own position with /b's -// properties, and the $ref found none. Each is now one error saying why. -func TestPathItemRefSiblings_ADefsPointerBesideARefIsRefused(t *testing.T) { - t.Parallel() - const schema = ` schema: - $defs: {Y: {type: object, properties: {k: {type: string}, %s: {type: string}}}} - type: object - properties: {y: {$ref: '#/$defs/Y'}} - oneOf: [{$ref: '#/components/schemas/Cat'}] - discriminator: {propertyName: k, mapping: {cat: '#/components/schemas/Cat', y: '#/$defs/Y'}} -` - spec := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /a: - $ref: '#/paths/~1b' - get: - requestBody: - content: - application/json: -` + strings.Replace(schema, "%s", "near", 1) + ` responses: {"200": {description: OK}} - /b: - get: - requestBody: - content: - application/json: -` + strings.Replace(schema, "%s", "far", 1) + ` responses: {"200": {description: OK}} -components: - schemas: - Cat: {type: object, properties: {k: {type: string}}} -` - doc, diags := parseFull(t, spec) - - const at = "/paths/~1a/get/requestBody/content/application~1json/schema" - errs := errorsAt(diags) - for _, site := range []jsontext.Pointer{at + "/properties/y", at + "/discriminator/mapping/y"} { - require.Len(t, errs[site], 1, "one report at %s: %v", site, errs) - assert.Contains(t, errs[site][0], "as is any definition it can name", site) - } - assert.NotContains(t, doc.Types, ir.TypeID("t/anon"+at+"/$defs/Y"), - "nothing is hoisted at /a's position from /b's definitions") -} - -// mapsTag reports whether a discriminator anywhere in doc maps tag. -func mapsTag(doc *ir.Document, tag string) bool { - for _, td := range doc.Types { - var d *ir.Discriminator - switch n := td.(type) { - case *ir.Union: - d = n.Discriminator - case *ir.Model: - d = n.Discriminator - default: - continue - } - if d == nil { - continue - } - if _, mapped := d.Mapping[tag]; mapped { - return true - } - } - return false -} - -// errorsAt returns the messages of the unresolved-ref errors in diags by -// position. -func errorsAt(diags []ir.Diagnostic) map[jsontext.Pointer][]string { - out := map[jsontext.Pointer][]string{} - for _, d := range diags { - if d.Severity == ir.SeverityError && d.Code == diag.UnresolvedRef { - out[d.Provenance.Pointer] = append(out[d.Provenance.Pointer], d.Message) - } - } - return out -} - -// TestPathItemRefSiblings_APointerIntoAnotherDocumentIsNotRefused pins that the -// refusal reads a pointer in the source only. /c names ext.yaml's /a, whose -// pointer spells a position /a here writes beside its $ref, so a check reading -// the pointer alone would refuse a reference the resolver read correctly. -func TestPathItemRefSiblings_APointerIntoAnotherDocumentIsNotRefused(t *testing.T) { - t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "ext.yaml"), []byte(`openapi: 3.1.0 -info: {title: E, version: "1"} -paths: - /a: - get: - requestBody: {content: {application/json: {schema: {type: object, properties: {ext: {type: string}}}}}} - responses: {"200": {description: OK}} -`), 0o600)) - root := filepath.Join(dir, "root.yaml") - src := besideRefSpec(` /c: - post: - operationId: postC - requestBody: {$ref: 'ext.yaml#/paths/~1a/get/requestBody'} - responses: {"200": {description: OK}} -`, "") - doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: root, Data: []byte(src)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err) - require.NotNil(t, doc) - - assert.Empty(t, errorsAt(diags)) - body, ok := doc.Types[openapitest.BodyTarget(t, openapitest.FindOp(t, doc, "postC").Request)].(*ir.Model) - require.True(t, ok, "/c lowers the other document's body") - assert.Equal(t, "ext", body.Properties[0].WireName) -} diff --git a/compilers/openapi/internal/operation/pathitemref_unmounted_test.go b/compilers/openapi/internal/operation/pathitemref_unmounted_test.go new file mode 100644 index 00000000..71f1de79 --- /dev/null +++ b/compilers/openapi/internal/operation/pathitemref_unmounted_test.go @@ -0,0 +1,155 @@ +package operation_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" +) + +// pathItemRefUnmountedSpec mounts no operation on either side of the reference: +// the use site writes item-level constructs and the referent declares none. +const pathItemRefUnmountedSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + servers: [{url: https://use.example}] + x-beside: use + /b: + summary: ref summary +` + +// TestPathItemRefSiblings_UnmountedKeepsSiblings pins the third carrier: an item +// that mounts no operation on either side has no operation to hold the use +// site's constructs, so they are kept on the nearest node that has an Unmodeled +// map and announced, exactly as a path item with no operations already was. +func TestPathItemRefSiblings_UnmountedKeepsSiblings(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefUnmountedSpec) + openapitest.RequireNoErrorDiags(t, diags) + + svc := doc.Services[0] + entry, ok := svc.Unmodeled["openapi:pathItem/paths/~1a/servers"] + require.True(t, ok, "the use site's servers are kept on the service") + assert.JSONEq(t, `[{"url":"https://use.example"}]`, string(entry.Value)) + ext, ok := svc.Unmodeled["openapi:pathItem/paths/~1a/x-beside"] + require.True(t, ok) + assert.JSONEq(t, `"use"`, string(ext.Value)) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/paths/~1a"), + "the preservation is announced at the use site's own pointer") +} + +// pathItemRefUnmountedWebhookSpec mounts nothing on either side of a webhook's +// $ref, so the use site's constructs have no operation to ride on. +const pathItemRefUnmountedWebhookSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +webhooks: + hooked: + $ref: '#/components/pathItems/Empty' + x-beside: use +components: + pathItems: + Empty: + summary: ref summary +` + +// TestPathItemRefSiblings_UnmountedWebhookKeepsSiblings pins the orphan carrier +// on the webhook route: the use site's constructs land on the service, under the +// use site's own scope, and the preservation is announced there. +func TestPathItemRefSiblings_UnmountedWebhookKeepsSiblings(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefUnmountedWebhookSpec) + openapitest.RequireNoErrorDiags(t, diags) + + entry, ok := doc.Services[0].Unmodeled["openapi:pathItem/webhooks/hooked/x-beside"] + require.True(t, ok, "the webhook's sibling x-* is kept on the service") + assert.JSONEq(t, `"use"`, string(entry.Value)) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/webhooks/hooked"), + "the preservation is announced at the use site's own pointer") +} + +// pathItemRefUnmountedCallbackSpec mounts nothing on either side of a callback +// expression's $ref. +const pathItemRefUnmountedCallbackSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /p: + post: + operationId: parent + callbacks: + onEvent: + '{$request.body#/url}': + $ref: '#/components/pathItems/Empty' + x-beside: use + responses: {"200": {description: OK}} +components: + pathItems: + Empty: + summary: ref summary +` + +// TestPathItemRefSiblings_UnmountedCallbackKeepsSiblings pins the orphan carrier +// on the callback route, which puts the kept constructs on the parent's HTTP +// binding, where the callback itself lives. +func TestPathItemRefSiblings_UnmountedCallbackKeepsSiblings(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefUnmountedCallbackSpec) + openapitest.RequireNoErrorDiags(t, diags) + + const expr = "{$request.body#~1url}" + parent := openapitest.FindOp(t, doc, "parent") + entry, ok := parent.Bindings.HTTP[0].Unmodeled["openapi:pathItem/paths/~1p/post/callbacks/onEvent/"+expr+"/x-beside"] + require.True(t, ok, "the expression's sibling x-* is kept on the parent's binding") + assert.JSONEq(t, `"use"`, string(entry.Value)) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, + "/paths/~1p/post/callbacks/onEvent/"+expr), + "the preservation is announced at the use site's own pointer") +} + +// pathItemRefUnmountedCollisionSpec declares the same constructs on both sides +// of a $ref neither side mounts an operation for. +const pathItemRefUnmountedCollisionSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + $ref: '#/paths/~1b' + summary: use summary + x-both: use + bogusBoth: {responses: {"200": {description: USE}}} + /b: + summary: ref summary + x-both: ref + bogusBoth: {responses: {"200": {description: REF}}} +` + +// TestPathItemRefSiblings_UnmountedCollisionKeepsTheUseSite pins precedence on +// the carrier an item mounting no operation uses: the use site keeps every +// construct there too, and a summary, which that carrier keeps from neither +// side, is reported as colliding nowhere. +func TestPathItemRefSiblings_UnmountedCollisionKeepsTheUseSite(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathItemRefUnmountedCollisionSpec) + openapitest.RequireNoErrorDiags(t, diags) + + svc := doc.Services[0] + for key, want := range map[string]string{ + "openapi:pathItem/paths/~1a/x-both": `"use"`, + "openapi:pathItem/paths/~1a/bogusBoth": `{"responses":{"200":{"description":"USE"}}}`, + } { + entry, ok := svc.Unmodeled[key] + require.True(t, ok, key) + assert.JSONEq(t, want, string(entry.Value), key) + assert.True(t, strings.HasPrefix(string(entry.Provenance.Pointer), "/paths/~1a/"), key) + } + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, "/paths/~1a/x-both")) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, "/paths/~1a/bogusBoth")) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.PathItemRefCollision, "/paths/~1a/summary"), + "neither summary is kept, so neither is the one kept") + assert.False(t, openapitest.HasDiag(diags, diag.UnknownKeyEntryTaken)) +} diff --git a/compilers/openapi/internal/schema/allof_conflicts_test.go b/compilers/openapi/internal/schema/allof_conflicts_test.go new file mode 100644 index 00000000..cd3cecb3 --- /dev/null +++ b/compilers/openapi/internal/schema/allof_conflicts_test.go @@ -0,0 +1,912 @@ +package schema_test + +import ( + "encoding/json/jsontext" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +func TestAllOf_OverlappingInlineBranchesReconcile(t *testing.T) { + t.Parallel() + // Two inline allOf branches redeclare the same fields — the shape GitHub's + // webhook `forkee` uses (a documented object plus a doc-stripped duplicate + // that marks some fields required). allOf is an intersection, so each wire + // name must reconcile to a single property, not append a duplicate. + spec := openapitest.ComponentSpec(` Forkish: + allOf: + - type: object + properties: + id: {type: integer, description: the identifier} + name: {type: string} + - type: object + required: [id] + properties: + id: {type: integer} + url: {type: string} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Forkish")].(*ir.Model) + require.True(t, ok, "Forkish should be a model") + + byWire := map[string]int{} + for _, p := range m.Properties { + byWire[p.WireName]++ + } + assert.Equal(t, 1, byWire["id"], "id declared in both branches reconciles to one property") + assert.Equal(t, 1, byWire["name"]) + assert.Equal(t, 1, byWire["url"]) + require.Len(t, m.Properties, 3, "no duplicate properties across overlapping inline branches") + + var id ir.Property + for _, p := range m.Properties { + if p.WireName == "id" { + id = p + } + } + assert.True(t, id.Required, "required in the second branch => required on the merged model (allOf intersection)") + assert.Equal(t, "the identifier", id.Docs.Description, + "the richer (documented) declaration defines the reconciled shape") +} + +func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { + t.Parallel() + // The bare declaration comes first and the richer one second: reconciliation + // must still surface every optional detail, so branch order never loses + // information (the reverse of the forkee documented-first shape). + spec := openapitest.ComponentSpec(` Tokenish: + allOf: + - type: object + properties: + token: {type: string} + - type: object + required: [token] + properties: + token: + type: string + format: password + description: the access token + default: none + minLength: 1 + deprecated: true + examples: [abc] + xml: {name: tok} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) + require.True(t, ok, "Tokenish should be a model") + require.Len(t, m.Properties, 1, "token reconciles to a single property") + + tok := m.Properties[0] + assert.True(t, tok.Required, "required in the richer branch => required on the merged property") + assert.True(t, tok.Secret, "secrecy (format:password) adopted from the richer branch") + assert.Equal(t, "the access token", tok.Docs.Description, "description adopted whichever branch carries it") + require.NotNil(t, tok.Default, "default adopted from the richer branch") + require.NotNil(t, tok.Constraints, "constraints adopted from the richer branch") + require.NotNil(t, tok.Constraints.MinLength, "minLength adopted from the richer branch") + require.NotNil(t, tok.Deprecation, "deprecation adopted from the richer branch") + require.NotNil(t, tok.XML, "xml hints adopted from the richer branch") + require.Len(t, tok.Examples, 1, "examples adopted from the richer branch") +} + +// TestAllOf_RedeclarationOrsSecret pins that a redaction one branch writes +// survives the fold in both declaration orders, and that it costs the fold +// nothing else: `format: password` names no shape, so both declarations share +// the bare string's target and every detail folds rather than the losing one +// being dropped whole. +func TestAllOf_RedeclarationOrsSecret(t *testing.T) { + t.Parallel() + secret := " token: {type: string, format: password}\n" + bounded := " token: {type: string, minLength: 3, description: the token}\n" + branch := " - type: object\n properties:\n" + for _, tc := range []struct{ name, first, second string }{ + {"password first", secret, bounded}, + {"password second", bounded, secret}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(" Tokenish:\n allOf:\n" + + branch + tc.first + branch + tc.second) + doc, diags := schema.LowerSpec(t, spec) + assert.Empty(t, diags, "nothing conflicts") + m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) + require.True(t, ok, "Tokenish should be a model") + require.Len(t, m.Properties, 1, "token reconciles to a single property") + tok := m.Properties[0] + assert.True(t, tok.Secret, "either branch's redaction makes the merged field secret") + assert.Equal(t, ir.TypeID("t/prim/string"), tok.Type.Target, "both branches share the target") + require.NotNil(t, tok.Constraints, "the other branch's bound is folded, not dropped") + assert.Equal(t, new(int64(3)), tok.Constraints.MinLength) + assert.Equal(t, "the token", tok.Docs.Description) + assert.Empty(t, tok.Unmodeled, "no declaration was kept as a loser") + }) + } +} + +func TestAllOf_ConflictingRedeclaredDescriptionDiagnosed(t *testing.T) { + t.Parallel() + // Two branches describe the same field differently. The first declaration in + // source order wins the shape, but the dropped description is surfaced as an + // info diagnostic rather than vanishing silently. + spec := openapitest.ComponentSpec(` Clashish: + allOf: + - type: object + properties: + id: {type: integer, description: the first meaning} + - type: object + properties: + id: {type: integer, description: a different meaning} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) // a description clash is info-level, never an error + m, ok := doc.Types[componentID("Clashish")].(*ir.Model) + require.True(t, ok, "Clashish should be a model") + require.Len(t, m.Properties, 1, "id still reconciles to one property") + assert.Equal(t, "the first meaning", m.Properties[0].Docs.Description, + "the first declaration in source order wins the description") + assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "a differing redeclared description is surfaced, not dropped silently") +} + +func TestAllOf_ConflictingRedeclaredTypeDiagnosedAndKept(t *testing.T) { + t.Parallel() + // allOf is an intersection, so a field one branch types `string` and another + // types `integer` describes an unsatisfiable schema. Reconciliation keeps the + // first declaration's shape (as before) but must no longer swallow the + // conflict — it names the field and both branch sites so the author can find + // and fix them. + spec := openapitest.ComponentSpec(` Conflictish: + allOf: + - type: object + properties: + id: {type: string} + - type: object + properties: + id: {type: integer} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) // a redeclaration conflict is a warning, not a refusal + m, ok := doc.Types[componentID("Conflictish")].(*ir.Model) + require.True(t, ok, "Conflictish should be a model") + require.Len(t, m.Properties, 1, "id still reconciles to one property") + assert.Equal(t, ir.TypeID("t/prim/string"), m.Properties[0].Type.Target, + "the first declaration in source order still wins the shape") + + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "the incompatible type redeclaration is diagnosed exactly once") + d := conflicts[0] + assert.Equal(t, ir.SeverityWarning, d.Severity, "a usable-but-suspicious model is a warning") + assert.Contains(t, d.Message, `"id"`, "the diagnostic names the conflicting field") + assert.Contains(t, d.Message, "allOf/0", "the diagnostic names the first branch site") + assert.Contains(t, d.Message, "allOf/1", "the diagnostic names the second branch site") + + // A diagnostic is not part of the document, so the losing declaration is + // also kept where a consumer reading the IR will reach it (GitHub #424). + lost, ok := m.Properties[0].Unmodeled["openapi:conflicting-redeclaration"+ + "/components/schemas/Conflictish/allOf/1/properties/id"] + require.True(t, ok, + "the discarded type is kept beside the winner; got %v", m.Properties[0].Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, lost.Reason) + assert.JSONEq(t, `{"type":"integer"}`, string(lost.Value), + "and it is the declaration the second branch wrote, not the one that won") + assert.Equal(t, jsontext.Pointer("/components/schemas/Conflictish/allOf/1/properties/id"), lost.Provenance.Pointer) +} + +// TestAllOf_OverlappingPrimitivesAreReportedNotConflicting pins that two +// formats whose kinds share values draw one info at the redeclaration, instead +// of the unsatisfiable-type warning, keep the first declaration's kind and keep +// the redeclaration, in either branch order. +func TestAllOf_OverlappingPrimitivesAreReportedNotConflicting(t *testing.T) { + t.Parallel() + const at = "/components/schemas/Overlapish/allOf/1/properties/n" + for _, order := range [][2]string{{"int32", "uint32"}, {"uint32", "int32"}} { + t.Run(order[0]+" first", func(t *testing.T) { + t.Parallel() + bounds := [2]int{10, 20} + if order[0] == "uint32" { + bounds = [2]int{20, 10} + } + spec := openapitest.ComponentSpec(fmt.Sprintf(` Overlapish: + allOf: + - type: object + properties: + n: {type: integer, format: %s, minimum: %d} + - type: object + properties: + n: {type: integer, format: %s, minimum: %d} +`, order[0], bounds[0], order[1], bounds[1])) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Overlapish")].(*ir.Model) + require.True(t, ok, "Overlapish should be a model") + require.Len(t, m.Properties, 1) + assert.Equal(t, ir.TypeID("t/prim/"+order[0]), m.Properties[0].Type.Target, "the first declaration's kind stays") + + assert.Empty(t, conflictDiags(diags), "overlapping kinds are not an unsatisfiable type") + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, at), + "overlap", "the overlap is reported at the redeclaration, as an info") + var atProperty []ir.Diagnostic + for _, d := range diags { + if d.Provenance.Pointer == at { + atProperty = append(atProperty, d) + } + } + assert.Len(t, atProperty, 1, "exactly one diagnostic at the property; got %v", atProperty) + assert.Contains(t, m.Properties[0].Unmodeled, + "openapi:conflicting-redeclaration"+at, "and the redeclaration is kept") + }) + } +} + +// TestAllOf_EnumPairsAreJudgedByKind pins what an enum side draws at the schema +// level, in either order: an enum over a bare kind it equals or narrows wins +// with no diagnostic; a bare kind that admits one of its members overlaps it, +// which is info; a kind that admits none of them warns, as does another family; +// and two enums of one kind, each with members, overlap. A Scalar node of a +// judged kind is judged by member too, and one with no judge refuses nothing. +func TestAllOf_EnumPairsAreJudgedByKind(t *testing.T) { + t.Parallel() + cases := []struct { + name, a, b string + info, warn bool + }{ + {"enum against uuid", "{type: string, enum: [a]}", "{type: string, format: uuid}", false, true}, + {"enum against int32", "{type: integer, enum: [3000000000]}", "{type: integer, format: int32}", false, true}, + {"enum against a date it admits", `{type: string, enum: ["2024-01-01"]}`, "{type: string, format: date}", true, false}, + {"enum against a uuid scalar", "{type: string, enum: [a]}", "{type: string, format: uuid, contentMediaType: text/plain}", false, true}, + {"enum against a date scalar it admits", `{type: string, enum: ["2024-01-01"]}`, "{type: string, format: date, contentMediaType: text/plain}", true, false}, + {"empty enum against byte", "{type: string, enum: []}", "{type: string, format: byte}", false, true}, + {"enum against byte", "{type: string, enum: [a]}", "{type: string, format: byte}", true, false}, + {"enum against integer", "{type: string, enum: [a]}", "{type: integer}", false, true}, + {"enum against enum", "{type: string, enum: [a]}", "{type: string, enum: [b]}", true, false}, + {"enum against string", "{type: string, enum: [a]}", "{type: string}", false, false}, + } + for _, tc := range cases { + for _, swap := range []bool{false, true} { + a, b := tc.a, tc.b + if swap { + a, b = b, a + } + t.Run(fmt.Sprintf("%s swapped=%t", tc.name, swap), func(t *testing.T) { + t.Parallel() + _, diags := schema.LowerSpec(t, allOfConflictSpec(a, b)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Equal(t, tc.info, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "the overlap info") + if tc.warn { + assert.Len(t, conflictDiags(diags), 1, "one conflict warning") + return + } + assert.Empty(t, conflictDiags(diags), "no conflict") + }) + } + } +} + +// TestAllOf_ALosingRedeclarationIsKeptAsWritten pins what the entry holds at +// the two property shapes that have no schema node of their own to speak of: a +// `$ref` is kept as the `$ref` the position wrote, not as the target it names, +// and a boolean schema — which has no source node to render — is kept as its value. +func TestAllOf_ALosingRedeclarationIsKeptAsWritten(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Str: {type: string} + Referred: + allOf: + - type: object + properties: + id: {type: integer} + - type: object + properties: + id: {$ref: '#/components/schemas/Str'} + Nothing: + allOf: + - type: object + properties: + id: {type: integer} + - type: object + properties: + id: false +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + for _, tc := range []struct{ model, want string }{ + {"Referred", `{"$ref":"#/components/schemas/Str"}`}, + {"Nothing", `false`}, + } { + m, ok := doc.Types[componentID(tc.model)].(*ir.Model) + require.True(t, ok, "%s should be a model", tc.model) + require.Len(t, m.Properties, 1) + key := "openapi:conflicting-redeclaration/components/schemas/" + tc.model + "/allOf/1/properties/id" + lost, ok := m.Properties[0].Unmodeled[key] + require.True(t, ok, "%s keeps its losing declaration; got %v", tc.model, m.Properties[0].Unmodeled) + assert.JSONEq(t, tc.want, string(lost.Value)) + } +} + +func TestAllOf_ConflictingRedeclaredConstraintDiagnosed(t *testing.T) { + t.Parallel() + // Same target type, but the two branches pin the same keyword to different + // values (maxLength 10 vs 20). The chosen winner is arbitrary source order, so + // the dropped bound is surfaced rather than silently discarded. + spec := openapitest.ComponentSpec(` Boundish: + allOf: + - type: object + properties: + code: {type: string, maxLength: 10} + - type: object + properties: + code: {type: string, maxLength: 20} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Boundish")].(*ir.Model) + require.True(t, ok, "Boundish should be a model") + require.Len(t, m.Properties, 1, "code reconciles to one property") + require.NotNil(t, m.Properties[0].Constraints, "the first declaration's constraints are kept") + require.NotNil(t, m.Properties[0].Constraints.MaxLength) + assert.Equal(t, int64(10), *m.Properties[0].Constraints.MaxLength, + "the first declaration in source order still wins the constraint") + + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "the incompatible constraint redeclaration is diagnosed exactly once") + d := conflicts[0] + assert.Equal(t, ir.SeverityWarning, d.Severity) + assert.Contains(t, d.Message, `"code"`, "the diagnostic names the conflicting field") + assert.Contains(t, d.Message, "allOf/0") + assert.Contains(t, d.Message, "allOf/1") +} + +func TestAllOf_CompatibleRedeclarationStaysSilent(t *testing.T) { + t.Parallel() + // The reconcilable case: identical target type, the second branch only adds + // `required`. This must stay silent — a redeclaration is not by itself a + // conflict, only an incompatible one is. + spec := openapitest.ComponentSpec(` Compatish: + allOf: + - type: object + properties: + id: {type: integer} + - type: object + required: [id] + properties: + id: {type: integer} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Compatish")].(*ir.Model) + require.True(t, ok, "Compatish should be a model") + require.Len(t, m.Properties, 1, "id reconciles to one property") + assert.True(t, m.Properties[0].Required, "required from the second branch is OR-ed in") + + assert.Empty(t, conflictDiags(diags), + "a compatible redeclaration is not a conflict") +} + +func TestAllOf_PropertyAlongsideAllOfReconciles(t *testing.T) { + t.Parallel() + // A property declared directly on the allOf schema redeclares a field an inline + // branch already contributed; the sibling and the branch declaration reconcile + // into one property instead of colliding on the wire. + spec := openapitest.ComponentSpec(` Mixish: + required: [id] + properties: + id: {type: integer} + allOf: + - type: object + properties: + id: {type: integer, description: the identifier} + name: {type: string} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Mixish")].(*ir.Model) + require.True(t, ok, "Mixish should be a model") + require.Len(t, m.Properties, 2, "id (branch + sibling) reconciles to one; name stays its own") + + var id ir.Property + for _, p := range m.Properties { + if p.WireName == "id" { + id = p + } + } + assert.True(t, id.Required, "required alongside allOf => required on the reconciled property") + assert.Equal(t, "the identifier", id.Docs.Description, + "detail from the branch declaration survives reconciliation with the sibling property") +} + +// TestAllOf_ReconcileAccumulatesRicherDetailForAnUnknownFormat is +// TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder with a format that +// hoists, in the richer branch. +func TestAllOf_ReconcileAccumulatesRicherDetailForAnUnknownFormat(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Tokenish: + allOf: + - type: object + properties: + token: {type: string} + - type: object + required: [token] + properties: + token: + type: string + format: hex-token + description: the access token + default: none + minLength: 1 + deprecated: true + examples: [abc] + xml: {name: tok} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) + require.True(t, ok, "Tokenish should be a model") + require.Len(t, m.Properties, 1, "token reconciles to a single property") + + tok := m.Properties[0] + assert.Equal(t, ir.KindScalar, doc.Types[tok.Type.Target].Kind(), "the format's scalar types the field") + assert.True(t, tok.Required) + sc, ok := doc.Types[tok.Type.Target].(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, "the access token", sc.Docs.Description, "the description stays on the scalar that wrote it") + assert.NotNil(t, sc.Deprecation, "so does the deprecation") + assert.Len(t, sc.Examples, 1, "and the examples") + require.NotNil(t, tok.Default) + require.NotNil(t, tok.Constraints) + require.NotNil(t, tok.Constraints.MinLength) + require.NotNil(t, sc.XML) + assert.Empty(t, tok.Unmodeled) +} + +// TestObject_MapRedeclaredAsAModelIsNoConflict pins that an allOf branch +// declaring a property as a map and another declaring it as a model is no +// conflicting redeclaration: both are objects, and one instance can satisfy +// both. Comparing node kinds alone reported it, and only in the order where +// the model was built first, so the spec runs in both orders. +func TestObject_MapRedeclaredAsAModelIsNoConflict(t *testing.T) { + t.Parallel() + const ( + a = " A: {type: object, properties: {b: {$ref: '#/components/schemas/B'}}}\n" + b = " B:\n allOf:\n - properties: {m: {type: object, additionalProperties: {type: string}}}\n - properties: {m: {$ref: '#/components/schemas/A'}}\n" + inline = " C:\n allOf:\n - properties: {meta: {type: object, additionalProperties: {type: string}}}\n - properties: {meta: {type: object, properties: {a: {type: string}}}}\n" + ) + for _, src := range []string{a + b + inline, inline + b + a} { + _, diags := parseFull(t, openapitest.ComponentSpec(src)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, conflictDiags(diags), "a map and a model can describe one object") + } +} + +func TestAllOf_UntypedRedeclarationDoesNotConflict(t *testing.T) { + t.Parallel() + // One branch leaves the field schemaless (the top type), the other types it. + // `any` intersects with everything under allOf, so this is a narrowing, not a + // contradiction — it must not be reported. + spec := openapitest.ComponentSpec(` Anyish: + allOf: + - type: object + properties: + id: {description: the identifier} + - type: object + properties: + id: {type: integer} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := typeByName(doc, "Anyish").(*ir.Model) + require.True(t, ok, "Anyish should be a model") + require.Len(t, m.Properties, 1, "id reconciles to one property") + assert.Empty(t, conflictDiags(diags), + "the schemaless top type never conflicts with a sibling redeclaration") +} + +func TestAllOf_EquivalentNumericBoundsDoNotConflict(t *testing.T) { + t.Parallel() + // The same bound spelled two ways (10 and 10.0) denotes one value, so it must + // compare equal by magnitude and stay silent. + spec := openapitest.ComponentSpec(` Boundish: + allOf: + - type: object + properties: + n: {type: number, minimum: 10} + - type: object + properties: + n: {type: number, minimum: 10.0} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, conflictDiags(diags), + "10 and 10.0 are the same numeric bound, not a conflict") +} + +func TestAllOf_DifferingNumericBoundsConflict(t *testing.T) { + t.Parallel() + // Two branches pin the same lower bound to different magnitudes; the kept + // winner is arbitrary source order, so the dropped bound is surfaced. + spec := openapitest.ComponentSpec(` Boundish: + allOf: + - type: object + properties: + n: {type: integer, minimum: 5} + - type: object + properties: + n: {type: integer, minimum: 10} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "differing numeric bounds are diagnosed once") + assert.Contains(t, conflicts[0].Message, `"n"`) +} + +func TestAllOf_ScalarVersusObjectRedeclarationConflicts(t *testing.T) { + t.Parallel() + // A scalar in one branch and a structural type in the other cannot both hold. + spec := openapitest.ComponentSpec(` Mixed: + allOf: + - type: object + properties: + f: {type: string} + - type: object + properties: + f: + type: object + properties: + x: {type: string} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "a scalar against an object is diagnosed once") + assert.Contains(t, conflicts[0].Message, `"f"`) +} + +func TestAllOf_DistinctInlineObjectsDoNotConflict(t *testing.T) { + t.Parallel() + // Two branches redeclare the field as distinct inline objects. Each hoists its + // own model at its own pointer, so the targets differ — but two objects of the + // same kind are not provably contradictory, and conflict detection never + // guesses. + spec := openapitest.ComponentSpec(` Objish: + allOf: + - type: object + properties: + f: + type: object + properties: + x: {type: string} + - type: object + properties: + f: + type: object + properties: + x: {type: string} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, conflictDiags(diags), + "two distinct inline objects for one field are not a provable conflict") +} + +func TestAllOf_ConstraintAndFormatConflictsDiagnosed(t *testing.T) { + t.Parallel() + // Each way a redeclaration can contradict across branches: a differing + // minimum, exclusiveMinimum, pattern or multipleOf, a bound and a multipleOf + // that differ past the magnitude a rational will build, and two + // format-derived primitives (uuid vs date-time). + // Each keeps the first declaration but surfaces exactly one conflict naming + // the field, both branch sites, and — for the constraint cases — the + // offending keyword with both of its conflicting values, so the author + // never has to diff both branches by hand. + cases := []struct { + name, a, b, wantDetail string + }{ + { + name: "minimum", + a: "{type: number, minimum: 10}", + b: "{type: number, minimum: 20}", + wantDetail: "conflicting minimum (10 and 20)", + }, + { + // The exclusive bound is a keyword of its own, so it conflicts + // under its own name rather than as a differing sense of minimum. + name: "exclusiveMinimum", + a: "{type: number, exclusiveMinimum: 10}", + b: "{type: number, exclusiveMinimum: 20}", + wantDetail: "conflicting exclusiveMinimum (10 and 20)", + }, + { + name: "pattern", + a: "{type: string, pattern: '^a$'}", + b: "{type: string, pattern: '^b$'}", + wantDetail: `conflicting pattern ("^a$" and "^b$")`, + }, + { + name: "multipleOf", + a: "{type: integer, multipleOf: 2}", + b: "{type: integer, multipleOf: 3}", + wantDetail: "conflicting multipleOf (2 and 3)", + }, + { + // The other half of the past-a-rational rows in + // TestAllOf_CompatibleConstraintRedeclarationsStaySilent: a + // magnitude no rational holds still has to be ordered, not waved + // through, or the fix for the equal pair would just silence every + // pair that large. + name: "minimum too large for a rational", + a: "{type: number, minimum: 1e1000001}", + b: "{type: number, minimum: 2e1000001}", + wantDetail: "conflicting minimum (1e1000001 and 2e1000001)", + }, + { + name: "multipleOf too large for a rational", + a: "{type: number, multipleOf: 1e1000001}", + b: "{type: number, multipleOf: 1e1000002}", + wantDetail: "conflicting multipleOf (1e1000001 and 1e1000002)", + }, + { + name: "uuid vs date-time", + a: "{type: string, format: uuid}", + b: "{type: string, format: date-time}", + // A type conflict, not a constraint one — checked via the + // type-conflict message shape in TestAllOf_TypeConflictMessageNamesBothTypes. + wantDetail: "incompatible types", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "%s is diagnosed exactly once", tc.name) + assert.Contains(t, conflicts[0].Message, `"v"`, "the diagnostic names the field") + assert.Contains(t, conflicts[0].Message, "allOf/0", "names the first branch site") + assert.Contains(t, conflicts[0].Message, "allOf/1", "names the second branch site") + assert.Contains(t, conflicts[0].Message, tc.wantDetail, + "%s: the diagnostic names what actually differs", tc.name) + }) + } +} + +func TestAllOf_CompatibleConstraintRedeclarationsStaySilent(t *testing.T) { + t.Parallel() + // The false-positive guards for the constraint path: a keyword present on + // only one branch (both branches still carry constraints) is intersected, + // not a conflict — and the merge must genuinely carry both keywords + // forward on the reconciled property, not just stay quiet about the one it + // used to drop; one magnitude spelled two ways is one value, whether or not + // it is one a rational will build; and an unknown-format scalar narrows the + // plain type it is based on, so it wins with nothing to report. + cases := []struct { + name string + a, b string + assertMerged func(t *testing.T, c *ir.Constraints) + }{ + { + name: "one-sided keywords", + a: "{type: string, maxLength: 10}", + b: "{type: string, minLength: 2}", + assertMerged: func(t *testing.T, c *ir.Constraints) { + t.Helper() + require.NotNil(t, c) + require.NotNil(t, c.MaxLength, "the first branch's maxLength is kept") + require.NotNil(t, c.MinLength, "the second branch's minLength is adopted, not dropped") + assert.Equal(t, int64(10), *c.MaxLength) + assert.Equal(t, int64(2), *c.MinLength) + }, + }, + { + name: "one-sided pattern", + a: "{type: string, maxLength: 10}", + b: "{type: string, pattern: '^a'}", + assertMerged: func(t *testing.T, c *ir.Constraints) { + t.Helper() + require.NotNil(t, c) + require.NotNil(t, c.MaxLength, "the first branch's maxLength is kept") + assert.Equal(t, int64(10), *c.MaxLength) + assert.Equal(t, "^a", c.Pattern, "the second branch's pattern is adopted, not dropped") + }, + }, + { + // minimum and exclusiveMinimum are two keywords, so a branch + // declaring one and a branch declaring the other intersect to a + // field carrying both — not to whichever the merge picked. + name: "minimum and exclusiveMinimum adopted side by side", + a: "{type: number, minimum: 1}", + b: "{type: number, exclusiveMinimum: 5}", + assertMerged: func(t *testing.T, c *ir.Constraints) { + t.Helper() + require.NotNil(t, c) + require.NotNil(t, c.Min, "the first branch's minimum stays") + assert.Equal(t, "1", c.Min.String()) + require.NotNil(t, c.ExclusiveMin, "the second branch's exclusiveMinimum is adopted beside it") + assert.Equal(t, "5", c.ExclusiveMin.String()) + }, + }, + {name: "equivalent multipleOf", a: "{type: number, multipleOf: 2}", b: "{type: number, multipleOf: 2.0}"}, + // Both BigVal keywords at a magnitude math/big will not build as a + // rational. The value is the same on either branch, so there is nothing + // to report; a comparison that gave up on the magnitude instead would + // call one value two and fail a --fail-on warning build. + { + name: "equal minimum too large for a rational", + a: "{type: number, minimum: 1e1000001}", + b: "{type: number, minimum: 10e1000000}", + }, + { + name: "equal multipleOf too large for a rational", + a: "{type: number, multipleOf: 1e1000001}", + b: "{type: number, multipleOf: 10e1000000}", + }, + {name: "custom format over base", a: "{type: string, format: weird}", b: "{type: string}"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) + assert.Empty(t, diags, "%s draws no diagnostic", tc.name) + if tc.assertMerged == nil { + return + } + m, ok := typeByName(doc, "T").(*ir.Model) + require.True(t, ok, "T should be a model") + require.Len(t, m.Properties, 1, "v reconciles to one property") + tc.assertMerged(t, m.Properties[0].Constraints) + }) + } +} + +func TestAllOf_OpaqueScalarVsPrimitiveNoConflict(t *testing.T) { + t.Parallel() + // An opaque scalar (format without a base type) is unknown, not structural, + // so it's not provably incompatible with a primitive. The "never guess" + // principle means we don't flag this as a conflict. + spec := openapitest.ComponentSpec(` OpaqueTest: + allOf: + - type: object + properties: + id: {type: string} + - type: object + properties: + id: {format: custom} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, conflictDiags(diags), + "opaque scalar vs primitive is not flagged as a conflict") +} + +func TestAllOf_ThreeWayRedeclarationProducesTwoDiagnostics(t *testing.T) { + t.Parallel() + // Three branches typing one field differently fold as one set: the first + // declaration wins, and each other is reported against it, so two + // diagnostics, though all three pairs are incompatible. + spec := openapitest.ComponentSpec(` ThreeWay: + allOf: + - type: object + properties: + id: {type: string} + - type: object + properties: + id: {type: integer} + - type: object + properties: + id: {type: boolean} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 2, "three-way redeclaration produces two diagnostics") +} + +func TestAllOf_ThreeWayCompatibleRedeclarationStaysSilent(t *testing.T) { + t.Parallel() + // When three allOf branches declare the same field with compatible types + // (all the same), no conflict is reported. + spec := openapitest.ComponentSpec(` ThreeWayCompat: + allOf: + - type: object + properties: + id: {type: string} + - type: object + properties: + id: {type: string} + - type: object + properties: + id: {type: string} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, conflictDiags(diags), + "three-way compatible redeclaration stays silent") +} + +func TestAllOf_SatisfiableNarrowingsStaySilent(t *testing.T) { + t.Parallel() + // Each case narrows a base type to a stricter but satisfiable shape under + // allOf intersection (enum of the base's own primitive, const pinning a + // legal value, a union narrowed to one of its own members) — none is a + // provable contradiction, so a bare scalar sibling against any of these must + // not be flagged. + cases := []struct { + name, a, b string + }{ + {"enum narrows base type", "{type: string}", "{type: string, enum: [active, inactive]}"}, + {"const narrows base type", "{type: string}", "{type: string, const: dog}"}, + {"union narrowed to one member", "{type: [string, integer]}", "{type: string}"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, conflictDiags(diags), "%s must not be reported as a conflict", tc.name) + }) + } +} + +func TestAllOf_EnumVersusIncompatibleTypeStillConflicts(t *testing.T) { + t.Parallel() + // Guard against over-correcting the enum false-positive fix: an enum whose + // declared ValueType is string still provably conflicts with an + // incompatible primitive (integer). + _, diags := schema.LowerSpec(t, allOfConflictSpec( + "{type: string, enum: [active, inactive]}", "{type: integer}")) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "enum of strings vs integer is still a provable conflict") + assert.Contains(t, conflicts[0].Message, `"v"`, "the diagnostic names the conflicting field") +} + +func TestAllOf_TypeConflictMessageNamesBothTypes(t *testing.T) { + t.Parallel() + // The type-conflict diagnostic must say what actually differs, not just + // that something did: the two conflicting type identities, so the author + // can see at a glance what disagreed without cross-referencing the spec. + _, diags := schema.LowerSpec(t, allOfConflictSpec("{type: string}", "{type: integer}")) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1) + assert.Contains(t, conflicts[0].Message, "t/prim/string", "names the first branch's type") + assert.Contains(t, conflicts[0].Message, "t/prim/integer", "names the second branch's type") +} + +func TestAllOf_PropertyAlongsideAllOfConflictMessageIsAccurate(t *testing.T) { + t.Parallel() + // A property declared directly on the schema alongside allOf redeclares a + // field the inline branch also declares. The redeclaration's provenance + // pointer here is a plain "properties/id" path, never an "allOf/N" branch, + // so the message must not claim "allOf branches redeclare" — it must read + // correctly for a co-declared sibling property too (merge.PropIndex's + // MergeProperty folds both cases the same way; see its doc comment). + spec := openapitest.ComponentSpec(` Along: + type: object + properties: + id: {type: string} + allOf: + - type: object + properties: + id: {type: integer} +`) + _, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + conflicts := conflictDiags(diags) + require.Len(t, conflicts, 1, "the co-declared property conflict is diagnosed once") + d := conflicts[0] + assert.Contains(t, d.Message, `"id"`, "the diagnostic names the conflicting field") + assert.NotContains(t, d.Message, "allOf branches", + "the redeclaration site is a co-declared property, not an allOf branch") + assert.Equal(t, jsontext.Pointer("/components/schemas/Along/properties/id"), d.Provenance.Pointer, + "the diagnostic's own site is the co-declared property, not an allOf branch") +} diff --git a/compilers/openapi/internal/schema/allof_narrowing_test.go b/compilers/openapi/internal/schema/allof_narrowing_test.go new file mode 100644 index 00000000..d18a5a4e --- /dev/null +++ b/compilers/openapi/internal/schema/allof_narrowing_test.go @@ -0,0 +1,637 @@ +package schema_test + +import ( + "encoding/json/jsontext" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +// TestAllOf_AnInadmissibleBroadValueIsKeptInEitherOrder pins that the broader +// branch of a narrowing, whichever comes first, never lands a default the +// narrower kind refuses on the merged field: it is kept verbatim at its own +// branch with one info, so the two orders give the same field and the same +// diagnostics. +func TestAllOf_AnInadmissibleBroadValueIsKeptInEitherOrder(t *testing.T) { + t.Parallel() + const broad = " p: {type: number, default: 1.5}\n" + const narrow = " p: {type: integer}\n" + branch := " - type: object\n properties:\n" + for _, tc := range []struct{ name, first, second, broadPtr string }{ + {"broad first", broad, narrow, "/components/schemas/Ranged/allOf/0/properties/p"}, + {"narrow first", narrow, broad, "/components/schemas/Ranged/allOf/1/properties/p"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Ranged:\n allOf:\n"+ + branch+tc.first+branch+tc.second)) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Ranged")].(*ir.Model) + require.True(t, ok, "Ranged should be a model") + require.Len(t, m.Properties, 1) + + p := m.Properties[0] + assert.Equal(t, ir.TypeID("t/prim/integer"), p.Type.Target) + assert.Nil(t, p.Default, "the refused default does not land") + require.Len(t, diags, 1, "one info and nothing else") + assert.Equal(t, ir.SeverityInfo, diags[0].Severity) + assert.Equal(t, diag.DegradedConstruct, diags[0].Code) + assert.Equal(t, jsontext.Pointer(tc.broadPtr), diags[0].Provenance.Pointer) + entry, ok := p.Unmodeled["openapi:conflicting-redeclaration"+tc.broadPtr] + require.True(t, ok, "the broad branch is kept at its own pointer; got %v", p.Unmodeled) + assert.JSONEq(t, `{"type":"number","default":1.5}`, string(entry.Value)) + assert.Len(t, p.Unmodeled, 1) + }) + } +} + +// TestAllOf_ANullOnARefOverTheTopTypeIsKept folds two $refs to a scalar over +// the top type, one writing default: null. The top type admits null, a fact +// only the Base walk reaches, so the default lands and nothing is reported or +// kept aside, in either order. +func TestAllOf_ANullOnARefOverTheTopTypeIsKept(t *testing.T) { + t.Parallel() + const withNull = " p: {$ref: '#/components/schemas/Y', default: null}\n" + const plain = " p: {$ref: '#/components/schemas/Y'}\n" + branch := " - type: object\n properties:\n" + for _, tc := range []struct{ name, first, second string }{ + {"null first", withNull, plain}, + {"null second", plain, withNull}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Y: {description: free}\n Held:\n allOf:\n"+ + branch+tc.first+branch+tc.second)) + assert.Empty(t, diags, "nothing is refused") + m, ok := doc.Types[componentID("Held")].(*ir.Model) + require.True(t, ok, "Held should be a model") + require.Len(t, m.Properties, 1) + p := m.Properties[0] + require.NotNil(t, p.Default, "the null default lands") + assert.Equal(t, ir.ValueNull, p.Default.Kind) + assert.Empty(t, p.Unmodeled) + }) + } +} + +// TestAllOf_NonBareNarrowingIsOrderInvariant pins that a redeclaration with a +// side that is not a bare primitive settles on its narrower side whichever +// branch is written first, with no conflict or degradation reported and +// nothing kept. +func TestAllOf_NonBareNarrowingIsOrderInvariant(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, extra, narrow, broad string + kind ir.TypeKind + }{ + {"an enum over its kind", "", "{type: integer, enum: [1, 2]}", "{type: integer}", ir.KindEnum}, + {"a const over its kind", "", "{const: 1}", "{type: integer}", ir.KindLiteral}, + {"an untyped, described schema", "", "{type: integer}", "{description: the id}", ir.KindPrimitive}, + {"a list of a narrower kind", "", "{type: array, items: {type: integer, format: int32}}", + "{type: array, items: {type: integer}}", ir.KindList}, + {"a map of a narrower kind", "", "{type: object, additionalProperties: {type: integer, format: int32}}", + "{type: object, additionalProperties: {type: integer}}", ir.KindMap}, + {"an unknown format over its kind", "", "{type: string, format: hex-token}", "{type: string}", ir.KindScalar}, + {"a ref declared before the schema", " Id: {type: integer, format: int32}\n", + "{$ref: '#/components/schemas/Id'}", "{type: integer}", ir.KindScalar}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + types := make([]ir.TypeDef, 0, 2) + for _, order := range [][2]string{{tc.narrow, tc.broad}, {tc.broad, tc.narrow}} { + doc, diags := schema.LowerSpec(t, nonBareSpec(tc.extra, order[0], order[1])) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 1) + p := m.Properties[0] + types = append(types, doc.Types[p.Type.Target]) + assert.Equal(t, tc.kind, doc.Types[p.Type.Target].Kind(), "the narrower declaration's type wins") + assert.Empty(t, p.Unmodeled, "nothing is kept") + for _, d := range diags { + assert.NotContains(t, []string{diag.ConflictingRedecl, diag.DegradedConstruct}, d.Code, d.Message) + } + } + assert.Empty(t, cmp.Diff(types[0], types[1], + cmpopts.IgnoreFields(ir.TypeCommon{}, "ID", "Name", "Provenance"), + cmpopts.IgnoreFields(ir.EnumMember{}, "ID")), + "the winner does not depend on branch order") + }) + } +} + +// TestAllOf_AnUnknownFormatWinsInEitherOrder pins GitHub #782: a format that +// hoists a Scalar over the bare kind narrows it whichever branch is first, and +// the format's own details land on the field rather than being parked. +func TestAllOf_AnUnknownFormatWinsInEitherOrder(t *testing.T) { + t.Parallel() + plain := "{type: string}" + formatted := "{type: string, format: hex-token, minLength: 8, default: x, description: the token}" + for i, order := range [][2]string{{plain, formatted}, {formatted, plain}} { + doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 1) + p := m.Properties[0] + + assert.Equal(t, ir.KindScalar, doc.Types[p.Type.Target].Kind(), "the format's scalar types the field (order %d)", i) + require.NotNil(t, p.Default, "order %d", i) + assert.Equal(t, "x", p.Default.Str, "order %d", i) + require.NotNil(t, p.Constraints, "order %d", i) + require.NotNil(t, p.Constraints.MinLength, "order %d", i) + assert.Equal(t, int64(8), *p.Constraints.MinLength, "order %d", i) + sc, ok := doc.Types[p.Type.Target].(*ir.Scalar) + require.True(t, ok, "order %d", i) + assert.Equal(t, "the token", sc.Docs.Description, "the description stays on the scalar that wrote it (order %d)", i) + assert.Empty(t, p.Unmodeled, "nothing is parked (order %d)", i) + assert.Empty(t, diags, "order %d", i) + } +} + +// TestAllOf_TheTopTypeAndANullableKindMeetAsNullableInEitherOrder pins that `{}` +// against `type: [integer, "null"]` is a nullable integer whichever is first. +func TestAllOf_TheTopTypeAndANullableKindMeetAsNullableInEitherOrder(t *testing.T) { + t.Parallel() + top, nullable := "{}", `{type: [integer, "null"]}` + for i, order := range [][2]string{{top, nullable}, {nullable, top}} { + doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 1) + p := m.Properties[0] + + assert.Equal(t, ir.TypeRef{Target: "t/prim/integer", Nullable: true}, p.Type, "order %d", i) + assert.Empty(t, p.Unmodeled, "order %d", i) + assert.Empty(t, diags, "order %d", i) + } +} + +// TestAllOf_ADefaultTheNarrowerNodeRefusesIsKeptInEitherOrder pins that the +// broader declaration's default is judged against a winner that is not a bare +// primitive: one the enum does not admit is not adopted, and the declaration +// that wrote it is kept at its own pointer, whichever branch it is. +func TestAllOf_ADefaultTheNarrowerNodeRefusesIsKeptInEitherOrder(t *testing.T) { + t.Parallel() + enum, broad := "{type: integer, enum: [1, 2]}", "{type: integer, default: 5}" + for i, tc := range []struct { + order [2]string + kept string + }{ + {[2]string{enum, broad}, "/components/schemas/T/allOf/1/properties/v"}, + {[2]string{broad, enum}, "/components/schemas/T/allOf/0/properties/v"}, + } { + doc, diags := schema.LowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 1) + p := m.Properties[0] + + assert.Equal(t, ir.KindEnum, doc.Types[p.Type.Target].Kind(), "the enum wins (order %d)", i) + assert.Nil(t, p.Default, "the refused default is not adopted (order %d)", i) + require.Len(t, p.Unmodeled, 1, "order %d", i) + assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration"+tc.kept, "order %d", i) + require.Len(t, diags, 1, "order %d", i) + assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) + assert.Equal(t, ir.SeverityInfo, diags[0].Severity, "order %d", i) + } +} + +// TestAllOf_AWinnersConstraintsRefuseADefaultInEitherOrder pins that the +// winning node holds the broader declaration's default to its constraints as +// well as its kind: a string past a component's maxLength, or a list past its +// maxItems, is not adopted, and the declaration that wrote it is kept at its +// own pointer, whichever branch it is and wherever the component is declared. +func TestAllOf_AWinnersConstraintsRefuseADefaultInEitherOrder(t *testing.T) { + t.Parallel() + named := "{$ref: '#/components/schemas/C'}" + lowerings := map[string]func(extra, a, b string) (*ir.Document, []ir.Diagnostic){ + "declared before": func(extra, a, b string) (*ir.Document, []ir.Diagnostic) { + return schema.LowerSpec(t, nonBareSpec(extra, a, b)) + }, + "declared after": func(extra, a, b string) (*ir.Document, []ir.Diagnostic) { return settledSpec(t, extra, a, b) }, + } + for _, tc := range []struct{ name, component, broad string }{ + {"a string past maxLength", " C: {type: string, maxLength: 3}\n", "{type: string, default: hello}"}, + {"a list past maxItems", " C: {type: array, items: {type: integer, format: int32}, maxItems: 2}\n", + "{type: array, items: {type: integer}, default: [1, 2, 3, 4]}"}, + } { + for where, lower := range lowerings { + for _, order := range []struct{ a, b, kept string }{ + {named, tc.broad, "/components/schemas/T/allOf/1/properties/v"}, + {tc.broad, named, "/components/schemas/T/allOf/0/properties/v"}, + } { + doc, diags := lower(tc.component, order.a, order.b) + at := tc.name + ", " + where + ", kept " + order.kept + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, at) + require.Len(t, m.Properties, 1, at) + p := m.Properties[0] + + assert.Equal(t, componentID("C"), p.Type.Target, "the component wins: %s", at) + assert.Nil(t, p.Default, "the refused default is not adopted: %s", at) + require.Len(t, p.Unmodeled, 1, at) + assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration"+order.kept, at) + require.Len(t, diags, 1, at) + assert.Equal(t, diag.DegradedConstruct, diags[0].Code, at) + assert.Equal(t, jsontext.Pointer(order.kept), diags[0].Provenance.Pointer, at) + } + } + } +} + +// TestAllOf_TwoEqualListsRefuseANullTheMergeDoesNotAdmit pins that two lists of +// one element, which are two targets, settle on a non-nullable list, so a null +// default the nullable declaration wrote is not adopted and the declaration is +// kept at its own pointer, whichever branch it is. +func TestAllOf_TwoEqualListsRefuseANullTheMergeDoesNotAdmit(t *testing.T) { + t.Parallel() + nullable := `{type: [array, "null"], items: {type: integer}, default: null}` + plain := "{type: array, items: {type: integer}}" + for i, tc := range []struct { + order [2]string + kept string + }{ + {[2]string{nullable, plain}, "/components/schemas/T/allOf/0/properties/v"}, + {[2]string{plain, nullable}, "/components/schemas/T/allOf/1/properties/v"}, + } { + doc, diags := schema.LowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 1) + p := m.Properties[0] + + require.Equal(t, ir.KindList, doc.Types[p.Type.Target].Kind(), "order %d", i) + assert.False(t, p.Type.Nullable, "order %d", i) + assert.Nil(t, p.Default, "the refused null is not adopted (order %d)", i) + require.Len(t, p.Unmodeled, 1, "order %d", i) + assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration"+tc.kept, "order %d", i) + require.Len(t, diags, 1, "order %d", i) + assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) + assert.Equal(t, ir.SeverityInfo, diags[0].Severity, "order %d", i) + assert.Equal(t, jsontext.Pointer(tc.kept), diags[0].Provenance.Pointer, "order %d", i) + } +} + +// TestAllOf_IdenticalNodesAreOneType compiles a field written twice with the +// same enum, const or format, in either order. Two inline copies are one type, +// and a component wins over an inline copy of itself: no diagnostic and nothing +// kept. Two components declared alike are two types the source named apart: an +// info, with the first kept and the second's $ref kept verbatim. +func TestAllOf_IdenticalNodesAreOneType(t *testing.T) { + t.Parallel() + for _, body := range []string{ + "{type: string, enum: [a, b]}", + "{const: a}", + "{type: string, format: hex}", + "{type: string, enum: [a, b], x-foo: 1}", + "{type: array, items: {type: integer, format: int32}}", + "{type: object, additionalProperties: {type: integer}}", + } { + t.Run(body, func(t *testing.T) { + t.Parallel() + extra := " P: " + body + "\n Q: " + body + "\n" + p, q := "{$ref: '#/components/schemas/P'}", "{$ref: '#/components/schemas/Q'}" + for i, tc := range []struct { + order [2]string + win ir.TypeID // the type that stands + same bool + }{ + {[2]string{body, body}, "t/anon/components/schemas/T/allOf/0/properties/v", true}, + {[2]string{p, body}, componentID("P"), true}, + {[2]string{body, p}, componentID("P"), true}, + {[2]string{p, q}, componentID("P"), false}, + {[2]string{q, p}, componentID("Q"), false}, + } { + doc, diags := schema.LowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, "order %d", i) + require.Len(t, m.Properties, 1, "order %d", i) + prop := m.Properties[0] + assert.Equal(t, tc.win, prop.Type.Target, "order %d", i) + if tc.same { + assert.Empty(t, diags, "order %d", i) + assert.Empty(t, prop.Unmodeled, "order %d", i) + } else { + require.Len(t, diags, 1, "order %d", i) + assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) + assert.Len(t, prop.Unmodeled, 1, "the second $ref is kept (order %d)", i) + } + for _, name := range []string{"P", "Q"} { + td, ok := doc.Types[componentID(name)] + require.True(t, ok, "%s (order %d)", name, i) + for key, e := range td.Common().Unmodeled { + assert.True(t, strings.HasPrefix(string(e.Provenance.Pointer), "/components/schemas/"+name+"/"), + "%s keeps where its %s came from, got %q (order %d)", name, key, e.Provenance.Pointer, i) + } + } + } + }) + } +} + +// TestAllOf_ARefToAnInlineNodeIsDeclaredApart compiles a field redeclared as +// a $ref to a node written inline elsewhere (X/properties/c), in either order. +// That node was declared apart from the field, as a component is: it wins over +// an inline copy of itself, and against a $ref to its twin (X/properties/d) the +// pair is an info, with the first kept and the second's $ref kept verbatim. +func TestAllOf_ARefToAnInlineNodeIsDeclaredApart(t *testing.T) { + t.Parallel() + const enum = "{type: string, enum: [a, b]}" + extra := " X: {type: object, properties: {c: " + enum + ", d: " + enum + "}}\n" + c, d := "{$ref: '#/components/schemas/X/properties/c'}", "{$ref: '#/components/schemas/X/properties/d'}" + at := func(p string) ir.TypeID { return ir.TypeID("t/anon/components/schemas/X/properties/" + p) } + for i, tc := range []struct { + order [2]string + win ir.TypeID + same bool + }{ + {[2]string{c, enum}, at("c"), true}, + {[2]string{enum, c}, at("c"), true}, + {[2]string{c, d}, at("c"), false}, + {[2]string{d, c}, at("d"), false}, + } { + doc, diags := schema.LowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, "order %d", i) + require.Len(t, m.Properties, 1, "order %d", i) + prop := m.Properties[0] + assert.Equal(t, tc.win, prop.Type.Target, "order %d", i) + if tc.same { + assert.Empty(t, diags, "order %d", i) + assert.Empty(t, prop.Unmodeled, "order %d", i) + continue + } + require.Len(t, diags, 1, "order %d", i) + assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) + assert.Len(t, prop.Unmodeled, 1, "the second $ref is kept (order %d)", i) + } +} + +// TestAllOf_ACopyANarrowingKeptIsStillACopy compiles a field declared as {}, +// an inline enum and a $ref to a component of the same enum, in every order, +// with the component declared before the composing schema and after it. +// Where {} comes first, the inline enum narrows it and the property holds a node +// written at the second declaration, not the first; it is still an inline copy, +// so the component wins in all six orders with nothing reported or kept. +func TestAllOf_ACopyANarrowingKeptIsStillACopy(t *testing.T) { + t.Parallel() + const enum = "{type: string, enum: [a, b]}" + decls := []string{"{}", enum, "{$ref: '#/components/schemas/Color'}"} + for _, colorLast := range []bool{false, true} { + for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} { + var branches strings.Builder + for _, i := range order { + branches.WriteString(" - type: object\n properties: {v: " + decls[i] + "}\n") + } + color, composed := " Color: "+enum+"\n", " T:\n allOf:\n"+branches.String() + schemas := color + composed + if colorLast { + schemas = composed + color + } + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(schemas)) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, "order %v, Color last %t", order, colorLast) + require.Len(t, m.Properties, 1, "order %v, Color last %t", order, colorLast) + assert.Equal(t, componentID("Color"), m.Properties[0].Type.Target, "order %v, Color last %t", order, colorLast) + assert.Empty(t, diags, "order %v, Color last %t", order, colorLast) + assert.Empty(t, m.Properties[0].Unmodeled, "order %v, Color last %t", order, colorLast) + } + } +} + +// TestAllOf_AListOfAComponentWinsOverAListOfItsCopy compiles a field redeclared +// as a list of a component and as a list of an inline copy of it, in either +// order. The copy is written under the field's own declaration, so the list +// holding the component wins in both orders with nothing reported or kept. +func TestAllOf_AListOfAComponentWinsOverAListOfItsCopy(t *testing.T) { + t.Parallel() + const enum = "{type: string, enum: [a, b]}" + ofRef, ofCopy := "{type: array, items: {$ref: '#/components/schemas/Color'}}", "{type: array, items: "+enum+"}" + for i, order := range [][2]string{{ofRef, ofCopy}, {ofCopy, ofRef}} { + doc, diags := schema.LowerSpec(t, nonBareSpec(" Color: "+enum+"\n", order[0], order[1])) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, "order %d", i) + require.Len(t, m.Properties, 1, "order %d", i) + list, ok := doc.Types[m.Properties[0].Type.Target].(*ir.List) + require.True(t, ok, "the field is a list (order %d)", i) + assert.Equal(t, componentID("Color"), list.Elem.Target, "order %d", i) + assert.Empty(t, diags, "order %d", i) + assert.Empty(t, m.Properties[0].Unmodeled, "order %d", i) + } +} + +// TestAllOf_ARefToASiblingFieldIsDeclaredApart compiles a field v redeclared as +// a $ref to its branch's sibling field vx and as an inline copy of vx, in either +// order. vx's pointer begins with v's but is not under it, so vx was declared +// apart and wins in both orders with nothing reported or kept. +func TestAllOf_ARefToASiblingFieldIsDeclaredApart(t *testing.T) { + t.Parallel() + const enum = "{type: string, enum: [a, b]}" + for i := range 2 { + vx := fmt.Sprintf("/components/schemas/T/allOf/%d/properties/vx", i) + branches := [2]string{ + " - type: object\n properties: {vx: " + enum + ", v: {$ref: '#" + vx + "'}}\n", + " - type: object\n properties: {v: " + enum + "}\n", + } + if i == 1 { + branches[0], branches[1] = branches[1], branches[0] + } + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" T:\n allOf:\n"+branches[0]+branches[1])) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, "order %d", i) + var v *ir.Property + for k := range m.Properties { + if m.Properties[k].WireName == "v" { + v = &m.Properties[k] + } + } + require.NotNil(t, v, "order %d", i) + assert.Equal(t, ir.TypeID("t/anon"+vx), v.Type.Target, "order %d", i) + assert.Empty(t, diags, "order %d", i) + assert.Empty(t, v.Unmodeled, "order %d", i) + } +} + +// TestAllOf_ARedeclarationIsJudgedOnceTheComponentsHaveLowered pins that a +// field redeclared as two $refs to components declared after the composing +// schema is judged as it is when they are declared before it: two components +// declared alike, or a pair the merge does not narrow, are reported and kept. It +// compiles three arms: the components declared before the schema, and after it +// in either order of the two (#898). +func TestAllOf_ARedeclarationIsJudgedOnceTheComponentsHaveLowered(t *testing.T) { + t.Parallel() + p, q := "{$ref: '#/components/schemas/P'}", "{$ref: '#/components/schemas/Q'}" + for _, tc := range []struct { + name, pdecl, qdecl string + items bool + kind ir.TypeKind + code string // the one diagnostic, or "" for none + }{ + {"enums declared alike", "{type: string, enum: [a, b]}", "{type: string, enum: [a, b]}", false, ir.KindEnum, diag.DegradedConstruct}, + {"an integer beside a number", "{type: number}", "{type: integer}", false, ir.KindScalar, diag.DegradedConstruct}, + {"a string against an integer", "{type: string}", "{type: integer}", false, ir.KindScalar, diag.ConflictingRedecl}, + {"enums declared alike as list items", "{type: string, enum: [a, b]}", "{type: string, enum: [a, b]}", true, ir.KindList, diag.DegradedConstruct}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + a, b := p, q + if tc.items { + a, b = "{type: array, items: "+p+"}", "{type: array, items: "+q+"}" + } + pq := " P: " + tc.pdecl + "\n Q: " + tc.qdecl + "\n" + qp := " Q: " + tc.qdecl + "\n P: " + tc.pdecl + "\n" + for i, lower := range []func() (*ir.Document, []ir.Diagnostic){ + func() (*ir.Document, []ir.Diagnostic) { return schema.LowerSpec(t, nonBareSpec(pq, a, b)) }, + func() (*ir.Document, []ir.Diagnostic) { return settledSpec(t, pq, a, b) }, + func() (*ir.Document, []ir.Diagnostic) { return settledSpec(t, qp, a, b) }, + } { + doc, diags := lower() + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok, "order %d", i) + require.Len(t, m.Properties, 1, "order %d", i) + prop := m.Properties[0] + + assert.Equal(t, tc.kind, doc.Types[prop.Type.Target].Kind(), "order %d", i) + if tc.code == "" { + assert.Empty(t, diags, "order %d", i) + assert.Empty(t, prop.Unmodeled, "order %d", i) + continue + } + require.Len(t, diags, 1, "order %d", i) + assert.Equal(t, tc.code, diags[0].Code, "order %d", i) + assert.Len(t, prop.Unmodeled, 1, "the losing declaration is kept (order %d)", i) + } + }) + } +} + +// TestAllOf_WhatAFieldKeepsOfItsDetailIsOrderFree compiles a field whose two +// declarations give an extension differently, alike in another, one promoting +// a deprecation from an extension, and one default spelled two ways. In either +// branch order the field keeps the first value of the extension and reports +// the other, names the heuristic behind its deprecation, and takes the default +// as the one value it is. +func TestAllOf_WhatAFieldKeepsOfItsDetailIsOrderFree(t *testing.T) { + t.Parallel() + a := "{type: number, default: 1, x-foo: 1, x-same: s}" + b := "{type: number, default: 1.0, x-foo: 2, x-same: s, deprecated: true, x-deprecated-reason: use q}" + for _, order := range [][2]string{{a, b}, {b, a}} { + doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) + m, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 1) + p := m.Properties[0] + first := "1" + if order[0] == b { + first = "2" + } + + require.Contains(t, p.Unmodeled, "openapi:x-foo", "order %v", order) + assert.Equal(t, ir.RawValue(first), p.Unmodeled["openapi:x-foo"].Value, "the first value stands") + assert.Contains(t, p.Unmodeled, "openapi:x-same") + assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration/components/schemas/T/allOf/1/properties/v", + "the other declaration is kept verbatim") + require.NotNil(t, p.Deprecation) + assert.Equal(t, "use q", p.Deprecation.Message) + assert.Equal(t, "extension-promotion", p.Provenance.Inferred, "the promoted deprecation stays marked") + require.NotNil(t, p.Default) + require.Len(t, diags, 1, "only the extension given differently is reported: %+v", diags) + assert.Contains(t, diags[0].Message, "openapi:x-foo differently") + } +} + +// TestAllOf_ContainerMembersAreJudgedAsFieldsAre pins that two lists' members +// get the verdict two fields of those types get. Enums sharing no value, one +// list never empty, are a conflict; the same lists both admitting the empty one +// overlap in it. Members judged only as a fallback for top-level fields were +// once left unrelated, which the merge reported as a pair it does not compare. +func TestAllOf_ContainerMembersAreJudgedAsFieldsAre(t *testing.T) { + t.Parallel() + str, num := "{type: array, items: {type: string, enum: [a]}%s}", "{type: array, items: {type: integer, enum: [1]}}" + for _, tc := range []struct { + name, min string + code string + sev ir.Severity + }{ + {"one list never empty", ", minItems: 1", diag.ConflictingRedecl, ir.SeverityWarning}, + {"both lists admitting the empty one", "", diag.DegradedConstruct, ir.SeverityInfo}, + } { + for _, order := range [][2]string{{fmt.Sprintf(str, tc.min), num}, {num, fmt.Sprintf(str, tc.min)}} { + _, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) + require.Len(t, diags, 1, "%s, order %v: %+v", tc.name, order, diags) + assert.Equal(t, tc.code, diags[0].Code, "%s, order %v", tc.name, order) + assert.Equal(t, tc.sev, diags[0].Severity, "%s, order %v", tc.name, order) + if tc.code == diag.DegradedConstruct { + assert.Contains(t, diags[0].Message, "whose values overlap", "%s, order %v", tc.name, order) + } + } + } +} + +// TestAllOf_PrimitiveNarrowingIsLosslessInEitherOrder pins that a redeclaration +// narrowing a bare primitive (GitHub #598, #446) loses nothing: the narrower +// target wins in both branch orders, with no diagnostic and no kept entry, and +// the two orders merge to the same type. +func TestAllOf_PrimitiveNarrowingIsLosslessInEitherOrder(t *testing.T) { + t.Parallel() + cases := []struct { + name, broad, narrow string + want ir.TypeID + }{ + {"number and integer", "{type: number}", "{type: integer}", "t/prim/integer"}, + {"integer and int32", "{type: integer}", "{type: integer, format: int32}", "t/prim/int32"}, + {"string and date-time", "{type: string}", "{type: string, format: date-time}", "t/prim/datetime_offset"}, + {"int32 and int64", "{type: integer, format: int64}", "{type: integer, format: int32}", "t/prim/int32"}, + {"uri and string", "{type: string}", "{type: string, format: uri}", "t/prim/url"}, + {"double and number", "{type: number}", "{type: number, format: double}", "t/prim/float64"}, + {"uuid and string", "{type: string}", "{type: string, format: uuid}", "t/prim/uuid"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var merged [2]ir.TypeRef + for i, spec := range []string{allOfConflictSpec(tc.broad, tc.narrow), allOfConflictSpec(tc.narrow, tc.broad)} { + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, diags, "a narrowing draws no diagnostic (order %d)", i) + model, ok := doc.Types[componentID("T")].(*ir.Model) + require.True(t, ok) + require.Len(t, model.Properties, 1) + prop := model.Properties[0] + assert.Equal(t, tc.want, prop.Type.Target, "the narrower target wins (order %d)", i) + for key := range prop.Unmodeled { + assert.NotContains(t, key, "openapi:conflicting-redeclaration", "nothing is kept (order %d)", i) + } + merged[i] = prop.Type + } + assert.Empty(t, cmp.Diff(merged[0], merged[1]), "the merged type does not depend on branch order") + }) + } +} + +// nonBareSpec declares T with one property v written as schemaA in the first +// branch and schemaB in the second, after the components in extra. +func nonBareSpec(extra, schemaA, schemaB string) string { + return openapitest.ComponentSpec(extra + + " T:\n" + + " allOf:\n" + + " - type: object\n" + + " properties: {v: " + schemaA + "}\n" + + " - type: object\n" + + " properties: {v: " + schemaB + "}\n") +} diff --git a/compilers/openapi/internal/schema/allof_nesting_test.go b/compilers/openapi/internal/schema/allof_nesting_test.go new file mode 100644 index 00000000..71b36bfe --- /dev/null +++ b/compilers/openapi/internal/schema/allof_nesting_test.go @@ -0,0 +1,742 @@ +package schema_test + +import ( + "encoding/json/jsontext" + "maps" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" +) + +// TestAllOf_NestedLowersAsFlat holds the claim of the flattening itself: a +// nested spelling of a composition lowers as its flat one does. It compares the +// composed models with pointers, provenance and hoisted IDs projected away — +// base, mixins, properties in order with their type and required flag, the +// required names held for no property, openness, discriminator value, variants — +// and the codes reported. The per-case rows of +// TestAllOf_NestedInlineWrapperFlattens each pin one consequence; this holds the +// property for every reader of the list. +func TestAllOf_NestedLowersAsFlat(t *testing.T) { + t.Parallel() + for _, tc := range equivalencePairs { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nested, nestedCodes := compileView(t, tc.nested) + flat, flatCodes := compileView(t, tc.flat) + assert.NotEqual(t, composedView{}, flat, "the flat spelling lowers to something to compare") + assert.Empty(t, cmp.Diff(flat, nested), "the nested spelling composes as the flat one does") + assert.Equal(t, flatCodes, nestedCodes, "and reports the same codes") + }) + } +} + +// TestAllOf_NestedLowersAsFlat_ReachesWhatItCompares pins that the projection +// sees the distinctions the pairs rely on, so a pair that agrees does so for a +// reason: a distributed union has variants, the discriminated subtype has its +// tag, a false branch closes the model, and a required name the base declares +// is held in Requires. The residue and the false branch are also kept, which +// only a positive Kept shows. +func TestAllOf_NestedLowersAsFlat_ReachesWhatItCompares(t *testing.T) { + t.Parallel() + byName := map[string]string{} + for _, tc := range equivalencePairs { + byName[tc.name] = tc.flat + } + distributed, _ := compileView(t, byName["a distributed oneOf beside the wrappers"]) + require.Len(t, distributed.Variants, 2, "the oneOf distributes") + assert.Equal(t, []string{"w"}, wires(distributed.Variants[0].Props), "each variant carries the body's own property") + assert.Equal(t, string(componentID("C")), distributed.Variants[0].Base, "and the ref the wrapper held as its base") + + closed, _ := compileView(t, byName["a false branch"]) + assert.Equal(t, ir.AdditionalClosed, closed.Additional) + assert.Positive(t, closed.Kept, "the false branch is kept beside closing the model") + + required, _ := compileView(t, byName["a required name only the base declares"]) + assert.Equal(t, []string{"a"}, required.Requires, "the name is required of the composition, not of a property it holds") + + residue, _ := compileView(t, byName["a residue description"]) + assert.Positive(t, residue.Kept, "the residue is kept") + + subtype, _ := compileView(t, byName["a discriminated base inside a wrapper"]) + assert.Equal(t, "dog", subtype.DiscValue) + assert.Equal(t, componentID("Pet"), ir.TypeID(subtype.Base), "the discriminated ref is the base") +} + +// TestAllOf_NestedInlineWrapperFlattens pins the flattening of +// docs/compilers/openapi.md#allof-classification: a nested spelling classifies +// like the flat one, with every property keeping the pointer it was written at +// (GitHub #599). +func TestAllOf_NestedInlineWrapperFlattens(t *testing.T) { + t.Parallel() + tests := []struct { + name string + m string + base ir.TypeID + mixins []ir.TypeID + props map[string]string // wire name -> provenance pointer + }{ + { + name: "the issue: a wrapper around two refs", + m: ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/B'} +`, + mixins: []ir.TypeID{componentID("A"), componentID("B")}, + }, + { + name: "two levels of wrapper", + m: ` M: + allOf: + - allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/B'} +`, + mixins: []ir.TypeID{componentID("A"), componentID("B")}, + }, + { + name: "a ref with an inline branch beside it carries the nested pointer", + m: ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - type: object + properties: {c: {type: string}} +`, + base: componentID("A"), + props: map[string]string{"c": "/components/schemas/M/allOf/0/allOf/1/properties/c"}, + }, + { + name: "a wrapper with its own properties and type object", + m: ` M: + allOf: + - type: object + properties: {w: {type: string}} + allOf: + - {$ref: '#/components/schemas/A'} +`, + base: componentID("A"), + props: map[string]string{"w": "/components/schemas/M/allOf/0/properties/w"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, doc := range nestedOrders(t, tc.m, nestedAB) { + m, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + if tc.base == "" { + assert.Nil(t, m.Base) + } else if assert.NotNil(t, m.Base) { + assert.Equal(t, tc.base, m.Base.Target) + } + assert.Equal(t, append([]ir.TypeID{}, tc.mixins...), refTargets(m.Mixins)) + assert.Empty(t, m.Unmodeled, "a flattened wrapper is not kept verbatim") + assert.False(t, slices.ContainsFunc(doc.Diagnostics, func(d ir.Diagnostic) bool { + return strings.HasPrefix(string(d.Provenance.Pointer), "/components/schemas/M") + }), "flattening is exact, so nothing is reported under M: %+v", doc.Diagnostics) + got := map[string]string{} + for _, p := range m.Properties { + got[p.WireName] = string(p.Provenance.Pointer) + } + want := tc.props + if want == nil { + want = map[string]string{} + } + assert.Equal(t, want, got) + } + }) + } +} + +// TestAllOf_NestedWrapperRequiredAttaches pins that a required list inside a +// wrapper is read like one at the top: it attaches to a property a flattened +// branch declares, and a name none declares is listed in Model.Requires. +func TestAllOf_NestedWrapperRequiredAttaches(t *testing.T) { + t.Parallel() + t.Run("wrapper required and nested-nested required attach", func(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - required: [x] + allOf: + - type: object + properties: {x: {type: integer}, y: {type: integer}} + - allOf: + - required: [y] +` + for _, doc := range nestedOrders(t, m, nestedAB) { + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + props := openapitest.PropsByWire(model.Properties) + assert.True(t, props["x"].Required, "the wrapper's required reaches the property it names") + assert.True(t, props["y"].Required, "so does a required two wrappers deep") + assert.Empty(t, model.Requires, "every name found its property") + } + }) + t.Run("a name no own property declares is listed in Requires", func(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - required: [ghost] + allOf: + - {$ref: '#/components/schemas/A'} +` + for _, doc := range nestedOrders(t, m, nestedAB) { + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + assert.Equal(t, []string{"ghost"}, model.Requires, "the wrapper's required list is read") + } + }) +} + +// TestAllOf_NestedWrapperRequiredAttachesBesideUnion pins that a required two +// wrappers deep reaches its property in every distributed variant. +func TestAllOf_NestedWrapperRequiredAttachesBesideUnion(t *testing.T) { + t.Parallel() + u := ` U: + oneOf: [{$ref: '#/components/schemas/P'}, {$ref: '#/components/schemas/Q'}] + allOf: + - allOf: + - type: object + properties: {x: {type: string}, y: {type: string}} + - allOf: + - required: [y] +` + const pq = ` P: {type: object, properties: {p: {type: string}}} + Q: {type: object, properties: {q: {type: string}}} +` + for _, doc := range nestedOrders(t, u, nestedAB+pq) { + for _, id := range []ir.TypeID{"t/composed/components/schemas/U/oneOf/0", "t/composed/components/schemas/U/oneOf/1"} { + model, ok := doc.Types[id].(*ir.Model) + require.True(t, ok, "%s should be a model", id) + assert.True(t, openapitest.PropsByWire(model.Properties)["y"].Required, id) + } + } +} + +// TestAllOf_NestedWrapperBaseSelection pins that the classification of +// docs/compilers/openapi.md#allof-classification runs over the flattened list: +// a discriminated ref inside a wrapper is the Base, whichever nested order it +// is written in, and still answers from its mapping. +func TestAllOf_NestedWrapperBaseSelection(t *testing.T) { + t.Parallel() + const pet = ` Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: {dog: '#/components/schemas/Dog'} + Named: {type: object, properties: {name: {type: string}}} +` + tests := []struct { + name string + dog string + }{ + {"pet first inside the wrapper", ` Dog: + allOf: + - allOf: + - {$ref: '#/components/schemas/Pet'} + - {$ref: '#/components/schemas/Named'} +`}, + {"pet last inside the wrapper", ` Dog: + allOf: + - allOf: + - {$ref: '#/components/schemas/Named'} + - {$ref: '#/components/schemas/Pet'} +`}, + {"pet inside a wrapper beside a top-level ref", ` Dog: + allOf: + - {$ref: '#/components/schemas/Named'} + - allOf: + - {$ref: '#/components/schemas/Pet'} +`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, doc := range nestedOrders(t, tc.dog, pet) { + dog, ok := typeByName(doc, "Dog").(*ir.Model) + require.True(t, ok, "Dog should be a model") + require.NotNil(t, dog.Base) + assert.Equal(t, componentID("Pet"), dog.Base.Target) + assert.Equal(t, []ir.TypeID{componentID("Named")}, refTargets(dog.Mixins)) + assert.Equal(t, "dog", dog.DiscriminatorValue, "the mapping key, not the schema name") + } + }) + } +} + +// TestAllOf_NestedFalseBranchClosesModel pins that a `false` inside a wrapper +// still closes the composition, and is kept at the pointer it was written at. +func TestAllOf_NestedFalseBranchClosesModel(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - allOf: [false] +` + for _, doc := range nestedOrders(t, m, nestedAB) { + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + assert.Equal(t, ir.AdditionalClosed, model.Additional) + entry, ok := model.Unmodeled["openapi:allOf/0/allOf/0"] + require.True(t, ok, "the false is kept under its nested key; got %+v", model.Unmodeled) + assert.Equal(t, "false", string(entry.Value)) + assert.Equal(t, jsontext.Pointer("/components/schemas/M/allOf/0/allOf/0"), entry.Provenance.Pointer) + assert.NotContains(t, model.Unmodeled, "openapi:allOf/0", "the wrapper itself is not kept") + assert.True(t, openapitest.HasDiagCodeAt(doc.Diagnostics, diag.FalseSchema, + "/components/schemas/M/allOf/0/allOf/0")) + } +} + +// TestAllOf_NestedWrapperWithResidueKeptVerbatim pins that a wrapper writing +// a keyword the merge does not consume is still flattened: its entries and +// properties compose as the flat spelling's do, the whole wrapper is kept under +// its key, and the diagnostic names the extra keyword and not allOf. +func TestAllOf_NestedWrapperWithResidueKeptVerbatim(t *testing.T) { + t.Parallel() + tests := []struct { + name string + sibling string + residue string + }{ + {"description", "description: Doc", "description"}, + {"title", "title: Grouped", "title"}, + {"vendor extension", "x-vendor: keepme", "x-vendor"}, + {"readOnly", "readOnly: true", "readOnly"}, + {"discriminator", "discriminator: {propertyName: kind}", "discriminator"}, + {"oneOf", "oneOf: [{type: string}]", "oneOf"}, + {"additionalProperties false", "additionalProperties: false", "additionalProperties"}, + {"type set with null", `type: [object, "null"]`, "type"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + ` + tc.sibling + ` + properties: {w: {type: string}} +` + for _, doc := range nestedOrders(t, m, nestedAB) { + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + require.NotNil(t, model.Base, "the wrapper's ref is classified as in the flat spelling") + assert.Equal(t, componentID("A"), model.Base.Target) + assert.Empty(t, model.Mixins) + _, hasW := openapitest.PropsByWire(model.Properties)["w"] + assert.True(t, hasW, "the wrapper's properties still merge") + entry, ok := model.Unmodeled["openapi:allOf/0"] + require.True(t, ok, "the wrapper is kept verbatim; got %+v", model.Unmodeled) + assert.Equal(t, jsontext.Pointer("/components/schemas/M/allOf/0"), entry.Provenance.Pointer) + assert.Contains(t, string(entry.Value), `"allOf"`, "the whole wrapper is kept") + var infos int + for _, d := range doc.Diagnostics { + if d.Code == diag.DegradedConstruct && d.Provenance.Pointer == "/components/schemas/M/allOf/0" { + infos++ + } + } + assert.Equal(t, 1, infos, "exactly one degraded-construct at the wrapper") + msg := openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, + ir.SeverityInfo, "/components/schemas/M/allOf/0") + assert.Contains(t, msg, "("+tc.residue+")") + assert.Contains(t, msg, "nested allOf compose") + assert.NotContains(t, msg, "(allOf") + } + }) + } +} + +// TestAllOf_KeptEntryOfAKeptWrapperIsKeptTwice pins the trade-off +// docs/compilers/openapi.md#allof-inline-residue states: a wrapper kept for +// its residue is kept whole, its entries included, so an entry kept itself, or +// a false one, is in Unmodeled twice, inside the wrapper's copy and under its +// own path. +func TestAllOf_KeptEntryOfAKeptWrapperIsKeptTwice(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - description: grouped + allOf: + - {$ref: '#/components/schemas/A'} + - {title: inner, properties: {p: {type: string}}} + - false +` + for _, doc := range nestedOrders(t, m, nestedAB) { + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + assert.Equal(t, []string{"openapi:allOf/0", "openapi:allOf/0/allOf/1", "openapi:allOf/0/allOf/2"}, + slices.Sorted(maps.Keys(model.Unmodeled)), "the wrapper and each entry kept itself, each under its own path") + wrapper := string(model.Unmodeled["openapi:allOf/0"].Value) + assert.Contains(t, wrapper, `"title":"inner"`, "the wrapper's copy holds the kept entry") + assert.Contains(t, wrapper, `,false]`, "and the false one") + assert.Contains(t, string(model.Unmodeled["openapi:allOf/0/allOf/1"].Value), `"title":"inner"`) + assert.Equal(t, "false", string(model.Unmodeled["openapi:allOf/0/allOf/2"].Value)) + } +} + +// TestAllOf_NestedEmptyAllOfKeptVerbatim pins that a branch wrapping an empty +// allOf is not flattened: it is kept verbatim and reported, never dropped. The +// source breaks the schema's minItems, so the one error the load reports for it +// is expected and nothing else. +func TestAllOf_NestedEmptyAllOfKeptVerbatim(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - allOf: [] + - {$ref: '#/components/schemas/A'} +` + docs := make([]*ir.Document, 0, 2) + for _, src := range []string{m + nestedAB, nestedAB + m} { + doc, diags := parseFull(t, openapitest.ComponentSpec(src)) + docs = append(docs, doc) + errs := slices.DeleteFunc(slices.Clone(diags), func(d ir.Diagnostic) bool { + return d.Severity != ir.SeverityError + }) + require.Len(t, errs, 1, "only the minItems finding is an error") + assert.Equal(t, jsontext.Pointer("/components/schemas/M/allOf/0/allOf"), errs[0].Provenance.Pointer) + + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + require.NotNil(t, model.Base, "the sibling $ref is the sole classified ref") + assert.Equal(t, componentID("A"), model.Base.Target) + assert.Empty(t, model.Mixins) + entry, ok := model.Unmodeled["openapi:allOf/0"] + require.True(t, ok, "the empty wrapper is kept verbatim; got %+v", model.Unmodeled) + assert.JSONEq(t, `{"allOf":[]}`, string(entry.Value)) + assert.Contains(t, + openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, + ir.SeverityInfo, "/components/schemas/M/allOf/0"), + "only its properties and required list compose; the branch (allOf) is kept verbatim") + } + assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...), + "declaration order must not change the types") +} + +// TestAllOf_NestedInlineBranchKeyedByPointer pins that an inline branch inside +// a wrapper is kept under a key spelling its path, so it can collide with +// neither a sibling nor a top-level branch of the same index. +func TestAllOf_NestedInlineBranchKeyedByPointer(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - type: object + properties: {a: {type: string}} + description: Top + - allOf: + - type: object + properties: {b: {type: string}} + description: First + - type: object + properties: {c: {type: string}} + minProperties: 1 +` + for _, doc := range nestedOrders(t, m, nestedAB) { + model, ok := typeByName(doc, "M").(*ir.Model) + require.True(t, ok, "M should be a model") + require.Len(t, model.Unmodeled, 3, "got %+v", model.Unmodeled) + for key, want := range map[string]string{ + "openapi:allOf/0": `"description":"Top"`, + "openapi:allOf/1/allOf/0": `"description":"First"`, + "openapi:allOf/1/allOf/1": `"minProperties":1`, + } { + entry, ok := model.Unmodeled[key] + require.True(t, ok, "missing %s in %+v", key, model.Unmodeled) + assert.Contains(t, string(entry.Value), want) + assert.Equal(t, "/components/schemas/M/"+strings.TrimPrefix(key, "openapi:"), + string(entry.Provenance.Pointer)) + assert.Contains(t, + openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, + "/components/schemas/M/"+strings.TrimPrefix(key, "openapi:")), "inline allOf branch") + } + } +} + +// TestAllOf_NestedBranchIsNamedByItsInnermostIndex pins that a $ref branch +// inside a wrapper is named by its innermost index, in either declaration +// order: an outside $ref to that pointer lowers it from the other side. The +// target is the empty-named component because a branch holding a $ref takes +// its target's name first, so only a target that names nothing lets the index +// reach the IR. The B before the wrapper puts the branch at flat position 2 but +// innermost index 1, so only the innermost index yields variant_1. +func TestAllOf_NestedBranchIsNamedByItsInnermostIndex(t *testing.T) { + t.Parallel() + m := ` M: + allOf: + - {$ref: '#/components/schemas/B'} + - allOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/', description: Narrowed} +` + outsider := ` Outsider: {$ref: '#/components/schemas/M/allOf/1/allOf/1'} +` + unnamed := " \"\": {type: object, properties: {e: {type: string}}}\n" + for _, doc := range nestedOrders(t, m, outsider+unnamed+nestedAB) { + narrowed, ok := doc.Types["t/anon/components/schemas/M/allOf/1/allOf/1"] + require.True(t, ok, "the narrowed branch is hoisted at its pointer") + assert.Equal(t, "variant_1", narrowed.Common().Name.Hint) + } +} + +// compileView compiles the defs with the component m and returns M's view and +// the codes reported, requiring no error. +func compileView(t *testing.T, m string) (composedView, []string) { + t.Helper() + doc, diags := parseFull(t, openapitest.ComponentSpec(equivalenceDefs+m)) + openapitest.RequireNoErrorDiags(t, diags) + return viewOf(doc, componentID("M"), 1), diagCodes(diags) +} + +// equivalencePairs are (nested, flat) spellings of one composition M. Each +// nested spelling must lower as its flat one does +// (docs/compilers/openapi.md#allof-classification), at any depth up to +// MaxAllOfFlattenDepth. +var equivalencePairs = []struct { + name string + nested string + flat string +}{ + {"one wrapper around two refs", ` M: + allOf: + - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`}, + {"two wrappers", ` M: + allOf: + - allOf: + - allOf: [{$ref: '#/components/schemas/A'}] + - {$ref: '#/components/schemas/B'} +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`}, + {"three wrappers", ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - allOf: + - allOf: [{$ref: '#/components/schemas/B'}, {$ref: '#/components/schemas/C'}] +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}, {$ref: '#/components/schemas/C'}] +`}, + {"four wrappers", ` M: + allOf: + - allOf: + - allOf: + - allOf: + - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`}, + {"a discriminated base inside a wrapper", pet + ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/Pet'} +`, pet + ` M: + allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/Pet'}] +`}, + {"inline branches with properties, required and type object", ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - type: object + required: [x] + properties: {x: {type: integer}, y: {type: string}} + - allOf: + - properties: {z: {type: boolean}} +`, ` M: + allOf: + - {$ref: '#/components/schemas/A'} + - type: object + required: [x] + properties: {x: {type: integer}, y: {type: string}} + - properties: {z: {type: boolean}} +`}, + {"a wrapper's own properties merge after its entries", ` M: + allOf: + - required: [w] + properties: {w: {type: string}} + allOf: + - type: object + properties: {x: {type: integer}} + - properties: {y: {type: integer}} +`, ` M: + allOf: + - type: object + properties: {x: {type: integer}} + - properties: {y: {type: integer}} + - required: [w] + properties: {w: {type: string}} +`}, + {"a required name only the base declares", ` M: + allOf: + - allOf: + - {$ref: '#/components/schemas/A'} + - {required: [a]} +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, {required: [a]}] +`}, + {"a true branch", ` M: + allOf: + - allOf: [{$ref: '#/components/schemas/A'}, true] +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, true] +`}, + {"a false branch", ` M: + allOf: + - allOf: [{$ref: '#/components/schemas/A'}, false] +`, ` M: + allOf: [{$ref: '#/components/schemas/A'}, false] +`}, + {"a residue description", ` M: + allOf: + - description: grouped + allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`, ` M: + allOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/B'} + - description: grouped +`}, + {"a distributed oneOf beside the wrappers", ` M: + type: object + allOf: + - allOf: + - {$ref: '#/components/schemas/C'} + - properties: {w: {type: string}} + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`, ` M: + type: object + allOf: + - {$ref: '#/components/schemas/C'} + - properties: {w: {type: string}} + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`}, +} + +// wires lists the wire names of props. +func wires(props []propView) []string { + out := make([]string, 0, len(props)) + for _, p := range props { + out = append(out, p.Wire) + } + return out +} + +// refTargets lists the TypeIDs refs target, in order. +func refTargets(refs []ir.TypeRef) []ir.TypeID { + out := make([]ir.TypeID, 0, len(refs)) + for _, r := range refs { + out = append(out, r.Target) + } + return out +} + +// equivalenceDefs are the components every pair composes: two plain schemas, a +// third to compose beside them. +const equivalenceDefs = ` A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + C: {type: object, properties: {c: {type: integer}}} +` + +// diagCodes lists the codes doc's compilation reported, in order. +func diagCodes(diags []ir.Diagnostic) []string { + out := make([]string, 0, len(diags)) + for _, d := range diags { + out = append(out, d.Code) + } + return out +} + +// pet is a discriminated base routing to M. +const pet = ` Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: {dog: '#/components/schemas/M'} +` + +// composedView is a composed model with what the nested and flat spellings may +// differ in projected away: pointers, provenance and the IDs of hoisted nodes. +// Kept counts the model's Unmodeled entries: their keys and values differ by +// spelling, but how many the composition kept must not. +type composedView struct { + Base string + Mixins []string + Props []propView + Requires []string + Additional ir.AdditionalMode + DiscValue string + Variants []composedView + Kept int +} + +// propView is one property of a composedView. +type propView struct { + Wire string + Type string + Required bool +} + +// projectTarget names a type reference without the ID of a node hoisted at a +// pointer, which differs by spelling: a component is kept by ID, anything else by +// its kind. +func projectTarget(doc *ir.Document, id ir.TypeID) string { + if strings.HasPrefix(string(id), "t/anon/") { + return "anon:" + string(doc.Types[id].Kind()) + } + return string(id) +} + +// viewOf projects the type at id: a model as itself, a union as its variants, +// each projected in turn. depth bounds the descent, which a union's variants +// reach once. +func viewOf(doc *ir.Document, id ir.TypeID, depth int) composedView { + var v composedView + switch td := doc.Types[id].(type) { + case *ir.Model: + v = modelView(doc, td) + case *ir.Union: + if depth > 0 { + for _, variant := range td.Variants { + v.Variants = append(v.Variants, viewOf(doc, variant.Type.Target, depth-1)) + } + } + default: + v.Base = "not a model or union: " + string(id) + } + return v +} + +// modelView projects a model's composition, properties, the required names it +// holds no property for, and its openness. +func modelView(doc *ir.Document, m *ir.Model) composedView { + v := composedView{ + Requires: m.Requires, Additional: m.Additional, DiscValue: m.DiscriminatorValue, Kept: len(m.Unmodeled), + } + if m.Base != nil { + v.Base = string(m.Base.Target) + } + for _, mixin := range m.Mixins { + v.Mixins = append(v.Mixins, string(mixin.Target)) + } + for _, p := range m.Properties { + v.Props = append(v.Props, propView{Wire: p.WireName, Type: projectTarget(doc, p.Type.Target), Required: p.Required}) + } + return v +} diff --git a/compilers/openapi/internal/schema/allof_required_test.go b/compilers/openapi/internal/schema/allof_required_test.go new file mode 100644 index 00000000..36c3f840 --- /dev/null +++ b/compilers/openapi/internal/schema/allof_required_test.go @@ -0,0 +1,295 @@ +package schema_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +// TestAllOf_CompositionRequiredAttaches covers issue #29: allOf is an +// intersection, so a `required` list constrains the whole composed object +// regardless of which branch declares the named property, or whether it is +// paired with a properties map at all. Each case names a wire property that +// must end up Required=true, which leaves Requires empty, since every name +// matches an own property. +func TestAllOf_CompositionRequiredAttaches(t *testing.T) { + t.Parallel() + tests := []struct { + name string // subtest name, describing the case + spec string + model string // component under test + wire string // property expected to end up Required=true + }{ + { + name: "required-only branch after the branch declaring the property (issue #29 repro)", + spec: openapitest.ComponentSpec(` Thing: + allOf: + - type: object + properties: + id: {type: integer} + - required: [id] +`), + model: "Thing", + wire: "id", + }, + { + name: "required-only branch before the branch declaring the property", + spec: openapitest.ComponentSpec(` ThingB: + allOf: + - required: [id] + - type: object + properties: + id: {type: integer} +`), + model: "ThingB", + wire: "id", + }, + { + name: "required-only branch, property declared alongside allOf", + spec: openapitest.ComponentSpec(` Foo: + type: object + properties: + a: {type: string} + Bar: + type: object + properties: + type: {type: string} + name: {type: string} + allOf: + - {$ref: "#/components/schemas/Foo"} + - required: [type] +`), + model: "Bar", + wire: "type", + }, + { + name: "branch declares properties: {} (empty, non-nil) plus required", + spec: openapitest.ComponentSpec(` ThingF: + allOf: + - type: object + properties: + id: {type: integer} + - type: object + properties: {} + required: [id] +`), + model: "ThingF", + wire: "id", + }, + { + name: "the schema's own required (sibling of allOf, no sibling properties) names a property declared inside a branch", + spec: openapitest.ComponentSpec(` ThingG: + required: [id] + allOf: + - type: object + properties: + id: {type: integer} +`), + model: "ThingG", + wire: "id", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, tc.spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID(tc.model)].(*ir.Model) + require.True(t, ok, "%s should be a model", tc.model) + p, ok := openapitest.PropsByWire(m.Properties)[tc.wire] + require.True(t, ok, "%s should have a %q property", tc.model, tc.wire) + assert.True(t, p.Required, "%s.%s should be required via composition-scope required", tc.model, tc.wire) + assert.Empty(t, m.Requires, "every required name matches an own property, so none is listed beside them") + }) + } +} + +// TestAllOf_RequiredOnlyBranchNamingBaseOwnedPropertyLandsInRequires pins an +// allOf composed of a sole $ref (its Base) and a required-only branch naming a +// property only the base declares. ir-design §4.3 stores a model's own +// properties, never a flattened view, so the name lands in Model.Requires: the +// composed model makes the inherited property required, and nothing is +// misattached to an unrelated property or reported as lost. +func TestAllOf_RequiredOnlyBranchNamingBaseOwnedPropertyLandsInRequires(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Human: + type: object + properties: + name: {type: string} + level: {type: integer} + SuperBaby: + allOf: + - {$ref: "#/components/schemas/Human"} + - required: [level] + properties: + gender: {type: string} +`) + doc, diags := schema.LowerSpec(t, spec) + m, ok := doc.Types[componentID("SuperBaby")].(*ir.Model) + require.True(t, ok, "SuperBaby should be a model") + require.NotNil(t, m.Base, "the sole $ref still becomes Base") + assert.Equal(t, componentID("Human"), m.Base.Target) + + _, hasLevel := openapitest.PropsByWire(m.Properties)["level"] + assert.False(t, hasLevel, "level belongs to the base, not to SuperBaby's own properties") + gender, hasGender := openapitest.PropsByWire(m.Properties)["gender"] + require.True(t, hasGender, "gender is SuperBaby's own property") + assert.False(t, gender.Required, "level's requiredness never misattaches to gender") + assert.Equal(t, []string{"level"}, m.Requires, "SuperBaby makes the level Human declares required") + assert.Empty(t, diags, "the requirement is carried, so nothing is reported") +} + +// TestAllOf_RequiredNameNothingDeclaresLandsInRequires covers an allOf with no +// $ref branch at all, so nothing becomes Base or a Mixin and the required name +// is one no schema declares: legal JSON Schema, which requires the member +// whatever its value. It lands in Model.Requires as the inherited one does, +// with no property invented for it. +func TestAllOf_RequiredNameNothingDeclaresLandsInRequires(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Thing: + allOf: + - type: object + properties: + id: {type: integer} + - required: [ghost] +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Thing")].(*ir.Model) + require.True(t, ok, "Thing should be a model") + assert.Nil(t, m.Base, "no $ref branch at all: no Base") + assert.Empty(t, m.Mixins, "no $ref branch at all: no Mixins") + + id, hasID := openapitest.PropsByWire(m.Properties)["id"] + require.True(t, hasID, "the branch's own id property still lowers") + assert.False(t, id.Required, "ghost's requiredness never misattaches to id") + _, hasGhost := openapitest.PropsByWire(m.Properties)["ghost"] + assert.False(t, hasGhost, "ghost is never invented as a property") + assert.Equal(t, []string{"ghost"}, m.Requires) + assert.Empty(t, diags, "the requirement is carried, so nothing is reported") +} + +// TestAllOf_RefBranchWithSiblingRequired31 pins the real behavior of a $ref +// allOf branch that also carries a sibling `required` (legal in OpenAPI 3.1 / +// JSON Schema 2020-12, where $ref no longer has to be the schema object's only +// keyword). compositionRequired reads a branch's required list off its own +// local schema node (GetSchema()) — never the resolved $ref target, which +// belongs to that target's own model — so this pins what that local schema +// node actually exposes for a $ref-with-siblings branch in this library. +func TestAllOf_RefBranchWithSiblingRequired31(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Human: + type: object + properties: + name: {type: string} + SuperBoyLike: + allOf: + - $ref: "#/components/schemas/Human" + required: [level] + - type: object + properties: + level: {type: integer} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("SuperBoyLike")].(*ir.Model) + require.True(t, ok, "SuperBoyLike should be a model") + require.NotNil(t, m.Base, "the sole $ref still becomes Base despite the sibling required") + + level, ok := openapitest.PropsByWire(m.Properties)["level"] + require.True(t, ok, "level is declared by the inline branch") + assert.True(t, level.Required, + "a required sibling on a $ref branch is read off the branch's own local schema and attaches to level") +} + +// TestAllOf_RequiresIsTheSameInEitherComponentOrder pins that an allOf making +// an inherited property required, and naming one nothing declares, lands both +// names in Model.Requires whichever of the composed model and its base is +// declared first. The composed model has no property of its own to hold either +// name, and the names come from the composition's required lists alone, so +// nothing about the base's declaration may reach them. +func TestAllOf_RequiresIsTheSameInEitherComponentOrder(t *testing.T) { + t.Parallel() + const ( + user = " User: {type: object, properties: {id: {type: string}}}\n" + created = ` CreatedUser: + allOf: + - {$ref: '#/components/schemas/User'} + - {required: [id, extra]} +` + ) + nodes := make([]*ir.Model, 0, 2) + for _, src := range []string{user + created, created + user} { + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(src)) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := typeByName(doc, "CreatedUser").(*ir.Model) + require.True(t, ok, "CreatedUser is a model") + nodes = append(nodes, m) + } + + assert.Empty(t, cmp.Diff(nodes[0], nodes[1]), "declaration order must not change the composed model") + for i, m := range nodes { + assert.Equal(t, []string{"id", "extra"}, m.Requires, "order %d: in source order, the inherited name included", i) + assert.Empty(t, m.Properties, "order %d: neither name is declared here, so no property holds one", i) + require.NotNil(t, m.Base, "order %d", i) + assert.Equal(t, componentID("User"), m.Base.Target) + } +} + +// TestAllOf_ARequiredNameWrittenInSeveralListsIsListedOnce pins that a name an +// allOf writes in more than one required list, a branch's and the composed +// schema's own, is in Model.Requires once, where it is first written. Each list +// is free of repeats, so the validation reports nothing, and only the lowering +// that gathers them can list a name twice. +func TestAllOf_ARequiredNameWrittenInSeveralListsIsListedOnce(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` User: {type: object, properties: {id: {type: string}}} + CreatedUser: + allOf: + - {$ref: '#/components/schemas/User'} + - {required: [id, extra]} + - {required: [extra, other]} + required: [extra, id] +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, diags, "no list repeats a name, so nothing is reported") + + m, ok := typeByName(doc, "CreatedUser").(*ir.Model) + require.True(t, ok, "CreatedUser is a model") + assert.Equal(t, []string{"id", "extra", "other"}, m.Requires, "once each, in the order first written") +} + +// TestAllOf_BoolBranchSkippedInCompositionRequired covers an allOf branch that +// is a bare boolean schema (`true` here) rather than a $ref or an inline +// object, so b.GetSchema() returns nil for it. It pins that the bool branch +// contributes no required names, while the sibling object branch's property and +// required still lower normally. +func TestAllOf_BoolBranchSkippedInCompositionRequired(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Thing: + allOf: + - type: object + required: [id] + properties: + id: {type: integer} + - true +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Thing")].(*ir.Model) + require.True(t, ok, "Thing should be a model") + assert.Nil(t, m.Base, "a bare boolean branch is never a $ref, so no Base") + assert.Empty(t, m.Mixins) + + id, hasID := openapitest.PropsByWire(m.Properties)["id"] + require.True(t, hasID, "the object branch's own property still lowers despite the sibling bool branch") + assert.True(t, id.Required, "the object branch's own required still attaches to its own property") + assert.Empty(t, m.Requires, "the boolean branch contributes no required names, so nothing goes unattached") +} diff --git a/compilers/openapi/internal/schema/allof_residue_test.go b/compilers/openapi/internal/schema/allof_residue_test.go new file mode 100644 index 00000000..fffbf6c5 --- /dev/null +++ b/compilers/openapi/internal/schema/allof_residue_test.go @@ -0,0 +1,294 @@ +package schema_test + +import ( + "encoding/json/jsontext" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +// TestAllOf_InlineBranchResidueKeptVerbatim covers GitHub #123: an inline allOf +// branch is merged in place, so the merge reads only its properties and its +// required list and everything else it declared used to be dropped without a +// diagnostic. The branch now survives verbatim beside the composed model. +func TestAllOf_InlineBranchResidueKeptVerbatim(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` S: + allOf: + - type: object + properties: {a: {type: string}} + required: [a] + additionalProperties: false + not: {type: string} + minProperties: 2 + description: BranchDoc + x-vendor: keepme +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := typeByName(doc, "S").(*ir.Model) + require.True(t, ok, "S should be a model") + + a, ok := openapitest.PropsByWire(m.Properties)["a"] + require.True(t, ok, "the merge still contributes the branch's properties") + assert.True(t, a.Required, "and still ORs the branch's required list onto them") + + entry, ok := m.Unmodeled["openapi:allOf/0"] + require.True(t, ok, "the branch is kept verbatim; got %+v", m.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Equal(t, jsontext.Pointer("/components/schemas/S/allOf/0"), entry.Provenance.Pointer, + "the entry locates the branch, not the composed schema") + for _, kept := range []string{ + `"additionalProperties":false`, `"not":{"type":"string"}`, `"minProperties":2`, + `"description":"BranchDoc"`, `"x-vendor":"keepme"`, + } { + assert.Contains(t, string(entry.Value), kept, "the whole branch is preserved") + } + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S/allOf/0"), + "additionalProperties, not, minProperties, description, x-vendor", + "the diagnostic names every keyword the merge left behind, in source order") +} + +// TestAllOf_InlineBranchNonObjectTypeWarns is the worse half of #123: a scalar +// branch declares no properties, so the merge composed an *empty* ir.Model — +// which asserts the value is an object the source says is a string. Preserving +// the branch keeps the source recoverable, and the severity says the IR states +// something wrong rather than merely incomplete. +func TestAllOf_InlineBranchNonObjectTypeWarns(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` T: + allOf: + - {type: string, maxLength: 3} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := typeByName(doc, "T").(*ir.Model) + require.True(t, ok, "the composed node is still a model") + assert.Empty(t, m.Properties, "a scalar branch declares no properties to merge") + + entry, ok := m.Unmodeled["openapi:allOf/0"] + require.True(t, ok, "the whole branch is kept verbatim; got %+v", m.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `{"type":"string","maxLength":3}`, string(entry.Value)) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/components/schemas/T/allOf/0"), + "declares a type that is not an object") + assert.False(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "a contradicted model is a warning, not the info a merely narrowed one gets") +} + +// TestAllOf_BranchWrittenByReferenceStillDerivesResidue pins the residue against +// the two ways YAML lets a branch be written by reference rather than in place. +// A branch spelled `*anchor` writes no keys of its own and one spelled +// `<<: *anchor` writes only `<<`, so reading the literal text reports either no +// residue at all — merging a branch that declares plenty to an empty model, in +// silence, which is exactly the loss #123 closed for branches written in place — +// or a residue named `<<`, which names nothing a reader can act on. +func TestAllOf_BranchWrittenByReferenceStillDerivesResidue(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Shared: &br {type: string, maxLength: 3, description: Aliased} + Aliased: {allOf: [*br]} + Merged: {allOf: [{<<: *br}]} + Overridden: {allOf: [{<<: *br, type: object, properties: {a: {type: string}}}]} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + cases := []struct { + name, wantKeys string + sev ir.Severity + }{ + {"Aliased", "type, maxLength, description", ir.SeverityWarning}, + {"Merged", "type, maxLength, description", ir.SeverityWarning}, + {"Overridden", "maxLength, description", ir.SeverityInfo}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, ok := typeByName(doc, tc.name).(*ir.Model) + require.True(t, ok, "%s should be a model", tc.name) + + entry, ok := m.Unmodeled["openapi:allOf/0"] + require.True(t, ok, "%s keeps the branch verbatim; got %+v", tc.name, m.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, string(entry.Value), `"maxLength":3`, + "the payload resolves the reference too, so the branch is recoverable") + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, tc.sev, + "/components/schemas/"+tc.name+"/allOf/0"), + "the branch ("+tc.wantKeys+")", + "the keywords the branch effectively declares are named, not `<<`") + }) + } +} + +// TestAllOf_MergedBranchKeywordsAreNotResidue guards the opposite direction: the +// keywords the merge does consume must not be preserved, or every allOf in the +// corpus would grow an Unmodeled entry restating what the composed model already +// holds. `type: object` counts as consumed because the composed node is a Model +// already. TypedBranch is the shape allof-inline-merge.yaml writes and +// RequiredOnlyBranch is allof-required-only.yaml's last branch; UntypedBranch — +// properties with no `type` at all — is written out here rather than borrowed +// from the corpus, which writes no such branch. +func TestAllOf_MergedBranchKeywordsAreNotResidue(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} + TypedBranch: + allOf: + - {$ref: '#/components/schemas/Base'} + - type: object + properties: {name: {type: string}} + UntypedBranch: + allOf: + - {$ref: '#/components/schemas/Base'} + - properties: {name: {type: string}} + RequiredOnlyBranch: + allOf: + - {$ref: '#/components/schemas/Base'} + - type: object + properties: {name: {type: string}} + - required: [name] +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + for _, name := range []string{"TypedBranch", "UntypedBranch", "RequiredOnlyBranch"} { + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s should be a model", name) + assert.Empty(t, m.Unmodeled, + "%s: properties, required and a bare `type: object` are merged, not residue", name) + } + assert.Zero(t, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "a branch the merge fully consumes announces nothing; got %+v", diags) +} + +// TestAllOf_BranchResidueDerivedFromDeclaredKeys pins that the residue is derived +// from the branch's own declared keys rather than from a list of keywords worth +// keeping — a keyword this compiler models nothing for still counts, so a keyword +// a later dialect adds needs no change here to survive. The `[object, "null"]` +// case is the same rule from the other side: `type` is consumed only when it is +// exactly `object`, so the null a set also declares stays recoverable. +func TestAllOf_BranchResidueDerivedFromDeclaredKeys(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Unknown: + allOf: + - type: object + properties: {a: {type: string}} + futureKeyword: {some: thing} + NullableBranch: + allOf: + - type: [object, 'null'] + properties: {a: {type: string}} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + for name, want := range map[string]string{ + "Unknown": `"futureKeyword":{"some":"thing"}`, + "NullableBranch": `"type":["object","null"]`, + } { + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s should be a model", name) + require.Len(t, m.Properties, 1, "%s still merges the branch's properties", name) + entry, ok := m.Unmodeled["openapi:allOf/0"] + require.True(t, ok, "%s keeps the branch verbatim; got %+v", name, m.Unmodeled) + assert.Contains(t, string(entry.Value), want, "%s", name) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, + "/components/schemas/"+name+"/allOf/0"), + "kept verbatim under Unmodeled", name) + } +} + +// TestAllOf_EachInlineBranchKeyedSeparately: two branches that both leave residue +// must not overwrite each other's entry, and each is reported at its own branch. +func TestAllOf_EachInlineBranchKeyedSeparately(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Multi: + allOf: + - type: object + properties: {a: {type: string}} + description: FirstDoc + - type: object + properties: {b: {type: string}} + minProperties: 1 +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := typeByName(doc, "Multi").(*ir.Model) + require.True(t, ok, "Multi should be a model") + require.Len(t, m.Properties, 2, "both branches still merge their properties") + require.Len(t, m.Unmodeled, 2, "one entry per branch; got %+v", m.Unmodeled) + assert.Contains(t, string(m.Unmodeled["openapi:allOf/0"].Value), `"description":"FirstDoc"`) + assert.Contains(t, string(m.Unmodeled["openapi:allOf/1"].Value), `"minProperties":1`) + + for i, want := range []string{"description", "minProperties"} { + at := fmt.Sprintf("/components/schemas/Multi/allOf/%d", i) + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, at), want, + "branch %d is reported at its own pointer, naming its own residue", i) + } +} + +// TestAllOf_BranchResidueRidesEveryDistributedVariant covers fillAllOf's second +// caller: buildComposedVariant re-runs the same merge once per union variant, so +// the residue has to land on every variant the composition rides on. The +// diagnostic is announced once even so — the branch is one source construct +// however many variants carry it. +func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} + A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Distributed: + allOf: + - {$ref: '#/components/schemas/Base'} + - type: object + properties: {c: {type: string}} + description: BranchDoc + oneOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/B'} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + u, ok := typeByName(doc, "Distributed").(*ir.Union) + require.True(t, ok, "Distributed distributes into a union") + require.Len(t, u.Variants, 2) + + for i, v := range u.Variants { + variant, ok := doc.Types[v.Type.Target].(*ir.Model) + require.True(t, ok, "variant %d is a composed model", i) + entry, ok := variant.Unmodeled["openapi:allOf/1"] + require.True(t, ok, "variant %d carries the branch residue; got %+v", i, variant.Unmodeled) + assert.Contains(t, string(entry.Value), `"description":"BranchDoc"`, "variant %d", i) + } + assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "one branch, one diagnostic, however many variants carry it; got %+v", diags) +} + +// TestAllOf_UnpreservableBranchIsNotAnnounced is +// TestUnhomedApplicator_UnpreservableKeywordIsNotAnnounced's pairing on the +// composition path: a branch the merge does not consume whole is normally kept +// beside the model and reported, and when the keeping fails the report must not +// happen either. +func TestAllOf_UnpreservableBranchIsNotAnnounced(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` M: + allOf: + - {type: string, maxLength: 3, x-t: `+unpreservableValue+`} +`)) + + td := typeByName(doc, "M") + require.NotNil(t, td) + assert.NotContains(t, td.Common().Unmodeled, "openapi:allOf/0", "the branch did not convert") + assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct)) + assert.Empty(t, preservationClaims(diags), + "no degradation is announced for a branch that was not kept") +} diff --git a/compilers/openapi/internal/schema/allof_test.go b/compilers/openapi/internal/schema/allof_test.go index 98efc124..0b4beb14 100644 --- a/compilers/openapi/internal/schema/allof_test.go +++ b/compilers/openapi/internal/schema/allof_test.go @@ -2,14 +2,9 @@ package schema_test import ( "encoding/json/jsontext" - "fmt" - "maps" - "slices" - "strings" "testing" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,60 +15,6 @@ import ( "github.com/dexpace/morphic/pass" ) -// TestAllOf_NestedLowersAsFlat holds the claim of the flattening itself: a -// nested spelling of a composition lowers as its flat one does. It compares the -// composed models with pointers, provenance and hoisted IDs projected away — -// base, mixins, properties in order with their type and required flag, the -// required names held for no property, openness, discriminator value, variants — -// and the codes reported. The per-case rows of -// TestAllOf_NestedInlineWrapperFlattens each pin one consequence; this holds the -// property for every reader of the list. -func TestAllOf_NestedLowersAsFlat(t *testing.T) { - t.Parallel() - for _, tc := range equivalencePairs { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - nested, nestedCodes := compileView(t, tc.nested) - flat, flatCodes := compileView(t, tc.flat) - assert.NotEqual(t, composedView{}, flat, "the flat spelling lowers to something to compare") - assert.Empty(t, cmp.Diff(flat, nested), "the nested spelling composes as the flat one does") - assert.Equal(t, flatCodes, nestedCodes, "and reports the same codes") - }) - } -} - -// TestAllOf_NestedLowersAsFlat_ReachesWhatItCompares pins that the projection -// sees the distinctions the pairs rely on, so a pair that agrees does so for a -// reason: a distributed union has variants, the discriminated subtype has its -// tag, a false branch closes the model, and a required name the base declares -// is held in Requires. The residue and the false branch are also kept, which -// only a positive Kept shows. -func TestAllOf_NestedLowersAsFlat_ReachesWhatItCompares(t *testing.T) { - t.Parallel() - byName := map[string]string{} - for _, tc := range equivalencePairs { - byName[tc.name] = tc.flat - } - distributed, _ := compileView(t, byName["a distributed oneOf beside the wrappers"]) - require.Len(t, distributed.Variants, 2, "the oneOf distributes") - assert.Equal(t, []string{"w"}, wires(distributed.Variants[0].Props), "each variant carries the body's own property") - assert.Equal(t, string(componentID("C")), distributed.Variants[0].Base, "and the ref the wrapper held as its base") - - closed, _ := compileView(t, byName["a false branch"]) - assert.Equal(t, ir.AdditionalClosed, closed.Additional) - assert.Positive(t, closed.Kept, "the false branch is kept beside closing the model") - - required, _ := compileView(t, byName["a required name only the base declares"]) - assert.Equal(t, []string{"a"}, required.Requires, "the name is required of the composition, not of a property it holds") - - residue, _ := compileView(t, byName["a residue description"]) - assert.Positive(t, residue.Kept, "the residue is kept") - - subtype, _ := compileView(t, byName["a discriminated base inside a wrapper"]) - assert.Equal(t, "dog", subtype.DiscValue) - assert.Equal(t, componentID("Pet"), ir.TypeID(subtype.Base), "the discriminated ref is the base") -} - func TestAllOf_SoleRefBecomesBase(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` Animal: @@ -100,929 +41,6 @@ func TestAllOf_SoleRefBecomesBase(t *testing.T) { "merged property provenance points into its allOf branch") } -func TestAllOf_OverlappingInlineBranchesReconcile(t *testing.T) { - t.Parallel() - // Two inline allOf branches redeclare the same fields — the shape GitHub's - // webhook `forkee` uses (a documented object plus a doc-stripped duplicate - // that marks some fields required). allOf is an intersection, so each wire - // name must reconcile to a single property, not append a duplicate. - spec := openapitest.ComponentSpec(` Forkish: - allOf: - - type: object - properties: - id: {type: integer, description: the identifier} - name: {type: string} - - type: object - required: [id] - properties: - id: {type: integer} - url: {type: string} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Forkish")].(*ir.Model) - require.True(t, ok, "Forkish should be a model") - - byWire := map[string]int{} - for _, p := range m.Properties { - byWire[p.WireName]++ - } - assert.Equal(t, 1, byWire["id"], "id declared in both branches reconciles to one property") - assert.Equal(t, 1, byWire["name"]) - assert.Equal(t, 1, byWire["url"]) - require.Len(t, m.Properties, 3, "no duplicate properties across overlapping inline branches") - - var id ir.Property - for _, p := range m.Properties { - if p.WireName == "id" { - id = p - } - } - assert.True(t, id.Required, "required in the second branch => required on the merged model (allOf intersection)") - assert.Equal(t, "the identifier", id.Docs.Description, - "the richer (documented) declaration defines the reconciled shape") -} - -func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { - t.Parallel() - // The bare declaration comes first and the richer one second: reconciliation - // must still surface every optional detail, so branch order never loses - // information (the reverse of the forkee documented-first shape). - spec := openapitest.ComponentSpec(` Tokenish: - allOf: - - type: object - properties: - token: {type: string} - - type: object - required: [token] - properties: - token: - type: string - format: password - description: the access token - default: none - minLength: 1 - deprecated: true - examples: [abc] - xml: {name: tok} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) - require.True(t, ok, "Tokenish should be a model") - require.Len(t, m.Properties, 1, "token reconciles to a single property") - - tok := m.Properties[0] - assert.True(t, tok.Required, "required in the richer branch => required on the merged property") - assert.True(t, tok.Secret, "secrecy (format:password) adopted from the richer branch") - assert.Equal(t, "the access token", tok.Docs.Description, "description adopted whichever branch carries it") - require.NotNil(t, tok.Default, "default adopted from the richer branch") - require.NotNil(t, tok.Constraints, "constraints adopted from the richer branch") - require.NotNil(t, tok.Constraints.MinLength, "minLength adopted from the richer branch") - require.NotNil(t, tok.Deprecation, "deprecation adopted from the richer branch") - require.NotNil(t, tok.XML, "xml hints adopted from the richer branch") - require.Len(t, tok.Examples, 1, "examples adopted from the richer branch") -} - -// TestAllOf_RedeclarationOrsSecret pins that a redaction one branch writes -// survives the fold in both declaration orders, and that it costs the fold -// nothing else: `format: password` names no shape, so both declarations share -// the bare string's target and every detail folds rather than the losing one -// being dropped whole. -func TestAllOf_RedeclarationOrsSecret(t *testing.T) { - t.Parallel() - secret := " token: {type: string, format: password}\n" - bounded := " token: {type: string, minLength: 3, description: the token}\n" - branch := " - type: object\n properties:\n" - for _, tc := range []struct{ name, first, second string }{ - {"password first", secret, bounded}, - {"password second", bounded, secret}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(" Tokenish:\n allOf:\n" + - branch + tc.first + branch + tc.second) - doc, diags := schema.LowerSpec(t, spec) - assert.Empty(t, diags, "nothing conflicts") - m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) - require.True(t, ok, "Tokenish should be a model") - require.Len(t, m.Properties, 1, "token reconciles to a single property") - tok := m.Properties[0] - assert.True(t, tok.Secret, "either branch's redaction makes the merged field secret") - assert.Equal(t, ir.TypeID("t/prim/string"), tok.Type.Target, "both branches share the target") - require.NotNil(t, tok.Constraints, "the other branch's bound is folded, not dropped") - assert.Equal(t, new(int64(3)), tok.Constraints.MinLength) - assert.Equal(t, "the token", tok.Docs.Description) - assert.Empty(t, tok.Unmodeled, "no declaration was kept as a loser") - }) - } -} - -func TestAllOf_ConflictingRedeclaredDescriptionDiagnosed(t *testing.T) { - t.Parallel() - // Two branches describe the same field differently. The first declaration in - // source order wins the shape, but the dropped description is surfaced as an - // info diagnostic rather than vanishing silently. - spec := openapitest.ComponentSpec(` Clashish: - allOf: - - type: object - properties: - id: {type: integer, description: the first meaning} - - type: object - properties: - id: {type: integer, description: a different meaning} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) // a description clash is info-level, never an error - m, ok := doc.Types[componentID("Clashish")].(*ir.Model) - require.True(t, ok, "Clashish should be a model") - require.Len(t, m.Properties, 1, "id still reconciles to one property") - assert.Equal(t, "the first meaning", m.Properties[0].Docs.Description, - "the first declaration in source order wins the description") - assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "a differing redeclared description is surfaced, not dropped silently") -} - -func TestAllOf_ConflictingRedeclaredTypeDiagnosedAndKept(t *testing.T) { - t.Parallel() - // allOf is an intersection, so a field one branch types `string` and another - // types `integer` describes an unsatisfiable schema. Reconciliation keeps the - // first declaration's shape (as before) but must no longer swallow the - // conflict — it names the field and both branch sites so the author can find - // and fix them. - spec := openapitest.ComponentSpec(` Conflictish: - allOf: - - type: object - properties: - id: {type: string} - - type: object - properties: - id: {type: integer} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) // a redeclaration conflict is a warning, not a refusal - m, ok := doc.Types[componentID("Conflictish")].(*ir.Model) - require.True(t, ok, "Conflictish should be a model") - require.Len(t, m.Properties, 1, "id still reconciles to one property") - assert.Equal(t, ir.TypeID("t/prim/string"), m.Properties[0].Type.Target, - "the first declaration in source order still wins the shape") - - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "the incompatible type redeclaration is diagnosed exactly once") - d := conflicts[0] - assert.Equal(t, ir.SeverityWarning, d.Severity, "a usable-but-suspicious model is a warning") - assert.Contains(t, d.Message, `"id"`, "the diagnostic names the conflicting field") - assert.Contains(t, d.Message, "allOf/0", "the diagnostic names the first branch site") - assert.Contains(t, d.Message, "allOf/1", "the diagnostic names the second branch site") - - // A diagnostic is not part of the document, so the losing declaration is - // also kept where a consumer reading the IR will reach it (GitHub #424). - lost, ok := m.Properties[0].Unmodeled["openapi:conflicting-redeclaration"+ - "/components/schemas/Conflictish/allOf/1/properties/id"] - require.True(t, ok, - "the discarded type is kept beside the winner; got %v", m.Properties[0].Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, lost.Reason) - assert.JSONEq(t, `{"type":"integer"}`, string(lost.Value), - "and it is the declaration the second branch wrote, not the one that won") - assert.Equal(t, jsontext.Pointer("/components/schemas/Conflictish/allOf/1/properties/id"), lost.Provenance.Pointer) -} - -// TestAllOf_OverlappingPrimitivesAreReportedNotConflicting pins that two -// formats whose kinds share values draw one info at the redeclaration, instead -// of the unsatisfiable-type warning, keep the first declaration's kind and keep -// the redeclaration, in either branch order. -func TestAllOf_OverlappingPrimitivesAreReportedNotConflicting(t *testing.T) { - t.Parallel() - const at = "/components/schemas/Overlapish/allOf/1/properties/n" - for _, order := range [][2]string{{"int32", "uint32"}, {"uint32", "int32"}} { - t.Run(order[0]+" first", func(t *testing.T) { - t.Parallel() - bounds := [2]int{10, 20} - if order[0] == "uint32" { - bounds = [2]int{20, 10} - } - spec := openapitest.ComponentSpec(fmt.Sprintf(` Overlapish: - allOf: - - type: object - properties: - n: {type: integer, format: %s, minimum: %d} - - type: object - properties: - n: {type: integer, format: %s, minimum: %d} -`, order[0], bounds[0], order[1], bounds[1])) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Overlapish")].(*ir.Model) - require.True(t, ok, "Overlapish should be a model") - require.Len(t, m.Properties, 1) - assert.Equal(t, ir.TypeID("t/prim/"+order[0]), m.Properties[0].Type.Target, "the first declaration's kind stays") - - assert.Empty(t, conflictDiags(diags), "overlapping kinds are not an unsatisfiable type") - assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, at), - "overlap", "the overlap is reported at the redeclaration, as an info") - var atProperty []ir.Diagnostic - for _, d := range diags { - if d.Provenance.Pointer == at { - atProperty = append(atProperty, d) - } - } - assert.Len(t, atProperty, 1, "exactly one diagnostic at the property; got %v", atProperty) - assert.Contains(t, m.Properties[0].Unmodeled, - "openapi:conflicting-redeclaration"+at, "and the redeclaration is kept") - }) - } -} - -// TestAllOf_EnumPairsAreJudgedByKind pins what an enum side draws at the schema -// level, in either order: an enum over a bare kind it equals or narrows wins -// with no diagnostic; a bare kind that admits one of its members overlaps it, -// which is info; a kind that admits none of them warns, as does another family; -// and two enums of one kind, each with members, overlap. A Scalar node of a -// judged kind is judged by member too, and one with no judge refuses nothing. -func TestAllOf_EnumPairsAreJudgedByKind(t *testing.T) { - t.Parallel() - cases := []struct { - name, a, b string - info, warn bool - }{ - {"enum against uuid", "{type: string, enum: [a]}", "{type: string, format: uuid}", false, true}, - {"enum against int32", "{type: integer, enum: [3000000000]}", "{type: integer, format: int32}", false, true}, - {"enum against a date it admits", `{type: string, enum: ["2024-01-01"]}`, "{type: string, format: date}", true, false}, - {"enum against a uuid scalar", "{type: string, enum: [a]}", "{type: string, format: uuid, contentMediaType: text/plain}", false, true}, - {"enum against a date scalar it admits", `{type: string, enum: ["2024-01-01"]}`, "{type: string, format: date, contentMediaType: text/plain}", true, false}, - {"empty enum against byte", "{type: string, enum: []}", "{type: string, format: byte}", false, true}, - {"enum against byte", "{type: string, enum: [a]}", "{type: string, format: byte}", true, false}, - {"enum against integer", "{type: string, enum: [a]}", "{type: integer}", false, true}, - {"enum against enum", "{type: string, enum: [a]}", "{type: string, enum: [b]}", true, false}, - {"enum against string", "{type: string, enum: [a]}", "{type: string}", false, false}, - } - for _, tc := range cases { - for _, swap := range []bool{false, true} { - a, b := tc.a, tc.b - if swap { - a, b = b, a - } - t.Run(fmt.Sprintf("%s swapped=%t", tc.name, swap), func(t *testing.T) { - t.Parallel() - _, diags := schema.LowerSpec(t, allOfConflictSpec(a, b)) - openapitest.RequireNoErrorDiags(t, diags) - assert.Equal(t, tc.info, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "the overlap info") - if tc.warn { - assert.Len(t, conflictDiags(diags), 1, "one conflict warning") - return - } - assert.Empty(t, conflictDiags(diags), "no conflict") - }) - } - } -} - -// TestAllOf_ALosingRedeclarationIsKeptAsWritten pins what the entry holds at -// the two property shapes that have no schema node of their own to speak of: a -// `$ref` is kept as the `$ref` the position wrote, not as the target it names, -// and a boolean schema — which has no source node to render — is kept as its value. -func TestAllOf_ALosingRedeclarationIsKeptAsWritten(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Str: {type: string} - Referred: - allOf: - - type: object - properties: - id: {type: integer} - - type: object - properties: - id: {$ref: '#/components/schemas/Str'} - Nothing: - allOf: - - type: object - properties: - id: {type: integer} - - type: object - properties: - id: false -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - for _, tc := range []struct{ model, want string }{ - {"Referred", `{"$ref":"#/components/schemas/Str"}`}, - {"Nothing", `false`}, - } { - m, ok := doc.Types[componentID(tc.model)].(*ir.Model) - require.True(t, ok, "%s should be a model", tc.model) - require.Len(t, m.Properties, 1) - key := "openapi:conflicting-redeclaration/components/schemas/" + tc.model + "/allOf/1/properties/id" - lost, ok := m.Properties[0].Unmodeled[key] - require.True(t, ok, "%s keeps its losing declaration; got %v", tc.model, m.Properties[0].Unmodeled) - assert.JSONEq(t, tc.want, string(lost.Value)) - } -} - -func TestAllOf_ConflictingRedeclaredConstraintDiagnosed(t *testing.T) { - t.Parallel() - // Same target type, but the two branches pin the same keyword to different - // values (maxLength 10 vs 20). The chosen winner is arbitrary source order, so - // the dropped bound is surfaced rather than silently discarded. - spec := openapitest.ComponentSpec(` Boundish: - allOf: - - type: object - properties: - code: {type: string, maxLength: 10} - - type: object - properties: - code: {type: string, maxLength: 20} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Boundish")].(*ir.Model) - require.True(t, ok, "Boundish should be a model") - require.Len(t, m.Properties, 1, "code reconciles to one property") - require.NotNil(t, m.Properties[0].Constraints, "the first declaration's constraints are kept") - require.NotNil(t, m.Properties[0].Constraints.MaxLength) - assert.Equal(t, int64(10), *m.Properties[0].Constraints.MaxLength, - "the first declaration in source order still wins the constraint") - - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "the incompatible constraint redeclaration is diagnosed exactly once") - d := conflicts[0] - assert.Equal(t, ir.SeverityWarning, d.Severity) - assert.Contains(t, d.Message, `"code"`, "the diagnostic names the conflicting field") - assert.Contains(t, d.Message, "allOf/0") - assert.Contains(t, d.Message, "allOf/1") -} - -func TestAllOf_CompatibleRedeclarationStaysSilent(t *testing.T) { - t.Parallel() - // The reconcilable case: identical target type, the second branch only adds - // `required`. This must stay silent — a redeclaration is not by itself a - // conflict, only an incompatible one is. - spec := openapitest.ComponentSpec(` Compatish: - allOf: - - type: object - properties: - id: {type: integer} - - type: object - required: [id] - properties: - id: {type: integer} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Compatish")].(*ir.Model) - require.True(t, ok, "Compatish should be a model") - require.Len(t, m.Properties, 1, "id reconciles to one property") - assert.True(t, m.Properties[0].Required, "required from the second branch is OR-ed in") - - assert.Empty(t, conflictDiags(diags), - "a compatible redeclaration is not a conflict") -} - -func TestAllOf_PropertyAlongsideAllOfReconciles(t *testing.T) { - t.Parallel() - // A property declared directly on the allOf schema redeclares a field an inline - // branch already contributed; the sibling and the branch declaration reconcile - // into one property instead of colliding on the wire. - spec := openapitest.ComponentSpec(` Mixish: - required: [id] - properties: - id: {type: integer} - allOf: - - type: object - properties: - id: {type: integer, description: the identifier} - name: {type: string} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Mixish")].(*ir.Model) - require.True(t, ok, "Mixish should be a model") - require.Len(t, m.Properties, 2, "id (branch + sibling) reconciles to one; name stays its own") - - var id ir.Property - for _, p := range m.Properties { - if p.WireName == "id" { - id = p - } - } - assert.True(t, id.Required, "required alongside allOf => required on the reconciled property") - assert.Equal(t, "the identifier", id.Docs.Description, - "detail from the branch declaration survives reconciliation with the sibling property") -} - -// TestAllOf_CompositionRequiredAttaches covers issue #29: allOf is an -// intersection, so a `required` list constrains the whole composed object -// regardless of which branch declares the named property, or whether it is -// paired with a properties map at all. Each case names a wire property that -// must end up Required=true, which leaves Requires empty, since every name -// matches an own property. -func TestAllOf_CompositionRequiredAttaches(t *testing.T) { - t.Parallel() - tests := []struct { - name string // subtest name, describing the case - spec string - model string // component under test - wire string // property expected to end up Required=true - }{ - { - name: "required-only branch after the branch declaring the property (issue #29 repro)", - spec: openapitest.ComponentSpec(` Thing: - allOf: - - type: object - properties: - id: {type: integer} - - required: [id] -`), - model: "Thing", - wire: "id", - }, - { - name: "required-only branch before the branch declaring the property", - spec: openapitest.ComponentSpec(` ThingB: - allOf: - - required: [id] - - type: object - properties: - id: {type: integer} -`), - model: "ThingB", - wire: "id", - }, - { - name: "required-only branch, property declared alongside allOf", - spec: openapitest.ComponentSpec(` Foo: - type: object - properties: - a: {type: string} - Bar: - type: object - properties: - type: {type: string} - name: {type: string} - allOf: - - {$ref: "#/components/schemas/Foo"} - - required: [type] -`), - model: "Bar", - wire: "type", - }, - { - name: "branch declares properties: {} (empty, non-nil) plus required", - spec: openapitest.ComponentSpec(` ThingF: - allOf: - - type: object - properties: - id: {type: integer} - - type: object - properties: {} - required: [id] -`), - model: "ThingF", - wire: "id", - }, - { - name: "the schema's own required (sibling of allOf, no sibling properties) names a property declared inside a branch", - spec: openapitest.ComponentSpec(` ThingG: - required: [id] - allOf: - - type: object - properties: - id: {type: integer} -`), - model: "ThingG", - wire: "id", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, tc.spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID(tc.model)].(*ir.Model) - require.True(t, ok, "%s should be a model", tc.model) - p, ok := openapitest.PropsByWire(m.Properties)[tc.wire] - require.True(t, ok, "%s should have a %q property", tc.model, tc.wire) - assert.True(t, p.Required, "%s.%s should be required via composition-scope required", tc.model, tc.wire) - assert.Empty(t, m.Requires, "every required name matches an own property, so none is listed beside them") - }) - } -} - -// TestAllOf_RequiredOnlyBranchNamingBaseOwnedPropertyLandsInRequires pins an -// allOf composed of a sole $ref (its Base) and a required-only branch naming a -// property only the base declares. ir-design §4.3 stores a model's own -// properties, never a flattened view, so the name lands in Model.Requires: the -// composed model makes the inherited property required, and nothing is -// misattached to an unrelated property or reported as lost. -func TestAllOf_RequiredOnlyBranchNamingBaseOwnedPropertyLandsInRequires(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Human: - type: object - properties: - name: {type: string} - level: {type: integer} - SuperBaby: - allOf: - - {$ref: "#/components/schemas/Human"} - - required: [level] - properties: - gender: {type: string} -`) - doc, diags := schema.LowerSpec(t, spec) - m, ok := doc.Types[componentID("SuperBaby")].(*ir.Model) - require.True(t, ok, "SuperBaby should be a model") - require.NotNil(t, m.Base, "the sole $ref still becomes Base") - assert.Equal(t, componentID("Human"), m.Base.Target) - - _, hasLevel := openapitest.PropsByWire(m.Properties)["level"] - assert.False(t, hasLevel, "level belongs to the base, not to SuperBaby's own properties") - gender, hasGender := openapitest.PropsByWire(m.Properties)["gender"] - require.True(t, hasGender, "gender is SuperBaby's own property") - assert.False(t, gender.Required, "level's requiredness never misattaches to gender") - assert.Equal(t, []string{"level"}, m.Requires, "SuperBaby makes the level Human declares required") - assert.Empty(t, diags, "the requirement is carried, so nothing is reported") -} - -// TestAllOf_RequiredNameNothingDeclaresLandsInRequires covers an allOf with no -// $ref branch at all, so nothing becomes Base or a Mixin and the required name -// is one no schema declares: legal JSON Schema, which requires the member -// whatever its value. It lands in Model.Requires as the inherited one does, -// with no property invented for it. -func TestAllOf_RequiredNameNothingDeclaresLandsInRequires(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Thing: - allOf: - - type: object - properties: - id: {type: integer} - - required: [ghost] -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Thing")].(*ir.Model) - require.True(t, ok, "Thing should be a model") - assert.Nil(t, m.Base, "no $ref branch at all: no Base") - assert.Empty(t, m.Mixins, "no $ref branch at all: no Mixins") - - id, hasID := openapitest.PropsByWire(m.Properties)["id"] - require.True(t, hasID, "the branch's own id property still lowers") - assert.False(t, id.Required, "ghost's requiredness never misattaches to id") - _, hasGhost := openapitest.PropsByWire(m.Properties)["ghost"] - assert.False(t, hasGhost, "ghost is never invented as a property") - assert.Equal(t, []string{"ghost"}, m.Requires) - assert.Empty(t, diags, "the requirement is carried, so nothing is reported") -} - -// TestAllOf_RefBranchWithSiblingRequired31 pins the real behavior of a $ref -// allOf branch that also carries a sibling `required` (legal in OpenAPI 3.1 / -// JSON Schema 2020-12, where $ref no longer has to be the schema object's only -// keyword). compositionRequired reads a branch's required list off its own -// local schema node (GetSchema()) — never the resolved $ref target, which -// belongs to that target's own model — so this pins what that local schema -// node actually exposes for a $ref-with-siblings branch in this library. -func TestAllOf_RefBranchWithSiblingRequired31(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Human: - type: object - properties: - name: {type: string} - SuperBoyLike: - allOf: - - $ref: "#/components/schemas/Human" - required: [level] - - type: object - properties: - level: {type: integer} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("SuperBoyLike")].(*ir.Model) - require.True(t, ok, "SuperBoyLike should be a model") - require.NotNil(t, m.Base, "the sole $ref still becomes Base despite the sibling required") - - level, ok := openapitest.PropsByWire(m.Properties)["level"] - require.True(t, ok, "level is declared by the inline branch") - assert.True(t, level.Required, - "a required sibling on a $ref branch is read off the branch's own local schema and attaches to level") -} - -// TestAllOf_InlineBranchResidueKeptVerbatim covers GitHub #123: an inline allOf -// branch is merged in place, so the merge reads only its properties and its -// required list and everything else it declared used to be dropped without a -// diagnostic. The branch now survives verbatim beside the composed model. -func TestAllOf_InlineBranchResidueKeptVerbatim(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` S: - allOf: - - type: object - properties: {a: {type: string}} - required: [a] - additionalProperties: false - not: {type: string} - minProperties: 2 - description: BranchDoc - x-vendor: keepme -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := typeByName(doc, "S").(*ir.Model) - require.True(t, ok, "S should be a model") - - a, ok := openapitest.PropsByWire(m.Properties)["a"] - require.True(t, ok, "the merge still contributes the branch's properties") - assert.True(t, a.Required, "and still ORs the branch's required list onto them") - - entry, ok := m.Unmodeled["openapi:allOf/0"] - require.True(t, ok, "the branch is kept verbatim; got %+v", m.Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Equal(t, jsontext.Pointer("/components/schemas/S/allOf/0"), entry.Provenance.Pointer, - "the entry locates the branch, not the composed schema") - for _, kept := range []string{ - `"additionalProperties":false`, `"not":{"type":"string"}`, `"minProperties":2`, - `"description":"BranchDoc"`, `"x-vendor":"keepme"`, - } { - assert.Contains(t, string(entry.Value), kept, "the whole branch is preserved") - } - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S/allOf/0"), - "additionalProperties, not, minProperties, description, x-vendor", - "the diagnostic names every keyword the merge left behind, in source order") -} - -// TestAllOf_InlineBranchNonObjectTypeWarns is the worse half of #123: a scalar -// branch declares no properties, so the merge composed an *empty* ir.Model — -// which asserts the value is an object the source says is a string. Preserving -// the branch keeps the source recoverable, and the severity says the IR states -// something wrong rather than merely incomplete. -func TestAllOf_InlineBranchNonObjectTypeWarns(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` T: - allOf: - - {type: string, maxLength: 3} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := typeByName(doc, "T").(*ir.Model) - require.True(t, ok, "the composed node is still a model") - assert.Empty(t, m.Properties, "a scalar branch declares no properties to merge") - - entry, ok := m.Unmodeled["openapi:allOf/0"] - require.True(t, ok, "the whole branch is kept verbatim; got %+v", m.Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, `{"type":"string","maxLength":3}`, string(entry.Value)) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/components/schemas/T/allOf/0"), - "declares a type that is not an object") - assert.False(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "a contradicted model is a warning, not the info a merely narrowed one gets") -} - -// TestAllOf_BranchWrittenByReferenceStillDerivesResidue pins the residue against -// the two ways YAML lets a branch be written by reference rather than in place. -// A branch spelled `*anchor` writes no keys of its own and one spelled -// `<<: *anchor` writes only `<<`, so reading the literal text reports either no -// residue at all — merging a branch that declares plenty to an empty model, in -// silence, which is exactly the loss #123 closed for branches written in place — -// or a residue named `<<`, which names nothing a reader can act on. -func TestAllOf_BranchWrittenByReferenceStillDerivesResidue(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Shared: &br {type: string, maxLength: 3, description: Aliased} - Aliased: {allOf: [*br]} - Merged: {allOf: [{<<: *br}]} - Overridden: {allOf: [{<<: *br, type: object, properties: {a: {type: string}}}]} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - cases := []struct { - name, wantKeys string - sev ir.Severity - }{ - {"Aliased", "type, maxLength, description", ir.SeverityWarning}, - {"Merged", "type, maxLength, description", ir.SeverityWarning}, - {"Overridden", "maxLength, description", ir.SeverityInfo}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - m, ok := typeByName(doc, tc.name).(*ir.Model) - require.True(t, ok, "%s should be a model", tc.name) - - entry, ok := m.Unmodeled["openapi:allOf/0"] - require.True(t, ok, "%s keeps the branch verbatim; got %+v", tc.name, m.Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, string(entry.Value), `"maxLength":3`, - "the payload resolves the reference too, so the branch is recoverable") - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, tc.sev, - "/components/schemas/"+tc.name+"/allOf/0"), - "the branch ("+tc.wantKeys+")", - "the keywords the branch effectively declares are named, not `<<`") - }) - } -} - -// TestAllOf_MergedBranchKeywordsAreNotResidue guards the opposite direction: the -// keywords the merge does consume must not be preserved, or every allOf in the -// corpus would grow an Unmodeled entry restating what the composed model already -// holds. `type: object` counts as consumed because the composed node is a Model -// already. TypedBranch is the shape allof-inline-merge.yaml writes and -// RequiredOnlyBranch is allof-required-only.yaml's last branch; UntypedBranch — -// properties with no `type` at all — is written out here rather than borrowed -// from the corpus, which writes no such branch. -func TestAllOf_MergedBranchKeywordsAreNotResidue(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} - TypedBranch: - allOf: - - {$ref: '#/components/schemas/Base'} - - type: object - properties: {name: {type: string}} - UntypedBranch: - allOf: - - {$ref: '#/components/schemas/Base'} - - properties: {name: {type: string}} - RequiredOnlyBranch: - allOf: - - {$ref: '#/components/schemas/Base'} - - type: object - properties: {name: {type: string}} - - required: [name] -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - for _, name := range []string{"TypedBranch", "UntypedBranch", "RequiredOnlyBranch"} { - m, ok := typeByName(doc, name).(*ir.Model) - require.True(t, ok, "%s should be a model", name) - assert.Empty(t, m.Unmodeled, - "%s: properties, required and a bare `type: object` are merged, not residue", name) - } - assert.Zero(t, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "a branch the merge fully consumes announces nothing; got %+v", diags) -} - -// TestAllOf_BranchResidueDerivedFromDeclaredKeys pins that the residue is derived -// from the branch's own declared keys rather than from a list of keywords worth -// keeping — a keyword this compiler models nothing for still counts, so a keyword -// a later dialect adds needs no change here to survive. The `[object, "null"]` -// case is the same rule from the other side: `type` is consumed only when it is -// exactly `object`, so the null a set also declares stays recoverable. -func TestAllOf_BranchResidueDerivedFromDeclaredKeys(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Unknown: - allOf: - - type: object - properties: {a: {type: string}} - futureKeyword: {some: thing} - NullableBranch: - allOf: - - type: [object, 'null'] - properties: {a: {type: string}} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - for name, want := range map[string]string{ - "Unknown": `"futureKeyword":{"some":"thing"}`, - "NullableBranch": `"type":["object","null"]`, - } { - m, ok := typeByName(doc, name).(*ir.Model) - require.True(t, ok, "%s should be a model", name) - require.Len(t, m.Properties, 1, "%s still merges the branch's properties", name) - entry, ok := m.Unmodeled["openapi:allOf/0"] - require.True(t, ok, "%s keeps the branch verbatim; got %+v", name, m.Unmodeled) - assert.Contains(t, string(entry.Value), want, "%s", name) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, - "/components/schemas/"+name+"/allOf/0"), - "kept verbatim under Unmodeled", name) - } -} - -// TestAllOf_EachInlineBranchKeyedSeparately: two branches that both leave residue -// must not overwrite each other's entry, and each is reported at its own branch. -func TestAllOf_EachInlineBranchKeyedSeparately(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Multi: - allOf: - - type: object - properties: {a: {type: string}} - description: FirstDoc - - type: object - properties: {b: {type: string}} - minProperties: 1 -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := typeByName(doc, "Multi").(*ir.Model) - require.True(t, ok, "Multi should be a model") - require.Len(t, m.Properties, 2, "both branches still merge their properties") - require.Len(t, m.Unmodeled, 2, "one entry per branch; got %+v", m.Unmodeled) - assert.Contains(t, string(m.Unmodeled["openapi:allOf/0"].Value), `"description":"FirstDoc"`) - assert.Contains(t, string(m.Unmodeled["openapi:allOf/1"].Value), `"minProperties":1`) - - for i, want := range []string{"description", "minProperties"} { - at := fmt.Sprintf("/components/schemas/Multi/allOf/%d", i) - assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, at), want, - "branch %d is reported at its own pointer, naming its own residue", i) - } -} - -// TestAllOf_BranchResidueRidesEveryDistributedVariant covers fillAllOf's second -// caller: buildComposedVariant re-runs the same merge once per union variant, so -// the residue has to land on every variant the composition rides on. The -// diagnostic is announced once even so — the branch is one source construct -// however many variants carry it. -func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} - A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Distributed: - allOf: - - {$ref: '#/components/schemas/Base'} - - type: object - properties: {c: {type: string}} - description: BranchDoc - oneOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/B'} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - u, ok := typeByName(doc, "Distributed").(*ir.Union) - require.True(t, ok, "Distributed distributes into a union") - require.Len(t, u.Variants, 2) - - for i, v := range u.Variants { - variant, ok := doc.Types[v.Type.Target].(*ir.Model) - require.True(t, ok, "variant %d is a composed model", i) - entry, ok := variant.Unmodeled["openapi:allOf/1"] - require.True(t, ok, "variant %d carries the branch residue; got %+v", i, variant.Unmodeled) - assert.Contains(t, string(entry.Value), `"description":"BranchDoc"`, "variant %d", i) - } - assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "one branch, one diagnostic, however many variants carry it; got %+v", diags) -} - -// TestAllOf_RequiresIsTheSameInEitherComponentOrder pins that an allOf making -// an inherited property required, and naming one nothing declares, lands both -// names in Model.Requires whichever of the composed model and its base is -// declared first. The composed model has no property of its own to hold either -// name, and the names come from the composition's required lists alone, so -// nothing about the base's declaration may reach them. -func TestAllOf_RequiresIsTheSameInEitherComponentOrder(t *testing.T) { - t.Parallel() - const ( - user = " User: {type: object, properties: {id: {type: string}}}\n" - created = ` CreatedUser: - allOf: - - {$ref: '#/components/schemas/User'} - - {required: [id, extra]} -` - ) - nodes := make([]*ir.Model, 0, 2) - for _, src := range []string{user + created, created + user} { - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(src)) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := typeByName(doc, "CreatedUser").(*ir.Model) - require.True(t, ok, "CreatedUser is a model") - nodes = append(nodes, m) - } - - assert.Empty(t, cmp.Diff(nodes[0], nodes[1]), "declaration order must not change the composed model") - for i, m := range nodes { - assert.Equal(t, []string{"id", "extra"}, m.Requires, "order %d: in source order, the inherited name included", i) - assert.Empty(t, m.Properties, "order %d: neither name is declared here, so no property holds one", i) - require.NotNil(t, m.Base, "order %d", i) - assert.Equal(t, componentID("User"), m.Base.Target) - } -} - -// TestAllOf_ARequiredNameWrittenInSeveralListsIsListedOnce pins that a name an -// allOf writes in more than one required list, a branch's and the composed -// schema's own, is in Model.Requires once, where it is first written. Each list -// is free of repeats, so the validation reports nothing, and only the lowering -// that gathers them can list a name twice. -func TestAllOf_ARequiredNameWrittenInSeveralListsIsListedOnce(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` User: {type: object, properties: {id: {type: string}}} - CreatedUser: - allOf: - - {$ref: '#/components/schemas/User'} - - {required: [id, extra]} - - {required: [extra, other]} - required: [extra, id] -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, diags, "no list repeats a name, so nothing is reported") - - m, ok := typeByName(doc, "CreatedUser").(*ir.Model) - require.True(t, ok, "CreatedUser is a model") - assert.Equal(t, []string{"id", "extra", "other"}, m.Requires, "once each, in the order first written") -} - func TestAllOf_ExtraRefsBecomeMixins(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` A: @@ -1105,1018 +123,25 @@ func TestAllOf_UnresolvedRefBranch(t *testing.T) { require.NotNil(t, doc) assert.True(t, openapitest.HasDiag(diags, diag.UnresolvedRef)) } - -func TestAllOf_MultiRefWithUnresolvedDoesNotAnchor(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Base: - type: object - discriminator: {propertyName: t} - properties: {t: {type: string}} - Sub: - allOf: - - {$ref: '#/components/schemas/Ghost'} - - {$ref: '#/components/schemas/Base'} - - {type: object, properties: {a: {type: string}}} -`) - doc, diags := schema.LowerSpec(t, spec) - require.NotNil(t, doc) - sub := typeByName(doc, "Sub").(*ir.Model) - require.NotNil(t, sub.Base, "the discriminator-anchoring Base becomes base despite an unresolved sibling ref") - assert.Equal(t, "Sub", sub.DiscriminatorValue) - _ = diags -} - -// TestAllOf_BoolBranchSkippedInCompositionRequired covers an allOf branch that -// is a bare boolean schema (`true` here) rather than a $ref or an inline -// object, so b.GetSchema() returns nil for it. It pins that the bool branch -// contributes no required names, while the sibling object branch's property and -// required still lower normally. -func TestAllOf_BoolBranchSkippedInCompositionRequired(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Thing: - allOf: - - type: object - required: [id] - properties: - id: {type: integer} - - true -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Thing")].(*ir.Model) - require.True(t, ok, "Thing should be a model") - assert.Nil(t, m.Base, "a bare boolean branch is never a $ref, so no Base") - assert.Empty(t, m.Mixins) - - id, hasID := openapitest.PropsByWire(m.Properties)["id"] - require.True(t, hasID, "the object branch's own property still lowers despite the sibling bool branch") - assert.True(t, id.Required, "the object branch's own required still attaches to its own property") - assert.Empty(t, m.Requires, "the boolean branch contributes no required names, so nothing goes unattached") -} - -// TestAllOf_AnInadmissibleBroadValueIsKeptInEitherOrder pins that the broader -// branch of a narrowing, whichever comes first, never lands a default the -// narrower kind refuses on the merged field: it is kept verbatim at its own -// branch with one info, so the two orders give the same field and the same -// diagnostics. -func TestAllOf_AnInadmissibleBroadValueIsKeptInEitherOrder(t *testing.T) { - t.Parallel() - const broad = " p: {type: number, default: 1.5}\n" - const narrow = " p: {type: integer}\n" - branch := " - type: object\n properties:\n" - for _, tc := range []struct{ name, first, second, broadPtr string }{ - {"broad first", broad, narrow, "/components/schemas/Ranged/allOf/0/properties/p"}, - {"narrow first", narrow, broad, "/components/schemas/Ranged/allOf/1/properties/p"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Ranged:\n allOf:\n"+ - branch+tc.first+branch+tc.second)) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Ranged")].(*ir.Model) - require.True(t, ok, "Ranged should be a model") - require.Len(t, m.Properties, 1) - - p := m.Properties[0] - assert.Equal(t, ir.TypeID("t/prim/integer"), p.Type.Target) - assert.Nil(t, p.Default, "the refused default does not land") - require.Len(t, diags, 1, "one info and nothing else") - assert.Equal(t, ir.SeverityInfo, diags[0].Severity) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code) - assert.Equal(t, jsontext.Pointer(tc.broadPtr), diags[0].Provenance.Pointer) - entry, ok := p.Unmodeled["openapi:conflicting-redeclaration"+tc.broadPtr] - require.True(t, ok, "the broad branch is kept at its own pointer; got %v", p.Unmodeled) - assert.JSONEq(t, `{"type":"number","default":1.5}`, string(entry.Value)) - assert.Len(t, p.Unmodeled, 1) - }) - } -} - -// TestAllOf_ANullOnARefOverTheTopTypeIsKept folds two $refs to a scalar over -// the top type, one writing default: null. The top type admits null, a fact -// only the Base walk reaches, so the default lands and nothing is reported or -// kept aside, in either order. -func TestAllOf_ANullOnARefOverTheTopTypeIsKept(t *testing.T) { - t.Parallel() - const withNull = " p: {$ref: '#/components/schemas/Y', default: null}\n" - const plain = " p: {$ref: '#/components/schemas/Y'}\n" - branch := " - type: object\n properties:\n" - for _, tc := range []struct{ name, first, second string }{ - {"null first", withNull, plain}, - {"null second", plain, withNull}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" Y: {description: free}\n Held:\n allOf:\n"+ - branch+tc.first+branch+tc.second)) - assert.Empty(t, diags, "nothing is refused") - m, ok := doc.Types[componentID("Held")].(*ir.Model) - require.True(t, ok, "Held should be a model") - require.Len(t, m.Properties, 1) - p := m.Properties[0] - require.NotNil(t, p.Default, "the null default lands") - assert.Equal(t, ir.ValueNull, p.Default.Kind) - assert.Empty(t, p.Unmodeled) - }) - } -} - -// TestAllOf_NonBareNarrowingIsOrderInvariant pins that a redeclaration with a -// side that is not a bare primitive settles on its narrower side whichever -// branch is written first, with no conflict or degradation reported and -// nothing kept. -func TestAllOf_NonBareNarrowingIsOrderInvariant(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name, extra, narrow, broad string - kind ir.TypeKind - }{ - {"an enum over its kind", "", "{type: integer, enum: [1, 2]}", "{type: integer}", ir.KindEnum}, - {"a const over its kind", "", "{const: 1}", "{type: integer}", ir.KindLiteral}, - {"an untyped, described schema", "", "{type: integer}", "{description: the id}", ir.KindPrimitive}, - {"a list of a narrower kind", "", "{type: array, items: {type: integer, format: int32}}", - "{type: array, items: {type: integer}}", ir.KindList}, - {"a map of a narrower kind", "", "{type: object, additionalProperties: {type: integer, format: int32}}", - "{type: object, additionalProperties: {type: integer}}", ir.KindMap}, - {"an unknown format over its kind", "", "{type: string, format: hex-token}", "{type: string}", ir.KindScalar}, - {"a ref declared before the schema", " Id: {type: integer, format: int32}\n", - "{$ref: '#/components/schemas/Id'}", "{type: integer}", ir.KindScalar}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - types := make([]ir.TypeDef, 0, 2) - for _, order := range [][2]string{{tc.narrow, tc.broad}, {tc.broad, tc.narrow}} { - doc, diags := schema.LowerSpec(t, nonBareSpec(tc.extra, order[0], order[1])) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - p := m.Properties[0] - types = append(types, doc.Types[p.Type.Target]) - assert.Equal(t, tc.kind, doc.Types[p.Type.Target].Kind(), "the narrower declaration's type wins") - assert.Empty(t, p.Unmodeled, "nothing is kept") - for _, d := range diags { - assert.NotContains(t, []string{diag.ConflictingRedecl, diag.DegradedConstruct}, d.Code, d.Message) - } - } - assert.Empty(t, cmp.Diff(types[0], types[1], - cmpopts.IgnoreFields(ir.TypeCommon{}, "ID", "Name", "Provenance"), - cmpopts.IgnoreFields(ir.EnumMember{}, "ID")), - "the winner does not depend on branch order") - }) - } -} - -// TestAllOf_AnUnknownFormatWinsInEitherOrder pins GitHub #782: a format that -// hoists a Scalar over the bare kind narrows it whichever branch is first, and -// the format's own details land on the field rather than being parked. -func TestAllOf_AnUnknownFormatWinsInEitherOrder(t *testing.T) { - t.Parallel() - plain := "{type: string}" - formatted := "{type: string, format: hex-token, minLength: 8, default: x, description: the token}" - for i, order := range [][2]string{{plain, formatted}, {formatted, plain}} { - doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - p := m.Properties[0] - - assert.Equal(t, ir.KindScalar, doc.Types[p.Type.Target].Kind(), "the format's scalar types the field (order %d)", i) - require.NotNil(t, p.Default, "order %d", i) - assert.Equal(t, "x", p.Default.Str, "order %d", i) - require.NotNil(t, p.Constraints, "order %d", i) - require.NotNil(t, p.Constraints.MinLength, "order %d", i) - assert.Equal(t, int64(8), *p.Constraints.MinLength, "order %d", i) - sc, ok := doc.Types[p.Type.Target].(*ir.Scalar) - require.True(t, ok, "order %d", i) - assert.Equal(t, "the token", sc.Docs.Description, "the description stays on the scalar that wrote it (order %d)", i) - assert.Empty(t, p.Unmodeled, "nothing is parked (order %d)", i) - assert.Empty(t, diags, "order %d", i) - } -} - -// TestAllOf_ReconcileAccumulatesRicherDetailForAnUnknownFormat is -// TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder with a format that -// hoists, in the richer branch. -func TestAllOf_ReconcileAccumulatesRicherDetailForAnUnknownFormat(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Tokenish: - allOf: - - type: object - properties: - token: {type: string} - - type: object - required: [token] - properties: - token: - type: string - format: hex-token - description: the access token - default: none - minLength: 1 - deprecated: true - examples: [abc] - xml: {name: tok} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) - require.True(t, ok, "Tokenish should be a model") - require.Len(t, m.Properties, 1, "token reconciles to a single property") - - tok := m.Properties[0] - assert.Equal(t, ir.KindScalar, doc.Types[tok.Type.Target].Kind(), "the format's scalar types the field") - assert.True(t, tok.Required) - sc, ok := doc.Types[tok.Type.Target].(*ir.Scalar) - require.True(t, ok) - assert.Equal(t, "the access token", sc.Docs.Description, "the description stays on the scalar that wrote it") - assert.NotNil(t, sc.Deprecation, "so does the deprecation") - assert.Len(t, sc.Examples, 1, "and the examples") - require.NotNil(t, tok.Default) - require.NotNil(t, tok.Constraints) - require.NotNil(t, tok.Constraints.MinLength) - require.NotNil(t, sc.XML) - assert.Empty(t, tok.Unmodeled) -} - -// TestAllOf_TheTopTypeAndANullableKindMeetAsNullableInEitherOrder pins that `{}` -// against `type: [integer, "null"]` is a nullable integer whichever is first. -func TestAllOf_TheTopTypeAndANullableKindMeetAsNullableInEitherOrder(t *testing.T) { - t.Parallel() - top, nullable := "{}", `{type: [integer, "null"]}` - for i, order := range [][2]string{{top, nullable}, {nullable, top}} { - doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - p := m.Properties[0] - - assert.Equal(t, ir.TypeRef{Target: "t/prim/integer", Nullable: true}, p.Type, "order %d", i) - assert.Empty(t, p.Unmodeled, "order %d", i) - assert.Empty(t, diags, "order %d", i) - } -} - -// TestAllOf_ADefaultTheNarrowerNodeRefusesIsKeptInEitherOrder pins that the -// broader declaration's default is judged against a winner that is not a bare -// primitive: one the enum does not admit is not adopted, and the declaration -// that wrote it is kept at its own pointer, whichever branch it is. -func TestAllOf_ADefaultTheNarrowerNodeRefusesIsKeptInEitherOrder(t *testing.T) { - t.Parallel() - enum, broad := "{type: integer, enum: [1, 2]}", "{type: integer, default: 5}" - for i, tc := range []struct { - order [2]string - kept string - }{ - {[2]string{enum, broad}, "/components/schemas/T/allOf/1/properties/v"}, - {[2]string{broad, enum}, "/components/schemas/T/allOf/0/properties/v"}, - } { - doc, diags := schema.LowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - p := m.Properties[0] - - assert.Equal(t, ir.KindEnum, doc.Types[p.Type.Target].Kind(), "the enum wins (order %d)", i) - assert.Nil(t, p.Default, "the refused default is not adopted (order %d)", i) - require.Len(t, p.Unmodeled, 1, "order %d", i) - assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration"+tc.kept, "order %d", i) - require.Len(t, diags, 1, "order %d", i) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) - assert.Equal(t, ir.SeverityInfo, diags[0].Severity, "order %d", i) - } -} - -// TestAllOf_AWinnersConstraintsRefuseADefaultInEitherOrder pins that the -// winning node holds the broader declaration's default to its constraints as -// well as its kind: a string past a component's maxLength, or a list past its -// maxItems, is not adopted, and the declaration that wrote it is kept at its -// own pointer, whichever branch it is and wherever the component is declared. -func TestAllOf_AWinnersConstraintsRefuseADefaultInEitherOrder(t *testing.T) { - t.Parallel() - named := "{$ref: '#/components/schemas/C'}" - lowerings := map[string]func(extra, a, b string) (*ir.Document, []ir.Diagnostic){ - "declared before": func(extra, a, b string) (*ir.Document, []ir.Diagnostic) { - return schema.LowerSpec(t, nonBareSpec(extra, a, b)) - }, - "declared after": func(extra, a, b string) (*ir.Document, []ir.Diagnostic) { return settledSpec(t, extra, a, b) }, - } - for _, tc := range []struct{ name, component, broad string }{ - {"a string past maxLength", " C: {type: string, maxLength: 3}\n", "{type: string, default: hello}"}, - {"a list past maxItems", " C: {type: array, items: {type: integer, format: int32}, maxItems: 2}\n", - "{type: array, items: {type: integer}, default: [1, 2, 3, 4]}"}, - } { - for where, lower := range lowerings { - for _, order := range []struct{ a, b, kept string }{ - {named, tc.broad, "/components/schemas/T/allOf/1/properties/v"}, - {tc.broad, named, "/components/schemas/T/allOf/0/properties/v"}, - } { - doc, diags := lower(tc.component, order.a, order.b) - at := tc.name + ", " + where + ", kept " + order.kept - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, at) - require.Len(t, m.Properties, 1, at) - p := m.Properties[0] - - assert.Equal(t, componentID("C"), p.Type.Target, "the component wins: %s", at) - assert.Nil(t, p.Default, "the refused default is not adopted: %s", at) - require.Len(t, p.Unmodeled, 1, at) - assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration"+order.kept, at) - require.Len(t, diags, 1, at) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code, at) - assert.Equal(t, jsontext.Pointer(order.kept), diags[0].Provenance.Pointer, at) - } - } - } -} - -// TestAllOf_TwoEqualListsRefuseANullTheMergeDoesNotAdmit pins that two lists of -// one element, which are two targets, settle on a non-nullable list, so a null -// default the nullable declaration wrote is not adopted and the declaration is -// kept at its own pointer, whichever branch it is. -func TestAllOf_TwoEqualListsRefuseANullTheMergeDoesNotAdmit(t *testing.T) { - t.Parallel() - nullable := `{type: [array, "null"], items: {type: integer}, default: null}` - plain := "{type: array, items: {type: integer}}" - for i, tc := range []struct { - order [2]string - kept string - }{ - {[2]string{nullable, plain}, "/components/schemas/T/allOf/0/properties/v"}, - {[2]string{plain, nullable}, "/components/schemas/T/allOf/1/properties/v"}, - } { - doc, diags := schema.LowerSpec(t, nonBareSpec("", tc.order[0], tc.order[1])) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - p := m.Properties[0] - - require.Equal(t, ir.KindList, doc.Types[p.Type.Target].Kind(), "order %d", i) - assert.False(t, p.Type.Nullable, "order %d", i) - assert.Nil(t, p.Default, "the refused null is not adopted (order %d)", i) - require.Len(t, p.Unmodeled, 1, "order %d", i) - assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration"+tc.kept, "order %d", i) - require.Len(t, diags, 1, "order %d", i) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) - assert.Equal(t, ir.SeverityInfo, diags[0].Severity, "order %d", i) - assert.Equal(t, jsontext.Pointer(tc.kept), diags[0].Provenance.Pointer, "order %d", i) - } -} - -// TestAllOf_IdenticalNodesAreOneType compiles a field written twice with the -// same enum, const or format, in either order. Two inline copies are one type, -// and a component wins over an inline copy of itself: no diagnostic and nothing -// kept. Two components declared alike are two types the source named apart: an -// info, with the first kept and the second's $ref kept verbatim. -func TestAllOf_IdenticalNodesAreOneType(t *testing.T) { - t.Parallel() - for _, body := range []string{ - "{type: string, enum: [a, b]}", - "{const: a}", - "{type: string, format: hex}", - "{type: string, enum: [a, b], x-foo: 1}", - "{type: array, items: {type: integer, format: int32}}", - "{type: object, additionalProperties: {type: integer}}", - } { - t.Run(body, func(t *testing.T) { - t.Parallel() - extra := " P: " + body + "\n Q: " + body + "\n" - p, q := "{$ref: '#/components/schemas/P'}", "{$ref: '#/components/schemas/Q'}" - for i, tc := range []struct { - order [2]string - win ir.TypeID // the type that stands - same bool - }{ - {[2]string{body, body}, "t/anon/components/schemas/T/allOf/0/properties/v", true}, - {[2]string{p, body}, componentID("P"), true}, - {[2]string{body, p}, componentID("P"), true}, - {[2]string{p, q}, componentID("P"), false}, - {[2]string{q, p}, componentID("Q"), false}, - } { - doc, diags := schema.LowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, "order %d", i) - require.Len(t, m.Properties, 1, "order %d", i) - prop := m.Properties[0] - assert.Equal(t, tc.win, prop.Type.Target, "order %d", i) - if tc.same { - assert.Empty(t, diags, "order %d", i) - assert.Empty(t, prop.Unmodeled, "order %d", i) - } else { - require.Len(t, diags, 1, "order %d", i) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) - assert.Len(t, prop.Unmodeled, 1, "the second $ref is kept (order %d)", i) - } - for _, name := range []string{"P", "Q"} { - td, ok := doc.Types[componentID(name)] - require.True(t, ok, "%s (order %d)", name, i) - for key, e := range td.Common().Unmodeled { - assert.True(t, strings.HasPrefix(string(e.Provenance.Pointer), "/components/schemas/"+name+"/"), - "%s keeps where its %s came from, got %q (order %d)", name, key, e.Provenance.Pointer, i) - } - } - } - }) - } -} - -// TestAllOf_ARefToAnInlineNodeIsDeclaredApart compiles a field redeclared as -// a $ref to a node written inline elsewhere (X/properties/c), in either order. -// That node was declared apart from the field, as a component is: it wins over -// an inline copy of itself, and against a $ref to its twin (X/properties/d) the -// pair is an info, with the first kept and the second's $ref kept verbatim. -func TestAllOf_ARefToAnInlineNodeIsDeclaredApart(t *testing.T) { - t.Parallel() - const enum = "{type: string, enum: [a, b]}" - extra := " X: {type: object, properties: {c: " + enum + ", d: " + enum + "}}\n" - c, d := "{$ref: '#/components/schemas/X/properties/c'}", "{$ref: '#/components/schemas/X/properties/d'}" - at := func(p string) ir.TypeID { return ir.TypeID("t/anon/components/schemas/X/properties/" + p) } - for i, tc := range []struct { - order [2]string - win ir.TypeID - same bool - }{ - {[2]string{c, enum}, at("c"), true}, - {[2]string{enum, c}, at("c"), true}, - {[2]string{c, d}, at("c"), false}, - {[2]string{d, c}, at("d"), false}, - } { - doc, diags := schema.LowerSpec(t, nonBareSpec(extra, tc.order[0], tc.order[1])) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, "order %d", i) - require.Len(t, m.Properties, 1, "order %d", i) - prop := m.Properties[0] - assert.Equal(t, tc.win, prop.Type.Target, "order %d", i) - if tc.same { - assert.Empty(t, diags, "order %d", i) - assert.Empty(t, prop.Unmodeled, "order %d", i) - continue - } - require.Len(t, diags, 1, "order %d", i) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code, "order %d", i) - assert.Len(t, prop.Unmodeled, 1, "the second $ref is kept (order %d)", i) - } -} - -// TestAllOf_ACopyANarrowingKeptIsStillACopy compiles a field declared as {}, -// an inline enum and a $ref to a component of the same enum, in every order, -// with the component declared before the composing schema and after it. -// Where {} comes first, the inline enum narrows it and the property holds a node -// written at the second declaration, not the first; it is still an inline copy, -// so the component wins in all six orders with nothing reported or kept. -func TestAllOf_ACopyANarrowingKeptIsStillACopy(t *testing.T) { - t.Parallel() - const enum = "{type: string, enum: [a, b]}" - decls := []string{"{}", enum, "{$ref: '#/components/schemas/Color'}"} - for _, colorLast := range []bool{false, true} { - for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} { - var branches strings.Builder - for _, i := range order { - branches.WriteString(" - type: object\n properties: {v: " + decls[i] + "}\n") - } - color, composed := " Color: "+enum+"\n", " T:\n allOf:\n"+branches.String() - schemas := color + composed - if colorLast { - schemas = composed + color - } - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(schemas)) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, "order %v, Color last %t", order, colorLast) - require.Len(t, m.Properties, 1, "order %v, Color last %t", order, colorLast) - assert.Equal(t, componentID("Color"), m.Properties[0].Type.Target, "order %v, Color last %t", order, colorLast) - assert.Empty(t, diags, "order %v, Color last %t", order, colorLast) - assert.Empty(t, m.Properties[0].Unmodeled, "order %v, Color last %t", order, colorLast) - } - } -} - -// TestAllOf_AListOfAComponentWinsOverAListOfItsCopy compiles a field redeclared -// as a list of a component and as a list of an inline copy of it, in either -// order. The copy is written under the field's own declaration, so the list -// holding the component wins in both orders with nothing reported or kept. -func TestAllOf_AListOfAComponentWinsOverAListOfItsCopy(t *testing.T) { - t.Parallel() - const enum = "{type: string, enum: [a, b]}" - ofRef, ofCopy := "{type: array, items: {$ref: '#/components/schemas/Color'}}", "{type: array, items: "+enum+"}" - for i, order := range [][2]string{{ofRef, ofCopy}, {ofCopy, ofRef}} { - doc, diags := schema.LowerSpec(t, nonBareSpec(" Color: "+enum+"\n", order[0], order[1])) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, "order %d", i) - require.Len(t, m.Properties, 1, "order %d", i) - list, ok := doc.Types[m.Properties[0].Type.Target].(*ir.List) - require.True(t, ok, "the field is a list (order %d)", i) - assert.Equal(t, componentID("Color"), list.Elem.Target, "order %d", i) - assert.Empty(t, diags, "order %d", i) - assert.Empty(t, m.Properties[0].Unmodeled, "order %d", i) - } -} - -// TestAllOf_ARefToASiblingFieldIsDeclaredApart compiles a field v redeclared as -// a $ref to its branch's sibling field vx and as an inline copy of vx, in either -// order. vx's pointer begins with v's but is not under it, so vx was declared -// apart and wins in both orders with nothing reported or kept. -func TestAllOf_ARefToASiblingFieldIsDeclaredApart(t *testing.T) { - t.Parallel() - const enum = "{type: string, enum: [a, b]}" - for i := range 2 { - vx := fmt.Sprintf("/components/schemas/T/allOf/%d/properties/vx", i) - branches := [2]string{ - " - type: object\n properties: {vx: " + enum + ", v: {$ref: '#" + vx + "'}}\n", - " - type: object\n properties: {v: " + enum + "}\n", - } - if i == 1 { - branches[0], branches[1] = branches[1], branches[0] - } - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(" T:\n allOf:\n"+branches[0]+branches[1])) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, "order %d", i) - var v *ir.Property - for k := range m.Properties { - if m.Properties[k].WireName == "v" { - v = &m.Properties[k] - } - } - require.NotNil(t, v, "order %d", i) - assert.Equal(t, ir.TypeID("t/anon"+vx), v.Type.Target, "order %d", i) - assert.Empty(t, diags, "order %d", i) - assert.Empty(t, v.Unmodeled, "order %d", i) - } -} - -// TestAllOf_ARedeclarationIsJudgedOnceTheComponentsHaveLowered pins that a -// field redeclared as two $refs to components declared after the composing -// schema is judged as it is when they are declared before it: two components -// declared alike, or a pair the merge does not narrow, are reported and kept. It -// compiles three arms: the components declared before the schema, and after it -// in either order of the two (#898). -func TestAllOf_ARedeclarationIsJudgedOnceTheComponentsHaveLowered(t *testing.T) { - t.Parallel() - p, q := "{$ref: '#/components/schemas/P'}", "{$ref: '#/components/schemas/Q'}" - for _, tc := range []struct { - name, pdecl, qdecl string - items bool - kind ir.TypeKind - code string // the one diagnostic, or "" for none - }{ - {"enums declared alike", "{type: string, enum: [a, b]}", "{type: string, enum: [a, b]}", false, ir.KindEnum, diag.DegradedConstruct}, - {"an integer beside a number", "{type: number}", "{type: integer}", false, ir.KindScalar, diag.DegradedConstruct}, - {"a string against an integer", "{type: string}", "{type: integer}", false, ir.KindScalar, diag.ConflictingRedecl}, - {"enums declared alike as list items", "{type: string, enum: [a, b]}", "{type: string, enum: [a, b]}", true, ir.KindList, diag.DegradedConstruct}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - a, b := p, q - if tc.items { - a, b = "{type: array, items: "+p+"}", "{type: array, items: "+q+"}" - } - pq := " P: " + tc.pdecl + "\n Q: " + tc.qdecl + "\n" - qp := " Q: " + tc.qdecl + "\n P: " + tc.pdecl + "\n" - for i, lower := range []func() (*ir.Document, []ir.Diagnostic){ - func() (*ir.Document, []ir.Diagnostic) { return schema.LowerSpec(t, nonBareSpec(pq, a, b)) }, - func() (*ir.Document, []ir.Diagnostic) { return settledSpec(t, pq, a, b) }, - func() (*ir.Document, []ir.Diagnostic) { return settledSpec(t, qp, a, b) }, - } { - doc, diags := lower() - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok, "order %d", i) - require.Len(t, m.Properties, 1, "order %d", i) - prop := m.Properties[0] - - assert.Equal(t, tc.kind, doc.Types[prop.Type.Target].Kind(), "order %d", i) - if tc.code == "" { - assert.Empty(t, diags, "order %d", i) - assert.Empty(t, prop.Unmodeled, "order %d", i) - continue - } - require.Len(t, diags, 1, "order %d", i) - assert.Equal(t, tc.code, diags[0].Code, "order %d", i) - assert.Len(t, prop.Unmodeled, 1, "the losing declaration is kept (order %d)", i) - } - }) - } -} - -// TestAllOf_WhatAFieldKeepsOfItsDetailIsOrderFree compiles a field whose two -// declarations give an extension differently, alike in another, one promoting -// a deprecation from an extension, and one default spelled two ways. In either -// branch order the field keeps the first value of the extension and reports -// the other, names the heuristic behind its deprecation, and takes the default -// as the one value it is. -func TestAllOf_WhatAFieldKeepsOfItsDetailIsOrderFree(t *testing.T) { - t.Parallel() - a := "{type: number, default: 1, x-foo: 1, x-same: s}" - b := "{type: number, default: 1.0, x-foo: 2, x-same: s, deprecated: true, x-deprecated-reason: use q}" - for _, order := range [][2]string{{a, b}, {b, a}} { - doc, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) - m, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - p := m.Properties[0] - first := "1" - if order[0] == b { - first = "2" - } - - require.Contains(t, p.Unmodeled, "openapi:x-foo", "order %v", order) - assert.Equal(t, ir.RawValue(first), p.Unmodeled["openapi:x-foo"].Value, "the first value stands") - assert.Contains(t, p.Unmodeled, "openapi:x-same") - assert.Contains(t, p.Unmodeled, "openapi:conflicting-redeclaration/components/schemas/T/allOf/1/properties/v", - "the other declaration is kept verbatim") - require.NotNil(t, p.Deprecation) - assert.Equal(t, "use q", p.Deprecation.Message) - assert.Equal(t, "extension-promotion", p.Provenance.Inferred, "the promoted deprecation stays marked") - require.NotNil(t, p.Default) - require.Len(t, diags, 1, "only the extension given differently is reported: %+v", diags) - assert.Contains(t, diags[0].Message, "openapi:x-foo differently") - } -} - -// TestAllOf_ContainerMembersAreJudgedAsFieldsAre pins that two lists' members -// get the verdict two fields of those types get. Enums sharing no value, one -// list never empty, are a conflict; the same lists both admitting the empty one -// overlap in it. Members judged only as a fallback for top-level fields were -// once left unrelated, which the merge reported as a pair it does not compare. -func TestAllOf_ContainerMembersAreJudgedAsFieldsAre(t *testing.T) { - t.Parallel() - str, num := "{type: array, items: {type: string, enum: [a]}%s}", "{type: array, items: {type: integer, enum: [1]}}" - for _, tc := range []struct { - name, min string - code string - sev ir.Severity - }{ - {"one list never empty", ", minItems: 1", diag.ConflictingRedecl, ir.SeverityWarning}, - {"both lists admitting the empty one", "", diag.DegradedConstruct, ir.SeverityInfo}, - } { - for _, order := range [][2]string{{fmt.Sprintf(str, tc.min), num}, {num, fmt.Sprintf(str, tc.min)}} { - _, diags := schema.LowerSpec(t, nonBareSpec("", order[0], order[1])) - require.Len(t, diags, 1, "%s, order %v: %+v", tc.name, order, diags) - assert.Equal(t, tc.code, diags[0].Code, "%s, order %v", tc.name, order) - assert.Equal(t, tc.sev, diags[0].Severity, "%s, order %v", tc.name, order) - if tc.code == diag.DegradedConstruct { - assert.Contains(t, diags[0].Message, "whose values overlap", "%s, order %v", tc.name, order) - } - } - } -} - -// TestAllOf_NestedInlineWrapperFlattens pins the flattening of -// docs/compilers/openapi.md#allof-classification: a nested spelling classifies -// like the flat one, with every property keeping the pointer it was written at -// (GitHub #599). -func TestAllOf_NestedInlineWrapperFlattens(t *testing.T) { - t.Parallel() - tests := []struct { - name string - m string - base ir.TypeID - mixins []ir.TypeID - props map[string]string // wire name -> provenance pointer - }{ - { - name: "the issue: a wrapper around two refs", - m: ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/B'} -`, - mixins: []ir.TypeID{componentID("A"), componentID("B")}, - }, - { - name: "two levels of wrapper", - m: ` M: - allOf: - - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/B'} -`, - mixins: []ir.TypeID{componentID("A"), componentID("B")}, - }, - { - name: "a ref with an inline branch beside it carries the nested pointer", - m: ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - type: object - properties: {c: {type: string}} -`, - base: componentID("A"), - props: map[string]string{"c": "/components/schemas/M/allOf/0/allOf/1/properties/c"}, - }, - { - name: "a wrapper with its own properties and type object", - m: ` M: - allOf: - - type: object - properties: {w: {type: string}} - allOf: - - {$ref: '#/components/schemas/A'} -`, - base: componentID("A"), - props: map[string]string{"w": "/components/schemas/M/allOf/0/properties/w"}, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - for _, doc := range nestedOrders(t, tc.m, nestedAB) { - m, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - if tc.base == "" { - assert.Nil(t, m.Base) - } else if assert.NotNil(t, m.Base) { - assert.Equal(t, tc.base, m.Base.Target) - } - assert.Equal(t, append([]ir.TypeID{}, tc.mixins...), refTargets(m.Mixins)) - assert.Empty(t, m.Unmodeled, "a flattened wrapper is not kept verbatim") - assert.False(t, slices.ContainsFunc(doc.Diagnostics, func(d ir.Diagnostic) bool { - return strings.HasPrefix(string(d.Provenance.Pointer), "/components/schemas/M") - }), "flattening is exact, so nothing is reported under M: %+v", doc.Diagnostics) - got := map[string]string{} - for _, p := range m.Properties { - got[p.WireName] = string(p.Provenance.Pointer) - } - want := tc.props - if want == nil { - want = map[string]string{} - } - assert.Equal(t, want, got) - } - }) - } -} - -// TestAllOf_NestedWrapperRequiredAttaches pins that a required list inside a -// wrapper is read like one at the top: it attaches to a property a flattened -// branch declares, and a name none declares is listed in Model.Requires. -func TestAllOf_NestedWrapperRequiredAttaches(t *testing.T) { - t.Parallel() - t.Run("wrapper required and nested-nested required attach", func(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - required: [x] - allOf: - - type: object - properties: {x: {type: integer}, y: {type: integer}} - - allOf: - - required: [y] -` - for _, doc := range nestedOrders(t, m, nestedAB) { - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - props := openapitest.PropsByWire(model.Properties) - assert.True(t, props["x"].Required, "the wrapper's required reaches the property it names") - assert.True(t, props["y"].Required, "so does a required two wrappers deep") - assert.Empty(t, model.Requires, "every name found its property") - } - }) - t.Run("a name no own property declares is listed in Requires", func(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - required: [ghost] - allOf: - - {$ref: '#/components/schemas/A'} -` - for _, doc := range nestedOrders(t, m, nestedAB) { - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - assert.Equal(t, []string{"ghost"}, model.Requires, "the wrapper's required list is read") - } - }) -} - -// TestAllOf_NestedWrapperRequiredAttachesBesideUnion pins that a required two -// wrappers deep reaches its property in every distributed variant. -func TestAllOf_NestedWrapperRequiredAttachesBesideUnion(t *testing.T) { - t.Parallel() - u := ` U: - oneOf: [{$ref: '#/components/schemas/P'}, {$ref: '#/components/schemas/Q'}] - allOf: - - allOf: - - type: object - properties: {x: {type: string}, y: {type: string}} - - allOf: - - required: [y] -` - const pq = ` P: {type: object, properties: {p: {type: string}}} - Q: {type: object, properties: {q: {type: string}}} -` - for _, doc := range nestedOrders(t, u, nestedAB+pq) { - for _, id := range []ir.TypeID{"t/composed/components/schemas/U/oneOf/0", "t/composed/components/schemas/U/oneOf/1"} { - model, ok := doc.Types[id].(*ir.Model) - require.True(t, ok, "%s should be a model", id) - assert.True(t, openapitest.PropsByWire(model.Properties)["y"].Required, id) - } - } -} - -// TestAllOf_NestedWrapperBaseSelection pins that the classification of -// docs/compilers/openapi.md#allof-classification runs over the flattened list: -// a discriminated ref inside a wrapper is the Base, whichever nested order it -// is written in, and still answers from its mapping. -func TestAllOf_NestedWrapperBaseSelection(t *testing.T) { - t.Parallel() - const pet = ` Pet: - type: object - required: [kind] - properties: {kind: {type: string}} - discriminator: - propertyName: kind - mapping: {dog: '#/components/schemas/Dog'} - Named: {type: object, properties: {name: {type: string}}} -` - tests := []struct { - name string - dog string - }{ - {"pet first inside the wrapper", ` Dog: - allOf: - - allOf: - - {$ref: '#/components/schemas/Pet'} - - {$ref: '#/components/schemas/Named'} -`}, - {"pet last inside the wrapper", ` Dog: - allOf: - - allOf: - - {$ref: '#/components/schemas/Named'} - - {$ref: '#/components/schemas/Pet'} -`}, - {"pet inside a wrapper beside a top-level ref", ` Dog: - allOf: - - {$ref: '#/components/schemas/Named'} - - allOf: - - {$ref: '#/components/schemas/Pet'} -`}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - for _, doc := range nestedOrders(t, tc.dog, pet) { - dog, ok := typeByName(doc, "Dog").(*ir.Model) - require.True(t, ok, "Dog should be a model") - require.NotNil(t, dog.Base) - assert.Equal(t, componentID("Pet"), dog.Base.Target) - assert.Equal(t, []ir.TypeID{componentID("Named")}, refTargets(dog.Mixins)) - assert.Equal(t, "dog", dog.DiscriminatorValue, "the mapping key, not the schema name") - } - }) - } -} - -// TestAllOf_NestedFalseBranchClosesModel pins that a `false` inside a wrapper -// still closes the composition, and is kept at the pointer it was written at. -func TestAllOf_NestedFalseBranchClosesModel(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - allOf: [false] -` - for _, doc := range nestedOrders(t, m, nestedAB) { - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - assert.Equal(t, ir.AdditionalClosed, model.Additional) - entry, ok := model.Unmodeled["openapi:allOf/0/allOf/0"] - require.True(t, ok, "the false is kept under its nested key; got %+v", model.Unmodeled) - assert.Equal(t, "false", string(entry.Value)) - assert.Equal(t, jsontext.Pointer("/components/schemas/M/allOf/0/allOf/0"), entry.Provenance.Pointer) - assert.NotContains(t, model.Unmodeled, "openapi:allOf/0", "the wrapper itself is not kept") - assert.True(t, openapitest.HasDiagCodeAt(doc.Diagnostics, diag.FalseSchema, - "/components/schemas/M/allOf/0/allOf/0")) - } -} - -// TestAllOf_NestedWrapperWithResidueKeptVerbatim pins that a wrapper writing -// a keyword the merge does not consume is still flattened: its entries and -// properties compose as the flat spelling's do, the whole wrapper is kept under -// its key, and the diagnostic names the extra keyword and not allOf. -func TestAllOf_NestedWrapperWithResidueKeptVerbatim(t *testing.T) { - t.Parallel() - tests := []struct { - name string - sibling string - residue string - }{ - {"description", "description: Doc", "description"}, - {"title", "title: Grouped", "title"}, - {"vendor extension", "x-vendor: keepme", "x-vendor"}, - {"readOnly", "readOnly: true", "readOnly"}, - {"discriminator", "discriminator: {propertyName: kind}", "discriminator"}, - {"oneOf", "oneOf: [{type: string}]", "oneOf"}, - {"additionalProperties false", "additionalProperties: false", "additionalProperties"}, - {"type set with null", `type: [object, "null"]`, "type"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - ` + tc.sibling + ` - properties: {w: {type: string}} -` - for _, doc := range nestedOrders(t, m, nestedAB) { - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - require.NotNil(t, model.Base, "the wrapper's ref is classified as in the flat spelling") - assert.Equal(t, componentID("A"), model.Base.Target) - assert.Empty(t, model.Mixins) - _, hasW := openapitest.PropsByWire(model.Properties)["w"] - assert.True(t, hasW, "the wrapper's properties still merge") - entry, ok := model.Unmodeled["openapi:allOf/0"] - require.True(t, ok, "the wrapper is kept verbatim; got %+v", model.Unmodeled) - assert.Equal(t, jsontext.Pointer("/components/schemas/M/allOf/0"), entry.Provenance.Pointer) - assert.Contains(t, string(entry.Value), `"allOf"`, "the whole wrapper is kept") - var infos int - for _, d := range doc.Diagnostics { - if d.Code == diag.DegradedConstruct && d.Provenance.Pointer == "/components/schemas/M/allOf/0" { - infos++ - } - } - assert.Equal(t, 1, infos, "exactly one degraded-construct at the wrapper") - msg := openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, - ir.SeverityInfo, "/components/schemas/M/allOf/0") - assert.Contains(t, msg, "("+tc.residue+")") - assert.Contains(t, msg, "nested allOf compose") - assert.NotContains(t, msg, "(allOf") - } - }) - } -} - -// TestAllOf_KeptEntryOfAKeptWrapperIsKeptTwice pins the trade-off -// docs/compilers/openapi.md#allof-inline-residue states: a wrapper kept for -// its residue is kept whole, its entries included, so an entry kept itself, or -// a false one, is in Unmodeled twice, inside the wrapper's copy and under its -// own path. -func TestAllOf_KeptEntryOfAKeptWrapperIsKeptTwice(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - description: grouped - allOf: - - {$ref: '#/components/schemas/A'} - - {title: inner, properties: {p: {type: string}}} - - false -` - for _, doc := range nestedOrders(t, m, nestedAB) { - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - assert.Equal(t, []string{"openapi:allOf/0", "openapi:allOf/0/allOf/1", "openapi:allOf/0/allOf/2"}, - slices.Sorted(maps.Keys(model.Unmodeled)), "the wrapper and each entry kept itself, each under its own path") - wrapper := string(model.Unmodeled["openapi:allOf/0"].Value) - assert.Contains(t, wrapper, `"title":"inner"`, "the wrapper's copy holds the kept entry") - assert.Contains(t, wrapper, `,false]`, "and the false one") - assert.Contains(t, string(model.Unmodeled["openapi:allOf/0/allOf/1"].Value), `"title":"inner"`) - assert.Equal(t, "false", string(model.Unmodeled["openapi:allOf/0/allOf/2"].Value)) - } -} - -// TestAllOf_NestedEmptyAllOfKeptVerbatim pins that a branch wrapping an empty -// allOf is not flattened: it is kept verbatim and reported, never dropped. The -// source breaks the schema's minItems, so the one error the load reports for it -// is expected and nothing else. -func TestAllOf_NestedEmptyAllOfKeptVerbatim(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - allOf: [] - - {$ref: '#/components/schemas/A'} -` - docs := make([]*ir.Document, 0, 2) - for _, src := range []string{m + nestedAB, nestedAB + m} { - doc, diags := parseFull(t, openapitest.ComponentSpec(src)) - docs = append(docs, doc) - errs := slices.DeleteFunc(slices.Clone(diags), func(d ir.Diagnostic) bool { - return d.Severity != ir.SeverityError - }) - require.Len(t, errs, 1, "only the minItems finding is an error") - assert.Equal(t, jsontext.Pointer("/components/schemas/M/allOf/0/allOf"), errs[0].Provenance.Pointer) - - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - require.NotNil(t, model.Base, "the sibling $ref is the sole classified ref") - assert.Equal(t, componentID("A"), model.Base.Target) - assert.Empty(t, model.Mixins) - entry, ok := model.Unmodeled["openapi:allOf/0"] - require.True(t, ok, "the empty wrapper is kept verbatim; got %+v", model.Unmodeled) - assert.JSONEq(t, `{"allOf":[]}`, string(entry.Value)) - assert.Contains(t, - openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, - ir.SeverityInfo, "/components/schemas/M/allOf/0"), - "only its properties and required list compose; the branch (allOf) is kept verbatim") - } - assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...), - "declaration order must not change the types") + +func TestAllOf_MultiRefWithUnresolvedDoesNotAnchor(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Base: + type: object + discriminator: {propertyName: t} + properties: {t: {type: string}} + Sub: + allOf: + - {$ref: '#/components/schemas/Ghost'} + - {$ref: '#/components/schemas/Base'} + - {type: object, properties: {a: {type: string}}} +`) + doc, diags := schema.LowerSpec(t, spec) + require.NotNil(t, doc) + sub := typeByName(doc, "Sub").(*ir.Model) + require.NotNil(t, sub.Base, "the discriminator-anchoring Base becomes base despite an unresolved sibling ref") + assert.Equal(t, "Sub", sub.DiscriminatorValue) + _ = diags } // TestAllOf_EmptyAllOfBesideNonObjectTypeNamesBoth pins that a branch whose @@ -2135,71 +160,6 @@ func TestAllOf_EmptyAllOfBesideNonObjectTypeNamesBoth(t *testing.T) { assert.Contains(t, msg, "the branch (type, allOf) is kept verbatim") } -// TestAllOf_NestedInlineBranchKeyedByPointer pins that an inline branch inside -// a wrapper is kept under a key spelling its path, so it can collide with -// neither a sibling nor a top-level branch of the same index. -func TestAllOf_NestedInlineBranchKeyedByPointer(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - type: object - properties: {a: {type: string}} - description: Top - - allOf: - - type: object - properties: {b: {type: string}} - description: First - - type: object - properties: {c: {type: string}} - minProperties: 1 -` - for _, doc := range nestedOrders(t, m, nestedAB) { - model, ok := typeByName(doc, "M").(*ir.Model) - require.True(t, ok, "M should be a model") - require.Len(t, model.Unmodeled, 3, "got %+v", model.Unmodeled) - for key, want := range map[string]string{ - "openapi:allOf/0": `"description":"Top"`, - "openapi:allOf/1/allOf/0": `"description":"First"`, - "openapi:allOf/1/allOf/1": `"minProperties":1`, - } { - entry, ok := model.Unmodeled[key] - require.True(t, ok, "missing %s in %+v", key, model.Unmodeled) - assert.Contains(t, string(entry.Value), want) - assert.Equal(t, "/components/schemas/M/"+strings.TrimPrefix(key, "openapi:"), - string(entry.Provenance.Pointer)) - assert.Contains(t, - openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, - "/components/schemas/M/"+strings.TrimPrefix(key, "openapi:")), "inline allOf branch") - } - } -} - -// TestAllOf_NestedBranchIsNamedByItsInnermostIndex pins that a $ref branch -// inside a wrapper is named by its innermost index, in either declaration -// order: an outside $ref to that pointer lowers it from the other side. The -// target is the empty-named component because a branch holding a $ref takes -// its target's name first, so only a target that names nothing lets the index -// reach the IR. The B before the wrapper puts the branch at flat position 2 but -// innermost index 1, so only the innermost index yields variant_1. -func TestAllOf_NestedBranchIsNamedByItsInnermostIndex(t *testing.T) { - t.Parallel() - m := ` M: - allOf: - - {$ref: '#/components/schemas/B'} - - allOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/', description: Narrowed} -` - outsider := ` Outsider: {$ref: '#/components/schemas/M/allOf/1/allOf/1'} -` - unnamed := " \"\": {type: object, properties: {e: {type: string}}}\n" - for _, doc := range nestedOrders(t, m, outsider+unnamed+nestedAB) { - narrowed, ok := doc.Types["t/anon/components/schemas/M/allOf/1/allOf/1"] - require.True(t, ok, "the narrowed branch is hoisted at its pointer") - assert.Equal(t, "variant_1", narrowed.Common().Name.Hint) - } -} - // TestAllOf_FalseBranchBesideUnionClosesEveryVariant pins that a `false` allOf // branch, flat or inside a wrapper, closes every variant of a schema that also // declares a oneOf, and is kept and reported once for the document. @@ -2249,490 +209,6 @@ func TestAllOf_FalseBranchBesideUnionClosesEveryVariant(t *testing.T) { } } -// TestObject_MapRedeclaredAsAModelIsNoConflict pins that an allOf branch -// declaring a property as a map and another declaring it as a model is no -// conflicting redeclaration: both are objects, and one instance can satisfy -// both. Comparing node kinds alone reported it, and only in the order where -// the model was built first, so the spec runs in both orders. -func TestObject_MapRedeclaredAsAModelIsNoConflict(t *testing.T) { - t.Parallel() - const ( - a = " A: {type: object, properties: {b: {$ref: '#/components/schemas/B'}}}\n" - b = " B:\n allOf:\n - properties: {m: {type: object, additionalProperties: {type: string}}}\n - properties: {m: {$ref: '#/components/schemas/A'}}\n" - inline = " C:\n allOf:\n - properties: {meta: {type: object, additionalProperties: {type: string}}}\n - properties: {meta: {type: object, properties: {a: {type: string}}}}\n" - ) - for _, src := range []string{a + b + inline, inline + b + a} { - _, diags := parseFull(t, openapitest.ComponentSpec(src)) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, conflictDiags(diags), "a map and a model can describe one object") - } -} - -func TestAllOf_UntypedRedeclarationDoesNotConflict(t *testing.T) { - t.Parallel() - // One branch leaves the field schemaless (the top type), the other types it. - // `any` intersects with everything under allOf, so this is a narrowing, not a - // contradiction — it must not be reported. - spec := openapitest.ComponentSpec(` Anyish: - allOf: - - type: object - properties: - id: {description: the identifier} - - type: object - properties: - id: {type: integer} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := typeByName(doc, "Anyish").(*ir.Model) - require.True(t, ok, "Anyish should be a model") - require.Len(t, m.Properties, 1, "id reconciles to one property") - assert.Empty(t, conflictDiags(diags), - "the schemaless top type never conflicts with a sibling redeclaration") -} - -func TestAllOf_EquivalentNumericBoundsDoNotConflict(t *testing.T) { - t.Parallel() - // The same bound spelled two ways (10 and 10.0) denotes one value, so it must - // compare equal by magnitude and stay silent. - spec := openapitest.ComponentSpec(` Boundish: - allOf: - - type: object - properties: - n: {type: number, minimum: 10} - - type: object - properties: - n: {type: number, minimum: 10.0} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, conflictDiags(diags), - "10 and 10.0 are the same numeric bound, not a conflict") -} - -func TestAllOf_DifferingNumericBoundsConflict(t *testing.T) { - t.Parallel() - // Two branches pin the same lower bound to different magnitudes; the kept - // winner is arbitrary source order, so the dropped bound is surfaced. - spec := openapitest.ComponentSpec(` Boundish: - allOf: - - type: object - properties: - n: {type: integer, minimum: 5} - - type: object - properties: - n: {type: integer, minimum: 10} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "differing numeric bounds are diagnosed once") - assert.Contains(t, conflicts[0].Message, `"n"`) -} - -func TestAllOf_ScalarVersusObjectRedeclarationConflicts(t *testing.T) { - t.Parallel() - // A scalar in one branch and a structural type in the other cannot both hold. - spec := openapitest.ComponentSpec(` Mixed: - allOf: - - type: object - properties: - f: {type: string} - - type: object - properties: - f: - type: object - properties: - x: {type: string} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "a scalar against an object is diagnosed once") - assert.Contains(t, conflicts[0].Message, `"f"`) -} - -func TestAllOf_DistinctInlineObjectsDoNotConflict(t *testing.T) { - t.Parallel() - // Two branches redeclare the field as distinct inline objects. Each hoists its - // own model at its own pointer, so the targets differ — but two objects of the - // same kind are not provably contradictory, and conflict detection never - // guesses. - spec := openapitest.ComponentSpec(` Objish: - allOf: - - type: object - properties: - f: - type: object - properties: - x: {type: string} - - type: object - properties: - f: - type: object - properties: - x: {type: string} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, conflictDiags(diags), - "two distinct inline objects for one field are not a provable conflict") -} - -func TestAllOf_ConstraintAndFormatConflictsDiagnosed(t *testing.T) { - t.Parallel() - // Each way a redeclaration can contradict across branches: a differing - // minimum, exclusiveMinimum, pattern or multipleOf, a bound and a multipleOf - // that differ past the magnitude a rational will build, and two - // format-derived primitives (uuid vs date-time). - // Each keeps the first declaration but surfaces exactly one conflict naming - // the field, both branch sites, and — for the constraint cases — the - // offending keyword with both of its conflicting values, so the author - // never has to diff both branches by hand. - cases := []struct { - name, a, b, wantDetail string - }{ - { - name: "minimum", - a: "{type: number, minimum: 10}", - b: "{type: number, minimum: 20}", - wantDetail: "conflicting minimum (10 and 20)", - }, - { - // The exclusive bound is a keyword of its own, so it conflicts - // under its own name rather than as a differing sense of minimum. - name: "exclusiveMinimum", - a: "{type: number, exclusiveMinimum: 10}", - b: "{type: number, exclusiveMinimum: 20}", - wantDetail: "conflicting exclusiveMinimum (10 and 20)", - }, - { - name: "pattern", - a: "{type: string, pattern: '^a$'}", - b: "{type: string, pattern: '^b$'}", - wantDetail: `conflicting pattern ("^a$" and "^b$")`, - }, - { - name: "multipleOf", - a: "{type: integer, multipleOf: 2}", - b: "{type: integer, multipleOf: 3}", - wantDetail: "conflicting multipleOf (2 and 3)", - }, - { - // The other half of the past-a-rational rows in - // TestAllOf_CompatibleConstraintRedeclarationsStaySilent: a - // magnitude no rational holds still has to be ordered, not waved - // through, or the fix for the equal pair would just silence every - // pair that large. - name: "minimum too large for a rational", - a: "{type: number, minimum: 1e1000001}", - b: "{type: number, minimum: 2e1000001}", - wantDetail: "conflicting minimum (1e1000001 and 2e1000001)", - }, - { - name: "multipleOf too large for a rational", - a: "{type: number, multipleOf: 1e1000001}", - b: "{type: number, multipleOf: 1e1000002}", - wantDetail: "conflicting multipleOf (1e1000001 and 1e1000002)", - }, - { - name: "uuid vs date-time", - a: "{type: string, format: uuid}", - b: "{type: string, format: date-time}", - // A type conflict, not a constraint one — checked via the - // type-conflict message shape in TestAllOf_TypeConflictMessageNamesBothTypes. - wantDetail: "incompatible types", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - _, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "%s is diagnosed exactly once", tc.name) - assert.Contains(t, conflicts[0].Message, `"v"`, "the diagnostic names the field") - assert.Contains(t, conflicts[0].Message, "allOf/0", "names the first branch site") - assert.Contains(t, conflicts[0].Message, "allOf/1", "names the second branch site") - assert.Contains(t, conflicts[0].Message, tc.wantDetail, - "%s: the diagnostic names what actually differs", tc.name) - }) - } -} - -// TestAllOf_PrimitiveNarrowingIsLosslessInEitherOrder pins that a redeclaration -// narrowing a bare primitive (GitHub #598, #446) loses nothing: the narrower -// target wins in both branch orders, with no diagnostic and no kept entry, and -// the two orders merge to the same type. -func TestAllOf_PrimitiveNarrowingIsLosslessInEitherOrder(t *testing.T) { - t.Parallel() - cases := []struct { - name, broad, narrow string - want ir.TypeID - }{ - {"number and integer", "{type: number}", "{type: integer}", "t/prim/integer"}, - {"integer and int32", "{type: integer}", "{type: integer, format: int32}", "t/prim/int32"}, - {"string and date-time", "{type: string}", "{type: string, format: date-time}", "t/prim/datetime_offset"}, - {"int32 and int64", "{type: integer, format: int64}", "{type: integer, format: int32}", "t/prim/int32"}, - {"uri and string", "{type: string}", "{type: string, format: uri}", "t/prim/url"}, - {"double and number", "{type: number}", "{type: number, format: double}", "t/prim/float64"}, - {"uuid and string", "{type: string}", "{type: string, format: uuid}", "t/prim/uuid"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - var merged [2]ir.TypeRef - for i, spec := range []string{allOfConflictSpec(tc.broad, tc.narrow), allOfConflictSpec(tc.narrow, tc.broad)} { - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, diags, "a narrowing draws no diagnostic (order %d)", i) - model, ok := doc.Types[componentID("T")].(*ir.Model) - require.True(t, ok) - require.Len(t, model.Properties, 1) - prop := model.Properties[0] - assert.Equal(t, tc.want, prop.Type.Target, "the narrower target wins (order %d)", i) - for key := range prop.Unmodeled { - assert.NotContains(t, key, "openapi:conflicting-redeclaration", "nothing is kept (order %d)", i) - } - merged[i] = prop.Type - } - assert.Empty(t, cmp.Diff(merged[0], merged[1]), "the merged type does not depend on branch order") - }) - } -} - -func TestAllOf_CompatibleConstraintRedeclarationsStaySilent(t *testing.T) { - t.Parallel() - // The false-positive guards for the constraint path: a keyword present on - // only one branch (both branches still carry constraints) is intersected, - // not a conflict — and the merge must genuinely carry both keywords - // forward on the reconciled property, not just stay quiet about the one it - // used to drop; one magnitude spelled two ways is one value, whether or not - // it is one a rational will build; and an unknown-format scalar narrows the - // plain type it is based on, so it wins with nothing to report. - cases := []struct { - name string - a, b string - assertMerged func(t *testing.T, c *ir.Constraints) - }{ - { - name: "one-sided keywords", - a: "{type: string, maxLength: 10}", - b: "{type: string, minLength: 2}", - assertMerged: func(t *testing.T, c *ir.Constraints) { - t.Helper() - require.NotNil(t, c) - require.NotNil(t, c.MaxLength, "the first branch's maxLength is kept") - require.NotNil(t, c.MinLength, "the second branch's minLength is adopted, not dropped") - assert.Equal(t, int64(10), *c.MaxLength) - assert.Equal(t, int64(2), *c.MinLength) - }, - }, - { - name: "one-sided pattern", - a: "{type: string, maxLength: 10}", - b: "{type: string, pattern: '^a'}", - assertMerged: func(t *testing.T, c *ir.Constraints) { - t.Helper() - require.NotNil(t, c) - require.NotNil(t, c.MaxLength, "the first branch's maxLength is kept") - assert.Equal(t, int64(10), *c.MaxLength) - assert.Equal(t, "^a", c.Pattern, "the second branch's pattern is adopted, not dropped") - }, - }, - { - // minimum and exclusiveMinimum are two keywords, so a branch - // declaring one and a branch declaring the other intersect to a - // field carrying both — not to whichever the merge picked. - name: "minimum and exclusiveMinimum adopted side by side", - a: "{type: number, minimum: 1}", - b: "{type: number, exclusiveMinimum: 5}", - assertMerged: func(t *testing.T, c *ir.Constraints) { - t.Helper() - require.NotNil(t, c) - require.NotNil(t, c.Min, "the first branch's minimum stays") - assert.Equal(t, "1", c.Min.String()) - require.NotNil(t, c.ExclusiveMin, "the second branch's exclusiveMinimum is adopted beside it") - assert.Equal(t, "5", c.ExclusiveMin.String()) - }, - }, - {name: "equivalent multipleOf", a: "{type: number, multipleOf: 2}", b: "{type: number, multipleOf: 2.0}"}, - // Both BigVal keywords at a magnitude math/big will not build as a - // rational. The value is the same on either branch, so there is nothing - // to report; a comparison that gave up on the magnitude instead would - // call one value two and fail a --fail-on warning build. - { - name: "equal minimum too large for a rational", - a: "{type: number, minimum: 1e1000001}", - b: "{type: number, minimum: 10e1000000}", - }, - { - name: "equal multipleOf too large for a rational", - a: "{type: number, multipleOf: 1e1000001}", - b: "{type: number, multipleOf: 10e1000000}", - }, - {name: "custom format over base", a: "{type: string, format: weird}", b: "{type: string}"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) - assert.Empty(t, diags, "%s draws no diagnostic", tc.name) - if tc.assertMerged == nil { - return - } - m, ok := typeByName(doc, "T").(*ir.Model) - require.True(t, ok, "T should be a model") - require.Len(t, m.Properties, 1, "v reconciles to one property") - tc.assertMerged(t, m.Properties[0].Constraints) - }) - } -} - -func TestAllOf_OpaqueScalarVsPrimitiveNoConflict(t *testing.T) { - t.Parallel() - // An opaque scalar (format without a base type) is unknown, not structural, - // so it's not provably incompatible with a primitive. The "never guess" - // principle means we don't flag this as a conflict. - spec := openapitest.ComponentSpec(` OpaqueTest: - allOf: - - type: object - properties: - id: {type: string} - - type: object - properties: - id: {format: custom} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, conflictDiags(diags), - "opaque scalar vs primitive is not flagged as a conflict") -} - -func TestAllOf_ThreeWayRedeclarationProducesTwoDiagnostics(t *testing.T) { - t.Parallel() - // Three branches typing one field differently fold as one set: the first - // declaration wins, and each other is reported against it, so two - // diagnostics, though all three pairs are incompatible. - spec := openapitest.ComponentSpec(` ThreeWay: - allOf: - - type: object - properties: - id: {type: string} - - type: object - properties: - id: {type: integer} - - type: object - properties: - id: {type: boolean} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 2, "three-way redeclaration produces two diagnostics") -} - -func TestAllOf_ThreeWayCompatibleRedeclarationStaysSilent(t *testing.T) { - t.Parallel() - // When three allOf branches declare the same field with compatible types - // (all the same), no conflict is reported. - spec := openapitest.ComponentSpec(` ThreeWayCompat: - allOf: - - type: object - properties: - id: {type: string} - - type: object - properties: - id: {type: string} - - type: object - properties: - id: {type: string} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, conflictDiags(diags), - "three-way compatible redeclaration stays silent") -} - -func TestAllOf_SatisfiableNarrowingsStaySilent(t *testing.T) { - t.Parallel() - // Each case narrows a base type to a stricter but satisfiable shape under - // allOf intersection (enum of the base's own primitive, const pinning a - // legal value, a union narrowed to one of its own members) — none is a - // provable contradiction, so a bare scalar sibling against any of these must - // not be flagged. - cases := []struct { - name, a, b string - }{ - {"enum narrows base type", "{type: string}", "{type: string, enum: [active, inactive]}"}, - {"const narrows base type", "{type: string}", "{type: string, const: dog}"}, - {"union narrowed to one member", "{type: [string, integer]}", "{type: string}"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - _, diags := schema.LowerSpec(t, allOfConflictSpec(tc.a, tc.b)) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, conflictDiags(diags), "%s must not be reported as a conflict", tc.name) - }) - } -} - -func TestAllOf_EnumVersusIncompatibleTypeStillConflicts(t *testing.T) { - t.Parallel() - // Guard against over-correcting the enum false-positive fix: an enum whose - // declared ValueType is string still provably conflicts with an - // incompatible primitive (integer). - _, diags := schema.LowerSpec(t, allOfConflictSpec( - "{type: string, enum: [active, inactive]}", "{type: integer}")) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "enum of strings vs integer is still a provable conflict") - assert.Contains(t, conflicts[0].Message, `"v"`, "the diagnostic names the conflicting field") -} - -func TestAllOf_TypeConflictMessageNamesBothTypes(t *testing.T) { - t.Parallel() - // The type-conflict diagnostic must say what actually differs, not just - // that something did: the two conflicting type identities, so the author - // can see at a glance what disagreed without cross-referencing the spec. - _, diags := schema.LowerSpec(t, allOfConflictSpec("{type: string}", "{type: integer}")) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1) - assert.Contains(t, conflicts[0].Message, "t/prim/string", "names the first branch's type") - assert.Contains(t, conflicts[0].Message, "t/prim/integer", "names the second branch's type") -} - -func TestAllOf_PropertyAlongsideAllOfConflictMessageIsAccurate(t *testing.T) { - t.Parallel() - // A property declared directly on the schema alongside allOf redeclares a - // field the inline branch also declares. The redeclaration's provenance - // pointer here is a plain "properties/id" path, never an "allOf/N" branch, - // so the message must not claim "allOf branches redeclare" — it must read - // correctly for a co-declared sibling property too (merge.PropIndex's - // MergeProperty folds both cases the same way; see its doc comment). - spec := openapitest.ComponentSpec(` Along: - type: object - properties: - id: {type: string} - allOf: - - type: object - properties: - id: {type: integer} -`) - _, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - conflicts := conflictDiags(diags) - require.Len(t, conflicts, 1, "the co-declared property conflict is diagnosed once") - d := conflicts[0] - assert.Contains(t, d.Message, `"id"`, "the diagnostic names the conflicting field") - assert.NotContains(t, d.Message, "allOf branches", - "the redeclaration site is a co-declared property, not an allOf branch") - assert.Equal(t, jsontext.Pointer("/components/schemas/Along/properties/id"), d.Provenance.Pointer, - "the diagnostic's own site is the co-declared property, not an allOf branch") -} - // TestAllOf_FalseBranchClosesTheComposition pins what a `false` allOf branch // does to the composed node. `false` admits nothing, so the composition admits // nothing: the model closes and the branch is kept verbatim beside it, which is @@ -2756,184 +232,6 @@ func TestAllOf_FalseBranchClosesTheComposition(t *testing.T) { assert.True(t, openapitest.HasDiagAt(diags, diag.FalseSchema, ir.SeverityInfo), "and it is announced") } -// TestAllOf_UnpreservableBranchIsNotAnnounced is -// TestUnhomedApplicator_UnpreservableKeywordIsNotAnnounced's pairing on the -// composition path: a branch the merge does not consume whole is normally kept -// beside the model and reported, and when the keeping fails the report must not -// happen either. -func TestAllOf_UnpreservableBranchIsNotAnnounced(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` M: - allOf: - - {type: string, maxLength: 3, x-t: `+unpreservableValue+`} -`)) - - td := typeByName(doc, "M") - require.NotNil(t, td) - assert.NotContains(t, td.Common().Unmodeled, "openapi:allOf/0", "the branch did not convert") - assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct)) - assert.Empty(t, preservationClaims(diags), - "no degradation is announced for a branch that was not kept") -} - -// compileView compiles the defs with the component m and returns M's view and -// the codes reported, requiring no error. -func compileView(t *testing.T, m string) (composedView, []string) { - t.Helper() - doc, diags := parseFull(t, openapitest.ComponentSpec(equivalenceDefs+m)) - openapitest.RequireNoErrorDiags(t, diags) - return viewOf(doc, componentID("M"), 1), diagCodes(diags) -} - -// equivalencePairs are (nested, flat) spellings of one composition M. Each -// nested spelling must lower as its flat one does -// (docs/compilers/openapi.md#allof-classification), at any depth up to -// MaxAllOfFlattenDepth. -var equivalencePairs = []struct { - name string - nested string - flat string -}{ - {"one wrapper around two refs", ` M: - allOf: - - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`}, - {"two wrappers", ` M: - allOf: - - allOf: - - allOf: [{$ref: '#/components/schemas/A'}] - - {$ref: '#/components/schemas/B'} -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`}, - {"three wrappers", ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - allOf: - - allOf: [{$ref: '#/components/schemas/B'}, {$ref: '#/components/schemas/C'}] -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}, {$ref: '#/components/schemas/C'}] -`}, - {"four wrappers", ` M: - allOf: - - allOf: - - allOf: - - allOf: - - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`}, - {"a discriminated base inside a wrapper", pet + ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/Pet'} -`, pet + ` M: - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/Pet'}] -`}, - {"inline branches with properties, required and type object", ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - type: object - required: [x] - properties: {x: {type: integer}, y: {type: string}} - - allOf: - - properties: {z: {type: boolean}} -`, ` M: - allOf: - - {$ref: '#/components/schemas/A'} - - type: object - required: [x] - properties: {x: {type: integer}, y: {type: string}} - - properties: {z: {type: boolean}} -`}, - {"a wrapper's own properties merge after its entries", ` M: - allOf: - - required: [w] - properties: {w: {type: string}} - allOf: - - type: object - properties: {x: {type: integer}} - - properties: {y: {type: integer}} -`, ` M: - allOf: - - type: object - properties: {x: {type: integer}} - - properties: {y: {type: integer}} - - required: [w] - properties: {w: {type: string}} -`}, - {"a required name only the base declares", ` M: - allOf: - - allOf: - - {$ref: '#/components/schemas/A'} - - {required: [a]} -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, {required: [a]}] -`}, - {"a true branch", ` M: - allOf: - - allOf: [{$ref: '#/components/schemas/A'}, true] -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, true] -`}, - {"a false branch", ` M: - allOf: - - allOf: [{$ref: '#/components/schemas/A'}, false] -`, ` M: - allOf: [{$ref: '#/components/schemas/A'}, false] -`}, - {"a residue description", ` M: - allOf: - - description: grouped - allOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`, ` M: - allOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/B'} - - description: grouped -`}, - {"a distributed oneOf beside the wrappers", ` M: - type: object - allOf: - - allOf: - - {$ref: '#/components/schemas/C'} - - properties: {w: {type: string}} - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`, ` M: - type: object - allOf: - - {$ref: '#/components/schemas/C'} - - properties: {w: {type: string}} - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`}, -} - -// wires lists the wire names of props. -func wires(props []propView) []string { - out := make([]string, 0, len(props)) - for _, p := range props { - out = append(out, p.Wire) - } - return out -} - -// nonBareSpec declares T with one property v written as schemaA in the first -// branch and schemaB in the second, after the components in extra. -func nonBareSpec(extra, schemaA, schemaB string) string { - return openapitest.ComponentSpec(extra + - " T:\n" + - " allOf:\n" + - " - type: object\n" + - " properties: {v: " + schemaA + "}\n" + - " - type: object\n" + - " properties: {v: " + schemaB + "}\n") -} - // nestedOrders compiles first and last in both declaration orders and requires // each to validate, report no error, and the two to agree on their types and on // what they report. Flattening reads a nested branch's $refs, so a decision @@ -2959,121 +257,3 @@ func nestedOrders(t *testing.T, first, last string) []*ir.Document { const nestedAB = ` A: {type: object, properties: {a: {type: string}}} B: {type: object, properties: {b: {type: string}}} ` - -// refTargets lists the TypeIDs refs target, in order. -func refTargets(refs []ir.TypeRef) []ir.TypeID { - out := make([]ir.TypeID, 0, len(refs)) - for _, r := range refs { - out = append(out, r.Target) - } - return out -} - -// equivalenceDefs are the components every pair composes: two plain schemas, a -// third to compose beside them. -const equivalenceDefs = ` A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - C: {type: object, properties: {c: {type: integer}}} -` - -// diagCodes lists the codes doc's compilation reported, in order. -func diagCodes(diags []ir.Diagnostic) []string { - out := make([]string, 0, len(diags)) - for _, d := range diags { - out = append(out, d.Code) - } - return out -} - -// pet is a discriminated base routing to M. -const pet = ` Pet: - type: object - required: [kind] - properties: {kind: {type: string}} - discriminator: - propertyName: kind - mapping: {dog: '#/components/schemas/M'} -` - -// allOfConflictSpec builds a component whose two inline allOf branches each -// declare field v with the given flow-style schemas, for exercising per-keyword -// redeclaration conflict detection. -func allOfConflictSpec(schemaA, schemaB string) string { - return openapitest.ComponentSpec( - " T:\n" + - " allOf:\n" + - " - type: object\n" + - " properties: {v: " + schemaA + "}\n" + - " - type: object\n" + - " properties: {v: " + schemaB + "}\n") -} - -// composedView is a composed model with what the nested and flat spellings may -// differ in projected away: pointers, provenance and the IDs of hoisted nodes. -// Kept counts the model's Unmodeled entries: their keys and values differ by -// spelling, but how many the composition kept must not. -type composedView struct { - Base string - Mixins []string - Props []propView - Requires []string - Additional ir.AdditionalMode - DiscValue string - Variants []composedView - Kept int -} - -// propView is one property of a composedView. -type propView struct { - Wire string - Type string - Required bool -} - -// projectTarget names a type reference without the ID of a node hoisted at a -// pointer, which differs by spelling: a component is kept by ID, anything else by -// its kind. -func projectTarget(doc *ir.Document, id ir.TypeID) string { - if strings.HasPrefix(string(id), "t/anon/") { - return "anon:" + string(doc.Types[id].Kind()) - } - return string(id) -} - -// viewOf projects the type at id: a model as itself, a union as its variants, -// each projected in turn. depth bounds the descent, which a union's variants -// reach once. -func viewOf(doc *ir.Document, id ir.TypeID, depth int) composedView { - var v composedView - switch td := doc.Types[id].(type) { - case *ir.Model: - v = modelView(doc, td) - case *ir.Union: - if depth > 0 { - for _, variant := range td.Variants { - v.Variants = append(v.Variants, viewOf(doc, variant.Type.Target, depth-1)) - } - } - default: - v.Base = "not a model or union: " + string(id) - } - return v -} - -// modelView projects a model's composition, properties, the required names it -// holds no property for, and its openness. -func modelView(doc *ir.Document, m *ir.Model) composedView { - v := composedView{ - Requires: m.Requires, Additional: m.Additional, DiscValue: m.DiscriminatorValue, Kept: len(m.Unmodeled), - } - if m.Base != nil { - v.Base = string(m.Base.Target) - } - for _, mixin := range m.Mixins { - v.Mixins = append(v.Mixins, string(mixin.Target)) - } - for _, p := range m.Properties { - v.Props = append(v.Props, propView{Wire: p.WireName, Type: projectTarget(doc, p.Type.Target), Required: p.Required}) - } - return v -} diff --git a/compilers/openapi/internal/schema/discriminator_allof_test.go b/compilers/openapi/internal/schema/discriminator_allof_test.go new file mode 100644 index 00000000..4f00e9a6 --- /dev/null +++ b/compilers/openapi/internal/schema/discriminator_allof_test.go @@ -0,0 +1,479 @@ +package schema_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +func TestAllOf_DiscriminatorSubtypeValue(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Pet: + type: object + discriminator: + propertyName: petType + mapping: + kitty: "#/components/schemas/Cat" + properties: + petType: {type: string} + Cat: + allOf: + - {$ref: "#/components/schemas/Pet"} + - type: object + properties: + meow: {type: string} + Dog: + allOf: + - {$ref: "#/components/schemas/Pet"} + - type: object + properties: + bark: {type: string} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) + require.True(t, ok, "Pet should be a model") + require.NotNil(t, pet.Discriminator, "the base carries the discriminator") + assert.Empty(t, pet.DiscriminatorValue, "the base itself has no wire tag value") + + cat, ok := doc.Types[componentID("Cat")].(*ir.Model) + require.True(t, ok, "Cat should be a model") + assert.Equal(t, componentID("Pet"), cat.Base.Target) + assert.Equal(t, "kitty", cat.DiscriminatorValue, + "mapping key pointing at Cat becomes its wire tag value") + + dog, ok := doc.Types[componentID("Dog")].(*ir.Model) + require.True(t, ok, "Dog should be a model") + assert.Equal(t, componentID("Pet"), dog.Base.Target) + assert.Equal(t, "Dog", dog.DiscriminatorValue, + "a subtype absent from the mapping falls back to its schema name") +} + +// TestAllOf_InlineSubtypeHasNoImplicitDiscriminatorValue pins GitHub #517. +// OpenAPI's implicit discriminator mapping names a subtype by its component +// schema name; an inline subtype — declared here as a property, once keyed like +// the unrelated component Dog and once keyed like nothing in the document — +// has no schema name of its own and so gets no implicit tag. Before the fix the +// fallback read the subtype's pointer's last token instead, so Kennel's "Dog" +// property claimed the same tag as the component Dog, and its "pet" property +// claimed its own property key as a tag the document never assigned. +func TestAllOf_InlineSubtypeHasNoImplicitDiscriminatorValue(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Pet: + type: object + required: [kind] + properties: + kind: {type: string} + discriminator: + propertyName: kind + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + type: object + properties: + bark: {type: string} + Kennel: + type: object + properties: + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + type: object + properties: + woof: {type: string} + pet: + allOf: + - $ref: '#/components/schemas/Pet' + type: object + properties: + meow: {type: string} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + kennelDog := ir.TypeID("t/anon/components/schemas/Kennel/properties/Dog") + kennelPet := ir.TypeID("t/anon/components/schemas/Kennel/properties/pet") + want := map[ir.TypeID]string{ + componentID("Dog"): "Dog", + kennelDog: "", + kennelPet: "", + } + got := discriminatorValues(t, doc, componentID("Dog"), kennelDog, kennelPet) + assert.Empty(t, cmp.Diff(want, got), + "the component keeps its implicit tag; neither inline subtype gets one") +} + +// TestAllOf_InlineSubtypeDiscriminatorValueFromMapping pins the path the #517 +// fix leaves untouched: a mapping entry can still name an inline subtype by +// JSON reference, and mappingTagsFor — not subtypeDiscriminatorValue's +// implicit-name fallback — is what gives it that tag. +func TestAllOf_InlineSubtypeDiscriminatorValueFromMapping(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Kennel: + type: object + properties: + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + type: object + properties: + woof: {type: string} + pet: + allOf: + - $ref: '#/components/schemas/Pet' + type: object + properties: + meow: {type: string} + Pet: + type: object + required: [kind] + properties: + kind: {type: string} + discriminator: + propertyName: kind + mapping: + woofer: '#/components/schemas/Kennel/properties/Dog' + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + type: object + properties: + bark: {type: string} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + kennelDog := ir.TypeID("t/anon/components/schemas/Kennel/properties/Dog") + kennelPet := ir.TypeID("t/anon/components/schemas/Kennel/properties/pet") + want := map[ir.TypeID]string{ + componentID("Dog"): "Dog", + kennelDog: "woofer", + kennelPet: "", + } + got := discriminatorValues(t, doc, componentID("Dog"), kennelDog, kennelPet) + assert.Empty(t, cmp.Diff(want, got), + "a mapping entry names Kennel's inline Dog by JSON reference; pet still gets no implicit tag") + + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) + require.True(t, ok, "Pet should be a model") + require.NotNil(t, pet.Discriminator) + assert.Equal(t, map[string]ir.TypeID{"woofer": kennelDog}, pet.Discriminator.Mapping, + "the base's own mapping resolves the same inline subtype the tag was read from") +} + +// TestAllOf_DiscriminatorValueFromDistantMapping pins the mapping-key spelling of +// a tag at depth: the root names a grandchild explicitly, and the grandchild's own +// branch is an intermediate that declares no discriminator, so the key is only +// reachable by walking past the immediate parent (GitHub #305). +// +// The conformance case discriminator-transitive.yaml witnesses the same walk. +func TestAllOf_DiscriminatorValueFromDistantMapping(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Pet: + type: object + required: [petType] + properties: {petType: {type: string}} + discriminator: + propertyName: petType + mapping: + dog: '#/components/schemas/Dog' + puppy: '#/components/schemas/Puppy' + Dog: + allOf: + - {$ref: '#/components/schemas/Pet'} + properties: {bark: {type: boolean}} + Puppy: + allOf: + - {$ref: '#/components/schemas/Dog'} + properties: {weeks: {type: integer}} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + dog := typeByName(doc, "Dog").(*ir.Model) + assert.Equal(t, "dog", dog.DiscriminatorValue, "the direct child takes its own mapping key") + + puppy := typeByName(doc, "Puppy").(*ir.Model) + require.NotNil(t, puppy.Base) + assert.Equal(t, componentID("Dog"), puppy.Base.Target, + "Puppy composes Dog, which declares no discriminator of its own") + assert.Equal(t, "puppy", puppy.DiscriminatorValue, + "the key Pet's mapping spells for Puppy survives the intermediate") +} + +// TestAllOf_DiscriminatorValueFromNearestAncestor pins which of two discriminated +// ancestors supplies the tag when both mappings name the same subtype. The walk +// is level by level so the answer is the nearer one, rather than whichever chain +// happened to be walked first — a subtype's own hierarchy outranks one it reaches +// only through a longer composition. +func TestAllOf_DiscriminatorValueFromNearestAncestor(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Far: + type: object + properties: {k: {type: string}} + discriminator: + propertyName: k + mapping: {far: '#/components/schemas/Leaf'} + Mid: + allOf: + - {$ref: '#/components/schemas/Far'} + properties: {m: {type: string}} + Near: + type: object + properties: {n: {type: string}} + discriminator: + propertyName: n + mapping: {near: '#/components/schemas/Leaf'} + Leaf: + allOf: + - {$ref: '#/components/schemas/Mid'} + - {$ref: '#/components/schemas/Near'} + properties: {l: {type: string}} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + leaf := typeByName(doc, "Leaf").(*ir.Model) + assert.Equal(t, "near", leaf.DiscriminatorValue, + "Near is one hop up and Far two, so Near's mapping key wins") +} + +// TestAllOf_DiscriminatorValueUndiscriminatedChain is the boundary of the +// ancestor walk: composing at any depth is not on its own a reason to carry a +// tag. Without this, a walk that fell back to the implicit schema name whenever +// it found a base would stamp every composed model in the document. +func TestAllOf_DiscriminatorValueUndiscriminatedChain(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Plant: + type: object + properties: {stem: {type: string}} + Shrub: + allOf: + - {$ref: '#/components/schemas/Plant'} + properties: {twigs: {type: integer}} + Sapling: + allOf: + - {$ref: '#/components/schemas/Shrub'} + properties: {rings: {type: integer}} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + for _, name := range []string{"Shrub", "Sapling"} { + m := typeByName(doc, name).(*ir.Model) + require.NotNil(t, m.Base, "%s composes a base", name) + assert.Empty(t, m.DiscriminatorValue, + "%s has no discriminated ancestor at any depth", name) + } +} + +// TestAllOf_DiscriminatorValueCyclicComposition drives the ancestor walk over a +// composition cycle. Nothing upstream refuses one — this spec compiles without an +// error diagnostic — so the walk meets it, and the visited set rather than the +// depth cap is what has to stop it. +// +// The fan-out is what makes that a real claim: every schema composes every other, +// so a walk that re-entered a schema it had already left would branch five ways +// per level for the length of the cap instead of terminating. +func TestAllOf_DiscriminatorValueCyclicComposition(t *testing.T) { + t.Parallel() + const n = 6 + var b strings.Builder + for i := range n { + refs := make([]string, 0, n-1) + for j := range n { + if j != i { + refs = append(refs, fmt.Sprintf("{$ref: '#/components/schemas/X%d'}", j)) + } + } + fmt.Fprintf(&b, " X%d:\n allOf: [%s]\n properties: {p%d: {type: string}}\n", + i, strings.Join(refs, ", "), i) + } + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(b.String())) + openapitest.RequireNoErrorDiags(t, diags) + + for i := range n { + name := fmt.Sprintf("X%d", i) + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s lowers to a Model", name) + assert.Empty(t, m.DiscriminatorValue, "%s has no discriminated ancestor", name) + } +} + +// TestAllOf_DiscriminatorValueSelfAncestorInCycle pins that a composition cycle +// does not make a schema one of its own ancestors. A declares the discriminator +// and composes B, which composes A back, so walking A's ancestors comes round to +// A — whose mapping names A. A hierarchy of one schema standing above itself is +// not a hierarchy, so the walk skips the schema it started from. +func TestAllOf_DiscriminatorValueSelfAncestorInCycle(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` A: + allOf: [{$ref: '#/components/schemas/B'}] + required: [k] + properties: {k: {type: string}} + discriminator: + propertyName: k + mapping: + selfkey: '#/components/schemas/A' + B: + allOf: [{$ref: '#/components/schemas/A'}] + properties: {b: {type: string}} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + a, ok := typeByName(doc, "A").(*ir.Model) + require.True(t, ok, "A lowers to a Model") + require.NotNil(t, a.Discriminator, "A anchors the hierarchy it declares") + assert.Empty(t, a.DiscriminatorValue, + "and takes no tag from its own mapping by way of the cycle") +} + +// TestAllOf_DiscriminatorValueChainDeeperThanCap pins the ancestor walk's cap as +// behaviour rather than as a comment. A chain longer than the cap stops being +// searched, so the tag the root would have supplied is not stamped — stated here +// so the limit is a decision on record and a chain that grows past it is a +// failing test rather than a silent reversion to the bug this walk fixed. +// +// The shallower half is the control: the same construction one level inside the +// cap does carry the tag, which is what says the deep case failed on the cap and +// not on the construction. +func TestAllOf_DiscriminatorValueChainDeeperThanCap(t *testing.T) { + t.Parallel() + // maxDiscriminatorAncestorDepth is unexported; 256 is its committed value, and + // the two cases below straddle it. + const ancestorCap = 256 + for _, tc := range []struct { + name string + links int + want string + }{ + {"within the cap", ancestorCap - 1, fmt.Sprintf("S%d", ancestorCap-2)}, + {"beyond the cap", ancestorCap + 4, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString(" Root:\n type: object\n properties: {k: {type: string}}\n" + + " discriminator: {propertyName: k}\n") + prev := "Root" + for i := range tc.links { + fmt.Fprintf(&b, " S%d:\n allOf: [{$ref: '#/components/schemas/%s'}]\n"+ + " properties: {p%d: {type: string}}\n", i, prev, i) + prev = fmt.Sprintf("S%d", i) + } + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(b.String())) + openapitest.RequireNoErrorDiags(t, diags) + + leaf := typeByName(doc, prev).(*ir.Model) + assert.Equal(t, tc.want, leaf.DiscriminatorValue, + "%s is %d links below the discriminated root", prev, tc.links) + }) + } +} + +func TestAllOf_ModelWithOwnDiscriminator(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Base: + allOf: + - {$ref: '#/components/schemas/Common'} + discriminator: {propertyName: kind} + properties: {kind: {type: string}} + Common: {type: object, properties: {id: {type: string}}} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + base := typeByName(doc, "Base").(*ir.Model) + require.NotNil(t, base.Discriminator, "allOf model may declare its own discriminator") + assert.NotEmpty(t, base.Discriminator.Property) +} + +func TestAllOf_BoolRefBranchHasNoDiscriminator(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` BoolComp: false + Sub: + allOf: + - {$ref: '#/components/schemas/BoolComp'} + - {type: object, properties: {a: {type: string}}} +`) + doc, diags := schema.LowerSpec(t, spec) + require.NotNil(t, doc) + sub := typeByName(doc, "Sub").(*ir.Model) + assert.Empty(t, sub.DiscriminatorValue, "a bool-schema ref target anchors no hierarchy") + _ = diags +} + +// TestAllOf_DiscriminatorAliasTagIsOrderInvariant pins that a subtype two mapping +// keys name takes the same tag whichever key is written first (GitHub #410). +// Model.DiscriminatorValue holds one value, and a mapping is unordered, so the +// key it holds must be chosen by a rule the source order cannot reach: the +// smallest in byte order. The base keeps every key, so nothing is lost, and the +// election is reported so a reader knows the subtype's tag set is wider than +// the field shows. +func TestAllOf_DiscriminatorAliasTagIsOrderInvariant(t *testing.T) { + t.Parallel() + const base = ` Pet: + type: object + required: [k] + properties: {k: {type: string}} + discriminator: + propertyName: k + mapping: +` + const sub = ` Dog: + allOf: [{$ref: '#/components/schemas/Pet'}] + properties: {bark: {type: boolean}} +` + const alpha = " alpha: '#/components/schemas/Dog'\n" + const zulu = " zulu: '#/components/schemas/Dog'\n" + + for _, tc := range []struct{ name, mapping string }{ + {"zulu first", zulu + alpha}, + {"alpha first", alpha + zulu}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(base+tc.mapping+sub)) + openapitest.RequireNoErrorDiags(t, diags) + + dog, ok := doc.Types[componentID("Dog")].(*ir.Model) + require.True(t, ok, "Dog should be a model") + assert.Equal(t, "alpha", dog.DiscriminatorValue, + "the smallest key in byte order, whichever was written first") + + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) + require.True(t, ok, "Pet should be a model") + assert.Equal(t, map[string]ir.TypeID{"alpha": componentID("Dog"), "zulu": componentID("Dog")}, + pet.Discriminator.Mapping, "the base keeps both keys") + + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Dog"), + "the election is reported at the subtype: %+v", diags) + assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "as information, since the base's mapping loses nothing") + }) + } +} + +// discriminatorValues reads the DiscriminatorValue of every Model named in +// typeIDs off doc, so a mismatch anywhere in the set shows every id at once +// rather than only the first one asserted. +func discriminatorValues(t *testing.T, doc *ir.Document, typeIDs ...ir.TypeID) map[ir.TypeID]string { + t.Helper() + got := make(map[ir.TypeID]string, len(typeIDs)) + for _, id := range typeIDs { + m, ok := doc.Types[id].(*ir.Model) + require.True(t, ok, "%s should be a model", id) + got[id] = m.DiscriminatorValue + } + return got +} diff --git a/compilers/openapi/internal/schema/discriminator_beside_union_test.go b/compilers/openapi/internal/schema/discriminator_beside_union_test.go new file mode 100644 index 00000000..69c5c6f7 --- /dev/null +++ b/compilers/openapi/internal/schema/discriminator_beside_union_test.go @@ -0,0 +1,534 @@ +package schema_test + +import ( + "encoding/json/jsontext" + "fmt" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/pass" +) + +// TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype pins that a model +// beside a union kept verbatim does not keep a discriminator that routes to a +// type that is not its subtype, which pass.Validate rejects. Each route counts: +// a mapping target, the default, and a component a $ref branch names, which +// the discriminator routes to by name. Each "lowers to" row names a type whose +// own allOf names Pet but which lowers to no Model with that Base, which only +// the lowered document can tell. +func TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype(t *testing.T) { + t.Parallel() + const viaPet = "{allOf: [{$ref: '#/components/schemas/Pet'}]" + cases := []struct { + name, version, discriminator, rest, want string + }{ + {"mapped", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", cat + dog, `"mapping"`}, + {"named by branch", "3.1.0", "{propertyName: kind}", cat + dog, `"propertyName"`}, + {"one mapped subtype and one branch that is not", "3.1.0", + "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + "}\n" + dog, `"mapping"`}, + {"default", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Other'}", + subtypes + " Other: {type: object}\n", `"defaultMapping"`}, + {"mixin only", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: {allOf: [{$ref: '#/components/schemas/Dog'}, {$ref: '#/components/schemas/Other'}]}\n" + + dog + " Other: {type: object}\n", `"mapping"`}, + {"position declaring an inline object", "3.1.0", + "{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}", + subtypes + " Holder: {type: object, properties: {pet: {type: object, properties: {kind: {type: string}}}}}\n", + `"mapping"`}, + {"component that is only an alias", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Alias'}}", + subtypes + " Alias: {$ref: '#/components/schemas/Cat'}\n", `"mapping"`}, + {"lowers to an enum", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + ", enum: [{kind: cat}]}\n" + subDog, `"mapping"`}, + {"lowers to a literal", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + ", const: {kind: cat}}\n" + subDog, `"mapping"`}, + {"lowers to an alias over its $ref", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: {$ref: '#/components/schemas/Other', allOf: [{$ref: '#/components/schemas/Pet'}]}\n" + + " Other: {type: object}\n" + subDog, `"mapping"`}, + {"lowers to a distributed union", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + ", oneOf: [{$ref: '#/components/schemas/House'}, {$ref: '#/components/schemas/Wild'}]}\n" + + " House: {type: object}\n Wild: {type: object}\n" + subDog, `"mapping"`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, c.version, petWith(c.discriminator), c.rest) { + assertMoved(t, doc, c.want) + } + }) + } +} + +// TestDiscriminatorBesideUnion_LeavesAMapWhenItMoves pins that a discriminator +// moving off a body whose one other member fact is its catch-all leaves a map, +// which is a MapT, as it is when no discriminator is written (ir-design §4.6). +func TestDiscriminatorBesideUnion_LeavesAMapWhenItMoves(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", mapPet, cat+dog) { + assertMovedToMap(t, typeByName(doc, "Pet")) + assert.Empty(t, censusReports(doc.Diagnostics, "/components/schemas/Pet"), + "the map holds all the census asked about, and the move keeps the discriminator itself") + } +} + +// TestDiscriminatorBesideUnion_SettlesAMapAReferenceNamesFirst is +// TestDiscriminatorBesideUnion_SettlesAPositionAReferenceNamesFirst over a map +// body: the reference builds nothing, so the one settlement the declaration +// defers moves the discriminator and leaves the MapT, in either order. +func TestDiscriminatorBesideUnion_SettlesAMapAReferenceNamesFirst(t *testing.T) { + t.Parallel() + const at = "/components/schemas/Holder/properties/pet" + outside := " Outside: {$ref: '#/components/schemas/Holder/properties/pet'}\n" + holder := " Holder:\n type: object\n properties:\n pet:\n type: object\n" + + " additionalProperties: {type: string}\n discriminator: {propertyName: kind}\n" + + " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}]\n" + for _, doc := range discriminatedOrders(t, "3.1.0", outside, holder+cat+dog) { + assertMovedToMap(t, doc.Types["t/anon"+at]) + assert.Contains(t, openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, at+"/discriminator"), + "not a subtype", "one move, reported once") + assert.Empty(t, censusReports(doc.Diagnostics, at), "the map homes all the census asked about") + } +} + +// TestDiscriminatorBesideUnion_ARequiredNameKeepsTheBodyAModel pins a body +// whose catch-all sits beside a required name its properties do not declare: +// the name is in Model.Requires, which no map holds, so the discriminator's +// move leaves a Model, nothing kept verbatim for want of a home. It holds for +// a plain object, a composed one, and the object variant of a multi-typed body. +func TestDiscriminatorBesideUnion_ARequiredNameKeepsTheBodyAModel(t *testing.T) { + t.Parallel() + const ( + body = " additionalProperties: {type: string}\n required: [a]\n discriminator: {propertyName: kind}\n" + petBranches + pointer = "/components/schemas/Pet" + ) + for _, tc := range []struct { + name, pet string + node ir.TypeID + }{ + {"plain", " Pet:\n type: object\n" + body, componentID("Pet")}, + {"composed", " Pet:\n allOf: [{type: object}]\n" + body, componentID("Pet")}, + {"variant", " Pet:\n type: [object, string]\n" + body, "t/anon" + pointer + "/type/0"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", tc.pet, cat+dog) { + m, ok := doc.Types[tc.node].(*ir.Model) + require.True(t, ok, "a required name keeps the body a Model; got %T", doc.Types[tc.node]) + assert.Nil(t, m.Discriminator, "the discriminator moved") + assert.Equal(t, []string{"a"}, m.Requires) + assert.NotContains(t, m.Unmodeled, "openapi:required") + assert.Empty(t, censusReports(doc.Diagnostics, pointer)) + } + }) + } +} + +// TestDiscriminatorBesideUnion_KeepsARoutingMapAModel pins the other outcome: +// a discriminator every route of which is a subtype stays, and a model that is +// a hierarchy's base is no map, whatever else it declares. +func TestDiscriminatorBesideUnion_KeepsARoutingMapAModel(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", mapPet, subtypes) { + assertStayed(t, doc) + assert.NotNil(t, petModel(t, doc).AdditionalProps, "the catch-all stays beside the discriminator") + } +} + +// TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype pins the case the +// model keeps its discriminator beside a kept union: every route is a subtype, +// at any distance and however it is spelled. A branch no name reaches, inline +// or a position, is no route, so it does not move the discriminator either. +func TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype(t *testing.T) { + t.Parallel() + const grand = " Cat: {allOf: [{$ref: '#/components/schemas/Mid'}], type: object}\n" + + " Mid: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + + " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + unnamed := " Pet:\n type: object\n discriminator: {propertyName: kind}\n" + + " oneOf: [{type: object, properties: {kind: {type: string}}}, {$ref: '#/components/schemas/Holder/properties/pet'}]\n" + cases := []struct { + name, version, pet, rest string + }{ + {"mapped", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), subtypes}, + {"named by branch", "3.1.0", petWith("{propertyName: kind}"), subtypes}, + {"mapped by bare name", "3.1.0", petWith("{propertyName: kind, mapping: {cat: Cat, dog: Dog}}"), subtypes}, + {"mapped through a position that is only a $ref", "3.1.0", + petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}"), + subtypes + " Holder: {type: object, properties: {pet: {$ref: '#/components/schemas/Cat'}}}\n"}, + {"grandchild", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), grand}, + {"default", "3.2.0", petWith("{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}"), subtypes}, + {"branches no name reaches", "3.1.0", unnamed, cat + " Holder: {type: object, properties: {pet: {type: object}}}\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, c.version, c.pet, c.rest) { + assertStayed(t, doc) + } + }) + } +} + +// TestDiscriminatorBesideUnion_StaysForASubtypeAtAnyDepth pins that no depth bound +// stands between a route and Pet: pass.Validate follows a hierarchy to any +// depth, so a bound here would move a discriminator it accepts. +func TestDiscriminatorBesideUnion_StaysForASubtypeAtAnyDepth(t *testing.T) { + t.Parallel() + var chain strings.Builder + for i := range 300 { + fmt.Fprintf(&chain, " C%d: {allOf: [{$ref: '#/components/schemas/C%d'}], type: object}\n", i, i+1) + } + chain.WriteString(" C300: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n") + pet := petWith("{propertyName: kind, mapping: {c: '#/components/schemas/C0'}}") + for _, doc := range discriminatedOrders(t, "3.1.0", pet, subtypes+chain.String()) { + assertStayed(t, doc) + } +} + +// TestDiscriminatorBesideUnion_ReportsTheMoveOnce pins the diagnostic: exactly +// one, at the discriminator, when it moves, in either declaration order, and +// none when it stays. +func TestDiscriminatorBesideUnion_ReportsTheMoveOnce(t *testing.T) { + t.Parallel() + pet := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") + for _, src := range []string{pet + cat + dog, cat + dog + pet} { + _, diags := parseFull(t, openapitest.ComponentSpec(src)) + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, movedPointer), + "not a subtype of the model holding it") + } + _, diags := parseFull(t, openapitest.ComponentSpec(pet+subtypes)) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, movedPointer)) +} + +// TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled pins that the rule +// holds on every union kept verbatim beside a model, not only one its +// discriminator alone degrades, both ways. +func TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled(t *testing.T) { + t.Parallel() + for _, shape := range degradedShapes { + t.Run(shape.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), cat+dog) { + assertMoved(t, doc, `"mapping"`) + } + for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), subtypes) { + assertStayed(t, doc) + } + }) + } +} + +// TestDiscriminatorBesideUnion_AllOfBodyIsSettled pins the other model body a +// kept union can sit beside: one composed with allOf lowers through +// lowerAllOf, and its discriminator is settled the same way. +func TestDiscriminatorBesideUnion_AllOfBodyIsSettled(t *testing.T) { + t.Parallel() + pet := " Pet:\n allOf: [{$ref: '#/components/schemas/Base'}]\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + petBranches + for _, doc := range discriminatedOrders(t, "3.1.0", pet, cat+dog+" Base: {type: object}\n") { + assertMoved(t, doc, `"mapping"`) + } +} + +// TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled pins the other model a +// discriminator beside a kept union can sit on: a multi-typed body lowers to a +// union whose object variant lowerUnion builds from the same schema, so the +// variant holds it. Routed to a non-subtype, it moves to the node keeping the +// union; with no route at all, it stays on the variant. +func TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled(t *testing.T) { + t.Parallel() + const variant = ir.TypeID("t/anon/components/schemas/Pet/type/0") + multi := func(discriminator, branches string) string { + return " Pet:\n type: [object, string]\n properties: {kind: {type: string}}\n" + + " discriminator: " + discriminator + "\n" + branches + } + cases := []struct { + name, pet string + moves bool + }{ + {"mapped to a non-subtype", multi("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", petBranches), true}, + {"no route", multi("{propertyName: kind}", " oneOf: [{type: object, properties: {kind: {type: string}}}, {type: string}]\n"), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", c.pet, cat+dog) { + owner, ok := doc.Types[componentID("Pet")].(*ir.Union) + require.True(t, ok, "a multi-typed body lowers to a union") + assert.Contains(t, owner.Unmodeled, "openapi:oneOf", "the union is kept on it") + held, ok := doc.Types[variant].(*ir.Model) + require.True(t, ok, "its object variant is a model") + assert.NotContains(t, held.Unmodeled, "openapi:discriminator", "the variant never keeps it verbatim") + if !c.moves { + assert.NotNil(t, held.Discriminator, "nothing routes anywhere, so it stays") + assert.NotContains(t, owner.Unmodeled, "openapi:discriminator") + continue + } + assert.Nil(t, held.Discriminator) + entry, ok := owner.Unmodeled["openapi:discriminator"] + require.True(t, ok, "it is kept beside the union it was written for") + assert.Equal(t, ir.Provenance{Pointer: movedPointer}, entry.Provenance) + assert.Contains(t, string(entry.Value), `"mapping"`) + } + }) + } +} + +// TestDiscriminatorBesideUnion_MovesOnlyWhatItCopied pins that the move never +// loses a discriminator it could not keep verbatim. One merged in through `<<` +// is read as a written one is (GitHub #395), so Pet lowers as it does with the +// discriminator written in place. One that converts to no JSON, through an +// extension written `.nan`, is reported as unpreservable and stays on the +// model, and nothing claims a copy never made. +func TestDiscriminatorBesideUnion_MovesOnlyWhatItCopied(t *testing.T) { + t.Parallel() + const disc = "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}" + written, _ := parseFull(t, openapitest.ComponentSpec(cat+dog+petWith(disc))) + merged, diags := parseFull(t, openapitest.ComponentSpec(cat+dog+ + " Pet:\n <<: {discriminator: "+disc+"}\n type: object\n"+petBranches)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, cmp.Diff(typeByName(written, "Pet"), typeByName(merged, "Pet")), + "a merged discriminator lowers as a written one") + + doc, diags := parseFull(t, openapitest.ComponentSpec(cat+dog+ + petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}, x-weight: .nan}"))) + pet := petModel(t, doc) + require.NotNil(t, pet.Discriminator, "the model read it, so it is not lost") + assert.Equal(t, "kind", pet.Discriminator.PropertyName) + assert.NotContains(t, pet.Unmodeled, "openapi:discriminator") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnpreservableConstruct, movedPointer), + "the failed copy is reported: %+v", diags) + assert.False(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { + return strings.Contains(d.Message, "beside the union it was written for") + }), "no move is announced") +} + +// TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute pins that a +// branch the discriminator cannot route to by name decides nothing, whichever +// way it is unreachable: a $ref to an undeclared component, or one into +// another document, though its fragment spells a component this one declares, +// Lone, which is no subtype. The unresolved $refs are error diagnostics, so +// the document is checked with pass.Validate directly. +func TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute(t *testing.T) { + t.Parallel() + const branches = " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Nope'}, " + + "{$ref: 'https://example.com/x.yaml#/components/schemas/Lone'}]\n" + const lone = " Lone: {type: object}\n" + for _, c := range []struct { + name, rest string + stays bool + }{ + {"beside a subtype", subtypes + lone, true}, + {"beside a non-subtype", cat + dog + lone, false}, + } { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + pet := degradedPet(branches) + docs := make([]*ir.Document, 0, 2) + for _, src := range []string{pet + c.rest, c.rest + pet} { + doc, _ := parseFull(t, openapitest.ComponentSpec(src)) + assert.Empty(t, pass.Validate(doc), "the document must validate") + docs = append(docs, doc) + if c.stays { + assertStayed(t, doc) + continue + } + assertMoved(t, doc, `"mapping"`) + } + assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...)) + assert.Equal(t, reported(docs[0].Diagnostics), reported(docs[1].Diagnostics)) + }) + } +} + +// TestDiscriminatorBesideUnion_CyclicRouteMoves pins that a route whose +// hierarchy loops without reaching Pet is no subtype, and the check ends. +func TestDiscriminatorBesideUnion_CyclicRouteMoves(t *testing.T) { + t.Parallel() + pet := petWith("{propertyName: kind, mapping: {loop: '#/components/schemas/Loop'}}") + rest := subtypes + + " Loop: {allOf: [{$ref: '#/components/schemas/LoopB'}]}\n" + + " LoopB: {allOf: [{$ref: '#/components/schemas/Loop'}]}\n" + for _, doc := range discriminatedOrders(t, "3.1.0", pet, rest) { + assertMoved(t, doc, `"loop"`) + } +} + +// TestDiscriminatorBesideUnion_SubtypeKeepsItsTag pins what a move leaves +// alone: a route that is a subtype still carries the tag the mapping gives it, +// since that is the value its instances carry on the wire. +func TestDiscriminatorBesideUnion_SubtypeKeepsItsTag(t *testing.T) { + t.Parallel() + pet := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog'}}") + rest := subCat + dog + for _, doc := range discriminatedOrders(t, "3.1.0", pet, rest) { + assertMoved(t, doc, `"dog"`) + sub, ok := typeByName(doc, "Cat").(*ir.Model) + require.True(t, ok) + assert.Equal(t, "cat", sub.DiscriminatorValue) + } +} + +// TestDiscriminatorBesideUnion_SettlesAnOperationsSchemaToo pins that the +// settlement waits for the operations as well as the components: a request +// body lowers in the service walk, after every component. +func TestDiscriminatorBesideUnion_SettlesAnOperationsSchemaToo(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}} + oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}] + responses: + "204": {description: none} +components: + schemas: +` + cat + dog + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + const at = "/paths/~1pets/post/requestBody/content/application~1json/schema/discriminator" + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, at), "%+v", diags) +} + +// TestDiscriminatorBesideUnion_SettlesAPositionAReferenceNamesFirst pins a +// model a $ref names before its declaration lowers it: the reference builds +// nothing, so the declaration alone lowers the model and defers its settlement, +// which moves the discriminator off it once, in either order. +func TestDiscriminatorBesideUnion_SettlesAPositionAReferenceNamesFirst(t *testing.T) { + t.Parallel() + const at = "/components/schemas/Holder/properties/pet" + outside := " Outside: {$ref: '#/components/schemas/Holder/properties/pet'}\n" + holder := " Holder:\n type: object\n properties:\n pet:\n type: object\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + + " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}]\n" + for _, doc := range discriminatedOrders(t, "3.1.0", outside, holder+cat+dog) { + pet, ok := doc.Types["t/anon"+at].(*ir.Model) + require.True(t, ok) + assert.Nil(t, pet.Discriminator) + assert.Contains(t, pet.Unmodeled, "openapi:discriminator") + assert.Contains(t, openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, at+"/discriminator"), + "not a subtype", "one move, reported once") + } +} + +// petWith declares Pet, a model whose oneOf names Cat and Dog, beside the +// discriminator text given. +func petWith(discriminator string) string { + return " Pet:\n type: object\n discriminator: " + discriminator + "\n" + petBranches +} + +// discriminatedOrders compiles pet and rest in both declaration orders, rest +// first second, and requires each to validate and the two to agree on their +// types and on what they report. A decision read off the registry while Pet +// lowers sees no subtype declared after it. +func discriminatedOrders(t *testing.T, version, pet, rest string) []*ir.Document { + t.Helper() + docs := make([]*ir.Document, 0, 2) + for _, src := range []string{pet + rest, rest + pet} { + doc, diags := parseFull(t, openapitest.ComponentSpecVer(version, src)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc), "the document must validate") + docs = append(docs, doc) + } + assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...), + "declaration order must not change the types") + assert.Equal(t, reported(docs[0].Diagnostics), reported(docs[1].Diagnostics), + "declaration order must not change what is reported, only the order it is reported in") + return docs +} + +// assertMoved requires Pet's discriminator to be kept verbatim, whole, rather +// than on the model, and want to appear in what was kept. +func assertMoved(t *testing.T, doc *ir.Document, want string) { + t.Helper() + pet := petModel(t, doc) + assert.Nil(t, pet.Discriminator, "a discriminator routing to a non-subtype stays off the model") + entry, ok := pet.Unmodeled["openapi:discriminator"] + require.True(t, ok, "the discriminator is kept verbatim") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Equal(t, ir.Provenance{Pointer: movedPointer}, entry.Provenance) + assert.Contains(t, string(entry.Value), `"propertyName":"kind"`, "the whole object is kept") + assert.Contains(t, string(entry.Value), want) +} + +// assertStayed requires Pet to hold its discriminator and to store it nowhere +// else. +func assertStayed(t *testing.T, doc *ir.Document) { + t.Helper() + pet := petModel(t, doc) + require.NotNil(t, pet.Discriminator, "every route is a subtype, so the model keeps it") + assert.Equal(t, "kind", pet.Discriminator.PropertyName) + assert.NotContains(t, pet.Unmodeled, "openapi:discriminator", "nothing is stored twice") +} + +// mapPet declares Pet as a map beside its union: a catch-all is its only member +// fact other than the discriminator. +const mapPet = " Pet:\n type: object\n additionalProperties: {type: string}\n" + + " discriminator: {propertyName: kind}\n" + petBranches + +// censusReports returns the messages of the census reports made at pointer, +// each naming the keywords that position kept for want of a home. +func censusReports(diags []ir.Diagnostic, pointer jsontext.Pointer) []string { + var out []string + for _, d := range diags { + if d.Code == diag.DegradedConstruct && d.Provenance.Pointer == pointer && + strings.HasPrefix(d.Message, "this position lowered to ") { + out = append(out, d.Message) + } + } + return out +} + +// assertMovedToMap requires td to be the MapT a map body is once its +// discriminator has moved, holding the union and the discriminator verbatim. +func assertMovedToMap(t *testing.T, td ir.TypeDef) { + t.Helper() + mt, ok := td.(*ir.MapT) + require.True(t, ok, "got %T", td) + assert.Equal(t, ir.TypeRef{Target: "t/prim/string"}, mt.Value) + assert.Contains(t, mt.Unmodeled, "openapi:oneOf", "the union is kept beside it") + assert.Contains(t, mt.Unmodeled, "openapi:discriminator", "and the discriminator with it") +} + +// degradedShapes are the cases besides the discriminator itself that keep a +// union verbatim beside a model, each with the branch text that makes the +// union that case. Each goes through lowerBesideUnmodeledUnion. +var degradedShapes = []struct { + name string + branches string +}{ + {"inline branch", " oneOf: [{$ref: '#/components/schemas/Cat'}, {type: object, properties: {kind: {type: string}}}]\n"}, + {"oneOf beside anyOf", " oneOf: [{$ref: '#/components/schemas/Cat'}]\n anyOf: [{$ref: '#/components/schemas/Dog'}]\n"}, + {"validation-only branches", " oneOf: [{required: [kind]}, {required: [x]}]\n"}, +} + +// degradedPet declares Pet, a model whose discriminator maps cat to Cat, +// beside the branches given. +func degradedPet(branches string) string { + return " Pet:\n type: object\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + branches +} + +// petModel returns Pet, which lowers to a model beside its kept union. +func petModel(t *testing.T, doc *ir.Document) *ir.Model { + t.Helper() + pet, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok, "Pet lowers to a model beside its union") + assert.Contains(t, pet.Unmodeled, "openapi:oneOf", "the union is kept beside it") + return pet +} diff --git a/compilers/openapi/internal/schema/discriminator_mapping_test.go b/compilers/openapi/internal/schema/discriminator_mapping_test.go new file mode 100644 index 00000000..72dd4f0b --- /dev/null +++ b/compilers/openapi/internal/schema/discriminator_mapping_test.go @@ -0,0 +1,705 @@ +package schema_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/pass" +) + +// TestDiscriminatorMapping_InlineTargetResolvesInEitherOrder pins that a +// discriminator mapping, or a 3.2 defaultMapping, naming an inline position +// resolves whichever is declared first: the base carrying the discriminator, or +// the schema enclosing the target. With the base first the target was not yet +// built when the mapping was read, so the entry was dropped as unresolved +// (GitHub #530). The two-order harness cannot see this: it stops at the first +// error diagnostic, which that order produced. +// +// Each row's spec is written base-first, the order that failed; the other order +// moves the base's block after the owner's. +func TestDiscriminatorMapping_InlineTargetResolvesInEitherOrder(t *testing.T) { + t.Parallel() + + const subBody = `{allOf: [{$ref: '#/components/schemas/Pet'}], type: object, properties: {woof: {type: string}}}` + // pet is the discriminated base, entry its mapping or defaultMapping line. + pet := func(entry string) string { + return ` Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + ` + entry + "\n" + } + mapping := func(target string) string { return pet(`mapping: {woofer: '#` + target + `'}`) } + spec32 := func(schemas string) string { return openapitest.ComponentSpecVer("3.2.0", schemas) } + + const ( + propTarget = "/components/schemas/Kennel/properties/Dog" + itemsTarget = "/components/schemas/Kennel/items" + keywordTarget = "/components/schemas/Kennel/properties/items/items" + defsTarget = "/components/schemas/Kennel/$defs/Dog" + pathsBase = "/paths/~1a/get/responses/200/content/application~1json/schema" + pathsTarget = "/paths/~1b/get/responses/200/content/application~1json/schema/items" + ) + ownerProperty := ` Kennel: + type: object + properties: + Dog: ` + subBody + "\n" + ownerItems := ` Kennel: + type: array + items: ` + subBody + "\n" + ownerKeywordKey := ` Kennel: + type: object + properties: + items: + type: array + items: ` + subBody + "\n" + ownerDefs := ` Kennel: + type: object + $defs: + Dog: ` + subBody + "\n" + pathBase := ` /a: + get: + operationId: getA + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: {woofer: '#` + pathsTarget + `'} +` + pathOwner := ` /b: + get: + operationId: getB + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: + allOf: [{$ref: '#` + pathsBase + `'}] + type: object + properties: {woof: {type: string}} +` + + cases := []struct { + name string + spec func(string) string + base, owner string + baseID, targetID ir.TypeID + wantDefault bool + }{ + {"a property", openapitest.ComponentSpec, mapping(propTarget), ownerProperty, + componentID("Pet"), "t/anon" + propTarget, false}, + {"items", openapitest.ComponentSpec, mapping(itemsTarget), ownerItems, + componentID("Pet"), "t/anon" + itemsTarget, false}, + {"a keyword-named key", openapitest.ComponentSpec, mapping(keywordTarget), ownerKeywordKey, + componentID("Pet"), "t/anon" + keywordTarget, false}, + {"a $defs entry", openapitest.ComponentSpec, mapping(defsTarget), ownerDefs, + componentID("Pet"), "t/anon" + defsTarget, false}, + {"a 3.2 defaultMapping", spec32, pet(`defaultMapping: '#` + propTarget + `'`), ownerProperty, + componentID("Pet"), "t/anon" + propTarget, true}, + {"a /paths inline subtype", openapitest.PathsSpec, pathBase, pathOwner, + "t/anon" + pathsBase, "t/anon" + pathsTarget, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + first, diags := parseFull(t, tc.spec(tc.base+tc.owner)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, tc.spec(tc.owner+tc.base)) + openapitest.RequireNoErrorDiags(t, diags) + + for _, doc := range []*ir.Document{first, last} { + base, ok := doc.Types[tc.baseID].(*ir.Model) + require.True(t, ok, "the discriminator base is a model") + require.NotNil(t, base.Discriminator) + + subtype, ok := doc.Types[tc.targetID].(*ir.Model) + require.True(t, ok, "the mapping target is a model") + + if tc.wantDefault { + assert.Equal(t, tc.targetID, base.Discriminator.Default) + // defaultMapping is the fallback for a tag the mapping does not + // recognize, so it names no tag for its target, and an inline + // subtype has no name to imply one (GitHub #517). + assert.Empty(t, subtype.DiscriminatorValue) + } else { + assert.Equal(t, tc.targetID, base.Discriminator.Mapping["woofer"]) + assert.Equal(t, "woofer", subtype.DiscriminatorValue) + } + } + + // The type registry is a map, so this is the properly order-blind + // claim; the /paths row's operation LIST legitimately reorders with + // its two path items, which is declaration order working as + // designed and no part of what this test is pinning. + assert.Empty(t, cmp.Diff(first.Types, last.Types), + "declaring the base before or after the owner must not change the registry") + }) + } +} + +// TestDiscriminatorMapping_ToAPureRefNamesItsTarget pins GitHub #758. A +// mapping names a type, and a position that is only a $ref to another is a use +// of that one's type, so a mapping to it names what the $ref names, as a union +// variant written there does. A component is a declaration of its own and +// keeps its own node. Each row is compiled base-first and reversed: both must +// map each tag to the same type, with nothing reported, pass.Validate included. +func TestDiscriminatorMapping_ToAPureRefNamesItsTarget(t *testing.T) { + t.Parallel() + const subtype = "{allOf: [{$ref: '#/components/schemas/Pet'}], type: object, properties: {woof: {type: string}}}" + pet := " Pet:\n type: object\n required: [kind]\n properties: {kind: {type: string}}\n" + + " discriminator: {propertyName: kind, mapping: {%s}}\n" + union := " Pet:\n oneOf: [%s]\n" + + " discriminator: {propertyName: kind, mapping: {%s}}\n" + for _, c := range []struct { + name string + base string + owner string + want map[string]ir.TypeID + }{ + { + name: "a model base's mapping to a property", + base: fmt.Sprintf(pet, "woofer: '#/components/schemas/Kennel/properties/Dog'"), + owner: " Dog: " + subtype + "\n Kennel: {type: object, properties: {Dog: {$ref: '#/components/schemas/Dog'}}}\n", + want: map[string]ir.TypeID{"woofer": componentID("Dog")}, + }, + { + name: "a union's mapping to its branches", + base: fmt.Sprintf(union, "{$ref: '#/components/schemas/Cat'}, {type: object, properties: {kind: {type: string}}}", + "c: '#/components/schemas/Pet/oneOf/0', d: '#/components/schemas/Pet/oneOf/1'"), + owner: " Cat: {type: object, properties: {kind: {type: string}}}\n", + want: map[string]ir.TypeID{"c": componentID("Cat"), + "d": "t/anon/components/schemas/Pet/oneOf/1"}, + }, + { + name: "a chain of such positions", + base: fmt.Sprintf(pet, "woofer: '#/components/schemas/Kennel/properties/a'"), + owner: " Dog: " + subtype + "\n Kennel:\n type: object\n properties:\n" + + " a: {$ref: '#/components/schemas/Kennel/properties/b'}\n" + + " b: {$ref: '#/components/schemas/Dog'}\n", + want: map[string]ir.TypeID{"woofer": componentID("Dog")}, + }, + { + name: "a union branch naming a component alias", + base: fmt.Sprintf(union, "{$ref: '#/components/schemas/CatAlias'}", + "c: '#/components/schemas/Pet/oneOf/0'"), + owner: " Cat: {type: object, properties: {kind: {type: string}}}\n" + + " CatAlias: {$ref: '#/components/schemas/Cat'}\n", + want: map[string]ir.TypeID{"c": componentID("CatAlias")}, + }, + { + name: "a union branch naming the component keyed by the empty string", + base: fmt.Sprintf(union, "{$ref: '#/components/schemas/'}", "c: '#/components/schemas/'"), + owner: " Cat: {type: object, properties: {kind: {type: string}}}\n" + + " '': {$ref: '#/components/schemas/Cat'}\n", + want: map[string]ir.TypeID{"c": "t/anon/components/schemas/"}, + }, + } { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + first, diags := parseFull(t, openapitest.ComponentSpec(c.base+c.owner)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, openapitest.ComponentSpec(c.owner+c.base)) + openapitest.RequireNoErrorDiags(t, diags) + + assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...)) + for _, doc := range []*ir.Document{first, last} { + assert.Equal(t, c.want, discriminatorOf(t, doc, componentID("Pet")).Mapping) + assert.Empty(t, pass.Validate(doc), "every target is a variant") + } + }) + } +} + +// TestDiscriminatorMapping_ChainOfPositionsDeeperThanCap pins the +// read-through's cap as behaviour rather than as a comment, as +// TestAllOf_DiscriminatorValueChainDeeperThanCap does for the ancestor walk. A +// chain of $ref positions longer than the cap that is no cycle stops at the +// position the cap reached, so the mapping names that position's alias. The +// chain exactly the cap long is the control: it reads through to the component. +func TestDiscriminatorMapping_ChainOfPositionsDeeperThanCap(t *testing.T) { + t.Parallel() + // maxTypePositionHops is unexported; 64 is its committed value, and the two + // cases below straddle it. + const hopCap = 64 + for _, tc := range []struct { + name string + links int + want ir.TypeID + }{ + {"as long as the cap", hopCap, componentID("Dog")}, + {"beyond the cap", hopCap + 1, "t/anon/components/schemas/Kennel/properties/a64"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var kennel strings.Builder + kennel.WriteString(" Kennel:\n type: object\n properties:\n") + for i := range tc.links - 1 { + fmt.Fprintf(&kennel, " a%d: {$ref: '#/components/schemas/Kennel/properties/a%d'}\n", i, i+1) + } + fmt.Fprintf(&kennel, " a%d: {$ref: '#/components/schemas/Dog'}\n", tc.links-1) + spec := " Pet:\n type: object\n required: [kind]\n properties: {kind: {type: string}}\n" + + " discriminator: {propertyName: kind, mapping: {woofer: '#/components/schemas/Kennel/properties/a0'}}\n" + + " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object, properties: {woof: {type: string}}}\n" + + kennel.String() + + doc, diags := parseFull(t, openapitest.ComponentSpec(spec)) + + openapitest.RequireNoErrorDiags(t, diags) + assert.Equal(t, map[string]ir.TypeID{"woofer": tc.want}, discriminatorOf(t, doc, componentID("Pet")).Mapping, + "a0 is %d $ref positions above the component", tc.links) + }) + } +} + +// TestDiscriminatorMapping_StopsAtAPureRefItCannotFollow pins where the +// read-through stops short of a target: a $ref into another document, one to +// nothing, and one on a loop name no position a type is read at, so the +// mapping keeps naming the position that $ref sits at, in either order of the +// entries, and the $ref is reported where it is written. +func TestDiscriminatorMapping_StopsAtAPureRefItCannotFollow(t *testing.T) { + t.Parallel() + const branches = " oneOf: [{$ref: 'other.yaml#/X'}, {$ref: '#/x-lib/Missing'}, {$ref: '#/components/schemas/Pet/oneOf/2'}]\n" + for _, mapping := range []string{"{e: '#/components/schemas/Pet/oneOf/0', m: '#/components/schemas/Pet/oneOf/1', " + + "l: '#/components/schemas/Pet/oneOf/2'}", "{l: '#/components/schemas/Pet/oneOf/2', " + + "m: '#/components/schemas/Pet/oneOf/1', e: '#/components/schemas/Pet/oneOf/0'}"} { + doc, diags := parseFull(t, openapitest.ComponentSpec(" Pet:\n"+branches+ + " discriminator: {propertyName: kind, mapping: "+mapping+"}\n")+"x-lib: {}\n") + + branch := func(i string) ir.TypeID { return ir.TypeID("t/anon/components/schemas/Pet/oneOf/" + i) } + assert.Equal(t, map[string]ir.TypeID{"e": branch("0"), "m": branch("1"), "l": branch("2")}, + discriminatorOf(t, doc, componentID("Pet")).Mapping, mapping) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnresolvedRef, "/components/schemas/Pet/oneOf/0"), mapping) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnresolvedRef, "/components/schemas/Pet/oneOf/1"), mapping) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.CyclicRef, "/components/schemas/Pet/oneOf/2"), mapping) + } +} + +// TestDiscriminatorMapping_ThroughARefNamingTheSourceByItsFileName pins a +// pure $ref position whose $ref names the source by its file name, with +// external references off: a same-document reference, read as the source, so +// the mapping reaches the schema it names in either order of its entries. Read +// as another document, it would be refused, and the mapping would name whatever +// interned the next position first. +func TestDiscriminatorMapping_ThroughARefNamingTheSourceByItsFileName(t *testing.T) { + t.Parallel() + pet := func(mapping string) string { + return " Pet:\n type: object\n required: [kind]\n properties: {kind: {type: string}}\n" + + " discriminator: {propertyName: kind, mapping: {" + mapping + "}}\n" + } + const lib = "x-lib:\n Cat: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + + " CatAlias: {$ref: 'spec.yaml#/x-lib/Cat'}\n" + for _, mapping := range []string{"cat: '#/x-lib/Cat', alias: '#/x-lib/CatAlias'", + "alias: '#/x-lib/CatAlias', cat: '#/x-lib/Cat'"} { + doc, diags := parseFull(t, openapitest.ComponentSpec(pet(mapping))+lib) + openapitest.RequireNoErrorDiags(t, diags) + got := discriminatorOf(t, doc, componentID("Pet")).Mapping + assert.Equal(t, map[string]ir.TypeID{"cat": "t/anon/x-lib/Cat", "alias": "t/anon/x-lib/Cat"}, got, mapping) + } +} + +// TestDiscriminatorMapping_ToASubtypeThroughAPureRefTagsIt pins the subtype's +// side of GitHub #758: the mapping key that names a subtype through a pure +// $ref position is its discriminatorValue, in either order, not its name. +func TestDiscriminatorMapping_ToASubtypeThroughAPureRefTagsIt(t *testing.T) { + t.Parallel() + const base = ` Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: {propertyName: kind, mapping: {woofer: '#/components/schemas/Kennel/properties/Dog'}} +` + const owner = ` Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object} + Kennel: {type: object, properties: {Dog: {$ref: '#/components/schemas/Dog'}}} +` + for _, spec := range []string{base + owner, owner + base} { + doc, diags := parseFull(t, openapitest.ComponentSpec(spec)) + openapitest.RequireNoErrorDiags(t, diags) + dog, ok := doc.Types[componentID("Dog")].(*ir.Model) + require.True(t, ok) + assert.Equal(t, "woofer", dog.DiscriminatorValue) + } +} + +// TestDiscriminatorMapping_NamesWhatAVariantWrittenThereNames pins that the +// read-through stops exactly where the lowering gives a $ref position a node of +// its own. A branch carries the $ref beside one keyword at a time, and the +// mapping to it must name what the union's variant there names: the target, +// or the alias holding what the keyword says. A keyword the lowering keeps on +// an alias, but the read-through did not stop for, makes them differ. +func TestDiscriminatorMapping_NamesWhatAVariantWrittenThereNames(t *testing.T) { + t.Parallel() + for _, sibling := range []string{ + "", "description: d", "title: t", "deprecated: true", "example: {}", "examples: [{}]", + "x-ext: 1", "minProperties: 1", "readOnly: true", "not: {required: [x]}", + "oneOf: [{type: object}]", "allOf: [{type: object}]", "if: {required: [x]}", + "$comment: c", "externalDocs: {url: 'https://example.com'}", "nullable: true", + "properties: {extra: {type: string}}", "required: [kind]", "type: object", + } { + branch := "{$ref: '#/components/schemas/Cat'}" + if sibling != "" { + branch = "{$ref: '#/components/schemas/Cat', " + sibling + "}" + } + doc, _ := parseFull(t, openapitest.ComponentSpec( + " Pet:\n oneOf: ["+branch+"]\n discriminator: {propertyName: kind, mapping: {c: '#/components/schemas/Pet/oneOf/0'}}\n"+ + " Cat: {type: object, properties: {kind: {type: string}}}\n")) + pet, ok := doc.Types[componentID("Pet")].(*ir.Union) + require.True(t, ok, sibling) + require.Len(t, pet.Variants, 1, sibling) + assert.Equal(t, pet.Variants[0].Type.Target, pet.Discriminator.Mapping["c"], "beside %q", sibling) + } +} + +// TestDiscriminatorMapping_NamesTheVariantNamingThePosition pins a union whose +// variant is a $ref to the position its mapping names, or to a position on the +// way there. That $ref hoists an alias at the position, as a $ref to any +// position does, so the mapping names that alias and not the target past it, +// which is no variant (GitHub #758). Where no variant names the position, the +// mapping still reads through it. Each row is compiled with the union first and +// again last: both must map the tag to the variant's type, which pass.Validate +// accepts, and build the same registry. +func TestDiscriminatorMapping_NamesTheVariantNamingThePosition(t *testing.T) { + t.Parallel() + const ( + cat = " Cat: {type: object, properties: {kind: {type: string}}}\n" + catRef = "x-lib:\n CatRef: {$ref: '#/components/schemas/Cat'}\n" + ) + union := func(branch, discriminator string) string { + return " Pet:\n oneOf: [{$ref: '" + branch + "'}]\n" + + " discriminator: {propertyName: kind, " + discriminator + "}\n" + } + tests := []struct { + name string + version string + components []string + tail string + variant ir.TypeID // what the variant names + isDefault bool // the discriminator's defaultMapping, not its mapping c + }{ + { + name: "an extension's value", + components: []string{union("#/x-lib/CatRef", "mapping: {c: '#/x-lib/CatRef'}"), cat}, + tail: catRef, + variant: "t/anon/x-lib/CatRef", + }, + { + name: "a property", + components: []string{union("#/components/schemas/Lib/properties/c", + "mapping: {c: '#/components/schemas/Lib/properties/c'}"), cat, + " Lib: {type: object, properties: {c: {$ref: '#/components/schemas/Cat'}}}\n"}, + variant: "t/anon/components/schemas/Lib/properties/c", + }, + { + name: "a $defs entry", + components: []string{union("#/$defs/C", "mapping: {c: '#/$defs/C'}") + + " $defs: {C: {$ref: '#/components/schemas/Cat'}}\n", cat}, + variant: "t/anon/components/schemas/Pet/$defs/C", + }, + { + name: "another union's branch", + components: []string{union("#/components/schemas/Other/oneOf/0", + "mapping: {c: '#/components/schemas/Other/oneOf/0'}"), cat, + " Other: {oneOf: [{$ref: '#/components/schemas/Cat'}, {type: string}]}\n"}, + variant: "t/anon/components/schemas/Other/oneOf/0", + }, + { + name: "a 3.2 defaultMapping", + version: "3.2.0", + components: []string{union("#/x-lib/CatRef", "defaultMapping: '#/x-lib/CatRef'"), cat}, + tail: catRef, + variant: "t/anon/x-lib/CatRef", + isDefault: true, + }, + { + name: "the union's own branch, through to the position its $ref names", + components: []string{union("#/x-lib/A", "mapping: {c: '#/components/schemas/Pet/oneOf/0'}"), cat}, + tail: "x-lib:\n A: {$ref: '#/x-lib/B'}\n B: {$ref: '#/components/schemas/Cat'}\n", + variant: "t/anon/x-lib/A", + }, + { + name: "a position no variant names, read through to the variant", + components: []string{" Pet:\n oneOf: [{$ref: '#/components/schemas/Cat'}]\n" + + " discriminator: {propertyName: kind, mapping: {c: '#/x-lib/CatRef'}}\n", cat}, + tail: catRef, + variant: componentID("Cat"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + version := tc.version + if version == "" { + version = "3.1.0" + } + first, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, false)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, true)) + openapitest.RequireNoErrorDiags(t, diags) + + for _, doc := range []*ir.Document{first, last} { + pet, ok := doc.Types[componentID("Pet")].(*ir.Union) + require.True(t, ok, "Pet is a union") + require.Len(t, pet.Variants, 1) + require.NotNil(t, pet.Discriminator) + assert.Equal(t, tc.variant, pet.Variants[0].Type.Target) + got := pet.Discriminator.Mapping["c"] + if tc.isDefault { + got = pet.Discriminator.Default + } + assert.Equal(t, pet.Variants[0].Type.Target, got, "the mapping names what the variant names") + assert.Empty(t, pass.Validate(doc), "every target is a variant") + } + assert.Empty(t, cmp.Diff(first.Types, last.Types), + "declaring the union before or after what its variant names must not change the registry") + }) + } +} + +// TestDiscriminatorMapping_HoistedTargetKeepsItsDiagnostics pins that what +// lowering a mapping target reports reaches the compile's diagnostics, for a +// mapping entry and a defaultMapping alike. The targets sit under not and if, +// which no declaration lowers, so the hoist is the only lowering that sees the +// format each declares beside a model: dropping its findings would lose them. +func TestDiscriminatorMapping_HoistedTargetKeepsItsDiagnostics(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.2.0", ` Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: {n: '#/components/schemas/Kennel/not'} + defaultMapping: '#/components/schemas/Kennel/if' + Kennel: + type: object + not: {type: object, format: wat, properties: {a: {type: string}}} + if: {type: object, format: huh, properties: {b: {type: string}}} + then: {required: [b]} +`)) + openapitest.RequireNoErrorDiags(t, diags) + + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) + require.True(t, ok, "Pet is a model") + require.NotNil(t, pet.Discriminator) + assert.Equal(t, ir.TypeID("t/anon/components/schemas/Kennel/not"), pet.Discriminator.Mapping["n"]) + assert.Equal(t, ir.TypeID("t/anon/components/schemas/Kennel/if"), pet.Discriminator.Default) + + for _, pointer := range []string{"/components/schemas/Kennel/not", "/components/schemas/Kennel/if"} { + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, pointer), + "the format beside the model hoisted at %s is reported", pointer) + } +} + +// TestMapping_InAnotherDocumentIsReportedWhereItIsWritten pins the report of a +// discriminator mapping value another document writes that names nothing +// there: once, at the value in that document's Document.Sources entry, in the +// words a mapping in the source draws, so the message does not name the +// document its Provenance already does. The value naming what it declares +// resolves and draws nothing. +func TestMapping_InAnotherDocumentIsReportedWhereItIsWritten(t *testing.T) { + t.Parallel() + _, diags, _ := compileBeside(t, map[string]string{"pets.yaml": `Pet: + oneOf: [{$ref: '#/Cat'}] + discriminator: {propertyName: kind, mapping: {cat: '#/Cat', dog: '#/Dog'}} +Cat: {type: object, properties: {kind: {type: string}}} +`}, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + A: {$ref: './pets.yaml#/Pet'} +`) + + assert.Empty(t, cmp.Diff([]ir.Diagnostic{{Severity: ir.SeverityError, Code: "openapi/unresolved-ref", + Message: `discriminator mapping "dog" references unresolved schema "#/Dog": ` + + "it names nothing in the document it points into", + Provenance: ir.Provenance{Source: 1, Pointer: "/Pet/discriminator/mapping/dog"}}}, + withCode(diags, "openapi/unresolved-ref"))) +} + +// TestDiscriminatorMapping_ToRawTargetsIsOrderInvariant pins that a mapping or +// defaultMapping naming a position the parsed model holds as raw YAML, an +// extension's value or an enum member, resolves to the type at that position +// whichever order the document declares things in (GitHub #757). Each row is +// written in the order that failed, with the mapping before any $ref to its +// target, and compiled again reversed. Neither compile may report an error, +// and both must produce the same registry. +func TestDiscriminatorMapping_ToRawTargetsIsOrderInvariant(t *testing.T) { + t.Parallel() + const ( + petSub = "allOf: [{$ref: '#/components/schemas/Pet'}], type: object" + cat = " Cat: {" + petSub + ", properties: {meow: {type: string}}}\n" + ) + pet := componentID("Pet") + anon := func(pointer string) ir.TypeID { return ir.TypeID("t/anon/" + pointer) } + model := func(base ir.TypeID, tag, pointer string) wantMapping { + return wantMapping{base: base, tag: tag, target: anon(pointer), kind: ir.KindModel} + } + tests := []struct { + name string + version string + components []string + tail string + wants []wantMapping + }{ + { + name: "an extension's value named by a mapping alone", + components: []string{rawBase("mapping: {c: '#/x-lib/Cat'}")}, + tail: "x-lib:\n" + cat, + wants: []wantMapping{model(pet, "c", "x-lib/Cat")}, + }, + { + name: "an extension's value also named by a $ref", + components: []string{ + rawBase("mapping: {c: '#/x-lib/Cat'}"), + " Holder: {$ref: '#/x-lib/Cat'}\n", + }, + tail: "x-lib:\n" + cat, + wants: []wantMapping{model(pet, "c", "x-lib/Cat")}, + }, + { + name: "an enum member", + components: []string{ + rawBase("mapping: {e: '#/components/schemas/Kennel/properties/e/enum/0'}"), + " Kennel:\n type: object\n properties:\n e:\n enum:\n" + + " - {type: object, properties: {wingspan: {type: number}}}\n", + }, + // A member that is an object is lowered to a literal, which no model + // subtypes, so only the resolution is pinned here, not a variant. + wants: []wantMapping{{pet, "e", anon("components/schemas/Kennel/properties/e/enum/0"), ir.KindLiteral}}, + }, + { + name: "a defaultMapping to an extension's value", + version: "3.2.0", + components: []string{rawBase("defaultMapping: '#/x-lib/Cat'")}, + tail: "x-lib:\n" + cat, + wants: []wantMapping{model(pet, "", "x-lib/Cat")}, + }, + { + name: "a mapping in a raw schema only a $ref reaches", + components: []string{" Holder: {$ref: '#/x-lib/Inner'}\n"}, + tail: "x-lib:\n" + + " Inner:\n type: object\n required: [tag]\n properties: {tag: {type: string}}\n" + + " discriminator: {propertyName: tag, mapping: {d: '#/x-lib/Dog'}}\n" + + " Dog: {allOf: [{$ref: '#/x-lib/Inner'}], type: object}\n", + wants: []wantMapping{model(anon("x-lib/Inner"), "d", "x-lib/Dog")}, + }, + { + name: "a mapping in a raw schema only another mapping reaches", + components: []string{rawBase("mapping: {i: '#/x-lib/Inner'}")}, + tail: "x-lib:\n" + + " Inner:\n allOf: [{$ref: '#/components/schemas/Pet'}]\n type: object\n" + + " required: [tag]\n properties: {tag: {type: string}}\n" + + " discriminator: {propertyName: tag, mapping: {d: '#/x-lib/Dog'}}\n" + + " Dog: {allOf: [{$ref: '#/x-lib/Inner'}], type: object}\n", + wants: []wantMapping{ + model(pet, "i", "x-lib/Inner"), + model(anon("x-lib/Inner"), "d", "x-lib/Dog"), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + version := tc.version + if version == "" { + version = "3.1.0" + } + first, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, false)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, true)) + openapitest.RequireNoErrorDiags(t, diags) + + for _, doc := range []*ir.Document{first, last} { + for _, want := range tc.wants { + base, ok := doc.Types[want.base].(*ir.Model) + require.True(t, ok, "%s is a model", want.base) + require.NotNil(t, base.Discriminator, "%s is discriminated", want.base) + + got := base.Discriminator.Default + if want.tag != "" { + got = base.Discriminator.Mapping[want.tag] + } + assert.Equal(t, want.target, got, "%s %q", want.base, want.tag) + + target, ok := doc.Types[want.target] + require.True(t, ok, "the target %s is in the registry", want.target) + assert.Equal(t, want.kind, target.Kind(), "the kind of %s", want.target) + } + } + + assert.Empty(t, cmp.Diff(first.Types, last.Types), + "declaring the mapping before or after what names its target must not change the registry") + }) + } +} + +// discriminatorOf returns the discriminator the model or union id declares. +func discriminatorOf(t *testing.T, doc *ir.Document, id ir.TypeID) *ir.Discriminator { + t.Helper() + switch td := doc.Types[id].(type) { + case *ir.Model: + require.NotNil(t, td.Discriminator, id) + return td.Discriminator + case *ir.Union: + require.NotNil(t, td.Discriminator, id) + return td.Discriminator + default: + require.Failf(t, "not a discriminated type", "%s is a %T", id, td) + return nil + } +} + +// rawBase is a discriminated Pet whose mapping is mapping, followed by whatever +// the row declares after it. +func rawBase(mapping string) string { + return " Pet:\n" + + " type: object\n" + + " required: [kind]\n" + + " properties: {kind: {type: string}}\n" + + " discriminator: {propertyName: kind, " + mapping + "}\n" +} + +// rawSpec writes components in the order given, and the root-level extensions +// tail after them. With reversed set it declares the components in the opposite +// order and the extensions ahead of them. +func rawSpec(version string, components []string, tail string, reversed bool) string { + if !reversed { + return openapitest.ComponentSpecVer(version, strings.Join(components, "")) + tail + } + rev := make([]string, len(components)) + for i, c := range components { + rev[len(components)-1-i] = c + } + doc := openapitest.ComponentSpecVer(version, strings.Join(rev, "")) + return strings.Replace(doc, "paths: {}\n", tail+"paths: {}\n", 1) +} + +// wantMapping names a discriminator, one of its mapping entries (or its default +// when tag is empty), and the type of the given kind that entry must resolve to. +type wantMapping struct { + base ir.TypeID + tag string + target ir.TypeID + kind ir.TypeKind +} diff --git a/compilers/openapi/internal/schema/discriminator_test.go b/compilers/openapi/internal/schema/discriminator_test.go index 40fc541f..46e73bde 100644 --- a/compilers/openapi/internal/schema/discriminator_test.go +++ b/compilers/openapi/internal/schema/discriminator_test.go @@ -1,13 +1,8 @@ package schema_test import ( - "encoding/json/jsontext" - "fmt" - "slices" - "strings" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -15,376 +10,8 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" - "github.com/dexpace/morphic/pass" ) -func TestAllOf_DiscriminatorSubtypeValue(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Pet: - type: object - discriminator: - propertyName: petType - mapping: - kitty: "#/components/schemas/Cat" - properties: - petType: {type: string} - Cat: - allOf: - - {$ref: "#/components/schemas/Pet"} - - type: object - properties: - meow: {type: string} - Dog: - allOf: - - {$ref: "#/components/schemas/Pet"} - - type: object - properties: - bark: {type: string} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - pet, ok := doc.Types[componentID("Pet")].(*ir.Model) - require.True(t, ok, "Pet should be a model") - require.NotNil(t, pet.Discriminator, "the base carries the discriminator") - assert.Empty(t, pet.DiscriminatorValue, "the base itself has no wire tag value") - - cat, ok := doc.Types[componentID("Cat")].(*ir.Model) - require.True(t, ok, "Cat should be a model") - assert.Equal(t, componentID("Pet"), cat.Base.Target) - assert.Equal(t, "kitty", cat.DiscriminatorValue, - "mapping key pointing at Cat becomes its wire tag value") - - dog, ok := doc.Types[componentID("Dog")].(*ir.Model) - require.True(t, ok, "Dog should be a model") - assert.Equal(t, componentID("Pet"), dog.Base.Target) - assert.Equal(t, "Dog", dog.DiscriminatorValue, - "a subtype absent from the mapping falls back to its schema name") -} - -// TestAllOf_InlineSubtypeHasNoImplicitDiscriminatorValue pins GitHub #517. -// OpenAPI's implicit discriminator mapping names a subtype by its component -// schema name; an inline subtype — declared here as a property, once keyed like -// the unrelated component Dog and once keyed like nothing in the document — -// has no schema name of its own and so gets no implicit tag. Before the fix the -// fallback read the subtype's pointer's last token instead, so Kennel's "Dog" -// property claimed the same tag as the component Dog, and its "pet" property -// claimed its own property key as a tag the document never assigned. -func TestAllOf_InlineSubtypeHasNoImplicitDiscriminatorValue(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Pet: - type: object - required: [kind] - properties: - kind: {type: string} - discriminator: - propertyName: kind - Dog: - allOf: - - $ref: '#/components/schemas/Pet' - type: object - properties: - bark: {type: string} - Kennel: - type: object - properties: - Dog: - allOf: - - $ref: '#/components/schemas/Pet' - type: object - properties: - woof: {type: string} - pet: - allOf: - - $ref: '#/components/schemas/Pet' - type: object - properties: - meow: {type: string} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - kennelDog := ir.TypeID("t/anon/components/schemas/Kennel/properties/Dog") - kennelPet := ir.TypeID("t/anon/components/schemas/Kennel/properties/pet") - want := map[ir.TypeID]string{ - componentID("Dog"): "Dog", - kennelDog: "", - kennelPet: "", - } - got := discriminatorValues(t, doc, componentID("Dog"), kennelDog, kennelPet) - assert.Empty(t, cmp.Diff(want, got), - "the component keeps its implicit tag; neither inline subtype gets one") -} - -// TestAllOf_InlineSubtypeDiscriminatorValueFromMapping pins the path the #517 -// fix leaves untouched: a mapping entry can still name an inline subtype by -// JSON reference, and mappingTagsFor — not subtypeDiscriminatorValue's -// implicit-name fallback — is what gives it that tag. -func TestAllOf_InlineSubtypeDiscriminatorValueFromMapping(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Kennel: - type: object - properties: - Dog: - allOf: - - $ref: '#/components/schemas/Pet' - type: object - properties: - woof: {type: string} - pet: - allOf: - - $ref: '#/components/schemas/Pet' - type: object - properties: - meow: {type: string} - Pet: - type: object - required: [kind] - properties: - kind: {type: string} - discriminator: - propertyName: kind - mapping: - woofer: '#/components/schemas/Kennel/properties/Dog' - Dog: - allOf: - - $ref: '#/components/schemas/Pet' - type: object - properties: - bark: {type: string} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - kennelDog := ir.TypeID("t/anon/components/schemas/Kennel/properties/Dog") - kennelPet := ir.TypeID("t/anon/components/schemas/Kennel/properties/pet") - want := map[ir.TypeID]string{ - componentID("Dog"): "Dog", - kennelDog: "woofer", - kennelPet: "", - } - got := discriminatorValues(t, doc, componentID("Dog"), kennelDog, kennelPet) - assert.Empty(t, cmp.Diff(want, got), - "a mapping entry names Kennel's inline Dog by JSON reference; pet still gets no implicit tag") - - pet, ok := doc.Types[componentID("Pet")].(*ir.Model) - require.True(t, ok, "Pet should be a model") - require.NotNil(t, pet.Discriminator) - assert.Equal(t, map[string]ir.TypeID{"woofer": kennelDog}, pet.Discriminator.Mapping, - "the base's own mapping resolves the same inline subtype the tag was read from") -} - -// TestAllOf_DiscriminatorValueFromDistantMapping pins the mapping-key spelling of -// a tag at depth: the root names a grandchild explicitly, and the grandchild's own -// branch is an intermediate that declares no discriminator, so the key is only -// reachable by walking past the immediate parent (GitHub #305). -// -// The conformance case discriminator-transitive.yaml witnesses the same walk. -func TestAllOf_DiscriminatorValueFromDistantMapping(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Pet: - type: object - required: [petType] - properties: {petType: {type: string}} - discriminator: - propertyName: petType - mapping: - dog: '#/components/schemas/Dog' - puppy: '#/components/schemas/Puppy' - Dog: - allOf: - - {$ref: '#/components/schemas/Pet'} - properties: {bark: {type: boolean}} - Puppy: - allOf: - - {$ref: '#/components/schemas/Dog'} - properties: {weeks: {type: integer}} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - dog := typeByName(doc, "Dog").(*ir.Model) - assert.Equal(t, "dog", dog.DiscriminatorValue, "the direct child takes its own mapping key") - - puppy := typeByName(doc, "Puppy").(*ir.Model) - require.NotNil(t, puppy.Base) - assert.Equal(t, componentID("Dog"), puppy.Base.Target, - "Puppy composes Dog, which declares no discriminator of its own") - assert.Equal(t, "puppy", puppy.DiscriminatorValue, - "the key Pet's mapping spells for Puppy survives the intermediate") -} - -// TestAllOf_DiscriminatorValueFromNearestAncestor pins which of two discriminated -// ancestors supplies the tag when both mappings name the same subtype. The walk -// is level by level so the answer is the nearer one, rather than whichever chain -// happened to be walked first — a subtype's own hierarchy outranks one it reaches -// only through a longer composition. -func TestAllOf_DiscriminatorValueFromNearestAncestor(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Far: - type: object - properties: {k: {type: string}} - discriminator: - propertyName: k - mapping: {far: '#/components/schemas/Leaf'} - Mid: - allOf: - - {$ref: '#/components/schemas/Far'} - properties: {m: {type: string}} - Near: - type: object - properties: {n: {type: string}} - discriminator: - propertyName: n - mapping: {near: '#/components/schemas/Leaf'} - Leaf: - allOf: - - {$ref: '#/components/schemas/Mid'} - - {$ref: '#/components/schemas/Near'} - properties: {l: {type: string}} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - leaf := typeByName(doc, "Leaf").(*ir.Model) - assert.Equal(t, "near", leaf.DiscriminatorValue, - "Near is one hop up and Far two, so Near's mapping key wins") -} - -// TestAllOf_DiscriminatorValueUndiscriminatedChain is the boundary of the -// ancestor walk: composing at any depth is not on its own a reason to carry a -// tag. Without this, a walk that fell back to the implicit schema name whenever -// it found a base would stamp every composed model in the document. -func TestAllOf_DiscriminatorValueUndiscriminatedChain(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Plant: - type: object - properties: {stem: {type: string}} - Shrub: - allOf: - - {$ref: '#/components/schemas/Plant'} - properties: {twigs: {type: integer}} - Sapling: - allOf: - - {$ref: '#/components/schemas/Shrub'} - properties: {rings: {type: integer}} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - for _, name := range []string{"Shrub", "Sapling"} { - m := typeByName(doc, name).(*ir.Model) - require.NotNil(t, m.Base, "%s composes a base", name) - assert.Empty(t, m.DiscriminatorValue, - "%s has no discriminated ancestor at any depth", name) - } -} - -// TestAllOf_DiscriminatorValueCyclicComposition drives the ancestor walk over a -// composition cycle. Nothing upstream refuses one — this spec compiles without an -// error diagnostic — so the walk meets it, and the visited set rather than the -// depth cap is what has to stop it. -// -// The fan-out is what makes that a real claim: every schema composes every other, -// so a walk that re-entered a schema it had already left would branch five ways -// per level for the length of the cap instead of terminating. -func TestAllOf_DiscriminatorValueCyclicComposition(t *testing.T) { - t.Parallel() - const n = 6 - var b strings.Builder - for i := range n { - refs := make([]string, 0, n-1) - for j := range n { - if j != i { - refs = append(refs, fmt.Sprintf("{$ref: '#/components/schemas/X%d'}", j)) - } - } - fmt.Fprintf(&b, " X%d:\n allOf: [%s]\n properties: {p%d: {type: string}}\n", - i, strings.Join(refs, ", "), i) - } - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(b.String())) - openapitest.RequireNoErrorDiags(t, diags) - - for i := range n { - name := fmt.Sprintf("X%d", i) - m, ok := typeByName(doc, name).(*ir.Model) - require.True(t, ok, "%s lowers to a Model", name) - assert.Empty(t, m.DiscriminatorValue, "%s has no discriminated ancestor", name) - } -} - -// TestAllOf_DiscriminatorValueSelfAncestorInCycle pins that a composition cycle -// does not make a schema one of its own ancestors. A declares the discriminator -// and composes B, which composes A back, so walking A's ancestors comes round to -// A — whose mapping names A. A hierarchy of one schema standing above itself is -// not a hierarchy, so the walk skips the schema it started from. -func TestAllOf_DiscriminatorValueSelfAncestorInCycle(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` A: - allOf: [{$ref: '#/components/schemas/B'}] - required: [k] - properties: {k: {type: string}} - discriminator: - propertyName: k - mapping: - selfkey: '#/components/schemas/A' - B: - allOf: [{$ref: '#/components/schemas/A'}] - properties: {b: {type: string}} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - a, ok := typeByName(doc, "A").(*ir.Model) - require.True(t, ok, "A lowers to a Model") - require.NotNil(t, a.Discriminator, "A anchors the hierarchy it declares") - assert.Empty(t, a.DiscriminatorValue, - "and takes no tag from its own mapping by way of the cycle") -} - -// TestAllOf_DiscriminatorValueChainDeeperThanCap pins the ancestor walk's cap as -// behaviour rather than as a comment. A chain longer than the cap stops being -// searched, so the tag the root would have supplied is not stamped — stated here -// so the limit is a decision on record and a chain that grows past it is a -// failing test rather than a silent reversion to the bug this walk fixed. -// -// The shallower half is the control: the same construction one level inside the -// cap does carry the tag, which is what says the deep case failed on the cap and -// not on the construction. -func TestAllOf_DiscriminatorValueChainDeeperThanCap(t *testing.T) { - t.Parallel() - // maxDiscriminatorAncestorDepth is unexported; 256 is its committed value, and - // the two cases below straddle it. - const ancestorCap = 256 - for _, tc := range []struct { - name string - links int - want string - }{ - {"within the cap", ancestorCap - 1, fmt.Sprintf("S%d", ancestorCap-2)}, - {"beyond the cap", ancestorCap + 4, ""}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - var b strings.Builder - b.WriteString(" Root:\n type: object\n properties: {k: {type: string}}\n" + - " discriminator: {propertyName: k}\n") - prev := "Root" - for i := range tc.links { - fmt.Fprintf(&b, " S%d:\n allOf: [{$ref: '#/components/schemas/%s'}]\n"+ - " properties: {p%d: {type: string}}\n", i, prev, i) - prev = fmt.Sprintf("S%d", i) - } - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(b.String())) - openapitest.RequireNoErrorDiags(t, diags) - - leaf := typeByName(doc, prev).(*ir.Model) - assert.Equal(t, tc.want, leaf.DiscriminatorValue, - "%s is %d links below the discriminated root", prev, tc.links) - }) - } -} - func TestModelDiscriminator_UndeclaredPropertyAndBadMapping(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` Vehicle: @@ -422,1320 +49,3 @@ func TestOneOf_DiscriminatorWithDefault(t *testing.T) { assert.Equal(t, "shapeType", u.Discriminator.PropertyName) assert.NotEmpty(t, u.Discriminator.Default) } - -func TestAllOf_ModelWithOwnDiscriminator(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Base: - allOf: - - {$ref: '#/components/schemas/Common'} - discriminator: {propertyName: kind} - properties: {kind: {type: string}} - Common: {type: object, properties: {id: {type: string}}} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - base := typeByName(doc, "Base").(*ir.Model) - require.NotNil(t, base.Discriminator, "allOf model may declare its own discriminator") - assert.NotEmpty(t, base.Discriminator.Property) -} - -func TestAllOf_BoolRefBranchHasNoDiscriminator(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` BoolComp: false - Sub: - allOf: - - {$ref: '#/components/schemas/BoolComp'} - - {type: object, properties: {a: {type: string}}} -`) - doc, diags := schema.LowerSpec(t, spec) - require.NotNil(t, doc) - sub := typeByName(doc, "Sub").(*ir.Model) - assert.Empty(t, sub.DiscriminatorValue, "a bool-schema ref target anchors no hierarchy") - _ = diags -} - -// TestAllOf_DiscriminatorAliasTagIsOrderInvariant pins that a subtype two mapping -// keys name takes the same tag whichever key is written first (GitHub #410). -// Model.DiscriminatorValue holds one value, and a mapping is unordered, so the -// key it holds must be chosen by a rule the source order cannot reach: the -// smallest in byte order. The base keeps every key, so nothing is lost, and the -// election is reported so a reader knows the subtype's tag set is wider than -// the field shows. -func TestAllOf_DiscriminatorAliasTagIsOrderInvariant(t *testing.T) { - t.Parallel() - const base = ` Pet: - type: object - required: [k] - properties: {k: {type: string}} - discriminator: - propertyName: k - mapping: -` - const sub = ` Dog: - allOf: [{$ref: '#/components/schemas/Pet'}] - properties: {bark: {type: boolean}} -` - const alpha = " alpha: '#/components/schemas/Dog'\n" - const zulu = " zulu: '#/components/schemas/Dog'\n" - - for _, tc := range []struct{ name, mapping string }{ - {"zulu first", zulu + alpha}, - {"alpha first", alpha + zulu}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(base+tc.mapping+sub)) - openapitest.RequireNoErrorDiags(t, diags) - - dog, ok := doc.Types[componentID("Dog")].(*ir.Model) - require.True(t, ok, "Dog should be a model") - assert.Equal(t, "alpha", dog.DiscriminatorValue, - "the smallest key in byte order, whichever was written first") - - pet, ok := doc.Types[componentID("Pet")].(*ir.Model) - require.True(t, ok, "Pet should be a model") - assert.Equal(t, map[string]ir.TypeID{"alpha": componentID("Dog"), "zulu": componentID("Dog")}, - pet.Discriminator.Mapping, "the base keeps both keys") - - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Dog"), - "the election is reported at the subtype: %+v", diags) - assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "as information, since the base's mapping loses nothing") - }) - } -} - -// TestDiscriminatorMapping_InlineTargetResolvesInEitherOrder pins that a -// discriminator mapping, or a 3.2 defaultMapping, naming an inline position -// resolves whichever is declared first: the base carrying the discriminator, or -// the schema enclosing the target. With the base first the target was not yet -// built when the mapping was read, so the entry was dropped as unresolved -// (GitHub #530). The two-order harness cannot see this: it stops at the first -// error diagnostic, which that order produced. -// -// Each row's spec is written base-first, the order that failed; the other order -// moves the base's block after the owner's. -func TestDiscriminatorMapping_InlineTargetResolvesInEitherOrder(t *testing.T) { - t.Parallel() - - const subBody = `{allOf: [{$ref: '#/components/schemas/Pet'}], type: object, properties: {woof: {type: string}}}` - // pet is the discriminated base, entry its mapping or defaultMapping line. - pet := func(entry string) string { - return ` Pet: - type: object - required: [kind] - properties: {kind: {type: string}} - discriminator: - propertyName: kind - ` + entry + "\n" - } - mapping := func(target string) string { return pet(`mapping: {woofer: '#` + target + `'}`) } - spec32 := func(schemas string) string { return openapitest.ComponentSpecVer("3.2.0", schemas) } - - const ( - propTarget = "/components/schemas/Kennel/properties/Dog" - itemsTarget = "/components/schemas/Kennel/items" - keywordTarget = "/components/schemas/Kennel/properties/items/items" - defsTarget = "/components/schemas/Kennel/$defs/Dog" - pathsBase = "/paths/~1a/get/responses/200/content/application~1json/schema" - pathsTarget = "/paths/~1b/get/responses/200/content/application~1json/schema/items" - ) - ownerProperty := ` Kennel: - type: object - properties: - Dog: ` + subBody + "\n" - ownerItems := ` Kennel: - type: array - items: ` + subBody + "\n" - ownerKeywordKey := ` Kennel: - type: object - properties: - items: - type: array - items: ` + subBody + "\n" - ownerDefs := ` Kennel: - type: object - $defs: - Dog: ` + subBody + "\n" - pathBase := ` /a: - get: - operationId: getA - responses: - "200": - description: ok - content: - application/json: - schema: - type: object - required: [kind] - properties: {kind: {type: string}} - discriminator: - propertyName: kind - mapping: {woofer: '#` + pathsTarget + `'} -` - pathOwner := ` /b: - get: - operationId: getB - responses: - "200": - description: ok - content: - application/json: - schema: - type: array - items: - allOf: [{$ref: '#` + pathsBase + `'}] - type: object - properties: {woof: {type: string}} -` - - cases := []struct { - name string - spec func(string) string - base, owner string - baseID, targetID ir.TypeID - wantDefault bool - }{ - {"a property", openapitest.ComponentSpec, mapping(propTarget), ownerProperty, - componentID("Pet"), "t/anon" + propTarget, false}, - {"items", openapitest.ComponentSpec, mapping(itemsTarget), ownerItems, - componentID("Pet"), "t/anon" + itemsTarget, false}, - {"a keyword-named key", openapitest.ComponentSpec, mapping(keywordTarget), ownerKeywordKey, - componentID("Pet"), "t/anon" + keywordTarget, false}, - {"a $defs entry", openapitest.ComponentSpec, mapping(defsTarget), ownerDefs, - componentID("Pet"), "t/anon" + defsTarget, false}, - {"a 3.2 defaultMapping", spec32, pet(`defaultMapping: '#` + propTarget + `'`), ownerProperty, - componentID("Pet"), "t/anon" + propTarget, true}, - {"a /paths inline subtype", openapitest.PathsSpec, pathBase, pathOwner, - "t/anon" + pathsBase, "t/anon" + pathsTarget, false}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - first, diags := parseFull(t, tc.spec(tc.base+tc.owner)) - openapitest.RequireNoErrorDiags(t, diags) - last, diags := parseFull(t, tc.spec(tc.owner+tc.base)) - openapitest.RequireNoErrorDiags(t, diags) - - for _, doc := range []*ir.Document{first, last} { - base, ok := doc.Types[tc.baseID].(*ir.Model) - require.True(t, ok, "the discriminator base is a model") - require.NotNil(t, base.Discriminator) - - subtype, ok := doc.Types[tc.targetID].(*ir.Model) - require.True(t, ok, "the mapping target is a model") - - if tc.wantDefault { - assert.Equal(t, tc.targetID, base.Discriminator.Default) - // defaultMapping is the fallback for a tag the mapping does not - // recognize, so it names no tag for its target, and an inline - // subtype has no name to imply one (GitHub #517). - assert.Empty(t, subtype.DiscriminatorValue) - } else { - assert.Equal(t, tc.targetID, base.Discriminator.Mapping["woofer"]) - assert.Equal(t, "woofer", subtype.DiscriminatorValue) - } - } - - // The type registry is a map, so this is the properly order-blind - // claim; the /paths row's operation LIST legitimately reorders with - // its two path items, which is declaration order working as - // designed and no part of what this test is pinning. - assert.Empty(t, cmp.Diff(first.Types, last.Types), - "declaring the base before or after the owner must not change the registry") - }) - } -} - -// TestDiscriminatorMapping_ToAPureRefNamesItsTarget pins GitHub #758. A -// mapping names a type, and a position that is only a $ref to another is a use -// of that one's type, so a mapping to it names what the $ref names, as a union -// variant written there does. A component is a declaration of its own and -// keeps its own node. Each row is compiled base-first and reversed: both must -// map each tag to the same type, with nothing reported, pass.Validate included. -func TestDiscriminatorMapping_ToAPureRefNamesItsTarget(t *testing.T) { - t.Parallel() - const subtype = "{allOf: [{$ref: '#/components/schemas/Pet'}], type: object, properties: {woof: {type: string}}}" - pet := " Pet:\n type: object\n required: [kind]\n properties: {kind: {type: string}}\n" + - " discriminator: {propertyName: kind, mapping: {%s}}\n" - union := " Pet:\n oneOf: [%s]\n" + - " discriminator: {propertyName: kind, mapping: {%s}}\n" - for _, c := range []struct { - name string - base string - owner string - want map[string]ir.TypeID - }{ - { - name: "a model base's mapping to a property", - base: fmt.Sprintf(pet, "woofer: '#/components/schemas/Kennel/properties/Dog'"), - owner: " Dog: " + subtype + "\n Kennel: {type: object, properties: {Dog: {$ref: '#/components/schemas/Dog'}}}\n", - want: map[string]ir.TypeID{"woofer": componentID("Dog")}, - }, - { - name: "a union's mapping to its branches", - base: fmt.Sprintf(union, "{$ref: '#/components/schemas/Cat'}, {type: object, properties: {kind: {type: string}}}", - "c: '#/components/schemas/Pet/oneOf/0', d: '#/components/schemas/Pet/oneOf/1'"), - owner: " Cat: {type: object, properties: {kind: {type: string}}}\n", - want: map[string]ir.TypeID{"c": componentID("Cat"), - "d": "t/anon/components/schemas/Pet/oneOf/1"}, - }, - { - name: "a chain of such positions", - base: fmt.Sprintf(pet, "woofer: '#/components/schemas/Kennel/properties/a'"), - owner: " Dog: " + subtype + "\n Kennel:\n type: object\n properties:\n" + - " a: {$ref: '#/components/schemas/Kennel/properties/b'}\n" + - " b: {$ref: '#/components/schemas/Dog'}\n", - want: map[string]ir.TypeID{"woofer": componentID("Dog")}, - }, - { - name: "a union branch naming a component alias", - base: fmt.Sprintf(union, "{$ref: '#/components/schemas/CatAlias'}", - "c: '#/components/schemas/Pet/oneOf/0'"), - owner: " Cat: {type: object, properties: {kind: {type: string}}}\n" + - " CatAlias: {$ref: '#/components/schemas/Cat'}\n", - want: map[string]ir.TypeID{"c": componentID("CatAlias")}, - }, - { - name: "a union branch naming the component keyed by the empty string", - base: fmt.Sprintf(union, "{$ref: '#/components/schemas/'}", "c: '#/components/schemas/'"), - owner: " Cat: {type: object, properties: {kind: {type: string}}}\n" + - " '': {$ref: '#/components/schemas/Cat'}\n", - want: map[string]ir.TypeID{"c": "t/anon/components/schemas/"}, - }, - } { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - first, diags := parseFull(t, openapitest.ComponentSpec(c.base+c.owner)) - openapitest.RequireNoErrorDiags(t, diags) - last, diags := parseFull(t, openapitest.ComponentSpec(c.owner+c.base)) - openapitest.RequireNoErrorDiags(t, diags) - - assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...)) - for _, doc := range []*ir.Document{first, last} { - assert.Equal(t, c.want, discriminatorOf(t, doc, componentID("Pet")).Mapping) - assert.Empty(t, pass.Validate(doc), "every target is a variant") - } - }) - } -} - -// TestDiscriminatorMapping_ChainOfPositionsDeeperThanCap pins the -// read-through's cap as behaviour rather than as a comment, as -// TestAllOf_DiscriminatorValueChainDeeperThanCap does for the ancestor walk. A -// chain of $ref positions longer than the cap that is no cycle stops at the -// position the cap reached, so the mapping names that position's alias. The -// chain exactly the cap long is the control: it reads through to the component. -func TestDiscriminatorMapping_ChainOfPositionsDeeperThanCap(t *testing.T) { - t.Parallel() - // maxTypePositionHops is unexported; 64 is its committed value, and the two - // cases below straddle it. - const hopCap = 64 - for _, tc := range []struct { - name string - links int - want ir.TypeID - }{ - {"as long as the cap", hopCap, componentID("Dog")}, - {"beyond the cap", hopCap + 1, "t/anon/components/schemas/Kennel/properties/a64"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - var kennel strings.Builder - kennel.WriteString(" Kennel:\n type: object\n properties:\n") - for i := range tc.links - 1 { - fmt.Fprintf(&kennel, " a%d: {$ref: '#/components/schemas/Kennel/properties/a%d'}\n", i, i+1) - } - fmt.Fprintf(&kennel, " a%d: {$ref: '#/components/schemas/Dog'}\n", tc.links-1) - spec := " Pet:\n type: object\n required: [kind]\n properties: {kind: {type: string}}\n" + - " discriminator: {propertyName: kind, mapping: {woofer: '#/components/schemas/Kennel/properties/a0'}}\n" + - " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object, properties: {woof: {type: string}}}\n" + - kennel.String() - - doc, diags := parseFull(t, openapitest.ComponentSpec(spec)) - - openapitest.RequireNoErrorDiags(t, diags) - assert.Equal(t, map[string]ir.TypeID{"woofer": tc.want}, discriminatorOf(t, doc, componentID("Pet")).Mapping, - "a0 is %d $ref positions above the component", tc.links) - }) - } -} - -// TestDiscriminatorMapping_StopsAtAPureRefItCannotFollow pins where the -// read-through stops short of a target: a $ref into another document, one to -// nothing, and one on a loop name no position a type is read at, so the -// mapping keeps naming the position that $ref sits at, in either order of the -// entries, and the $ref is reported where it is written. -func TestDiscriminatorMapping_StopsAtAPureRefItCannotFollow(t *testing.T) { - t.Parallel() - const branches = " oneOf: [{$ref: 'other.yaml#/X'}, {$ref: '#/x-lib/Missing'}, {$ref: '#/components/schemas/Pet/oneOf/2'}]\n" - for _, mapping := range []string{"{e: '#/components/schemas/Pet/oneOf/0', m: '#/components/schemas/Pet/oneOf/1', " + - "l: '#/components/schemas/Pet/oneOf/2'}", "{l: '#/components/schemas/Pet/oneOf/2', " + - "m: '#/components/schemas/Pet/oneOf/1', e: '#/components/schemas/Pet/oneOf/0'}"} { - doc, diags := parseFull(t, openapitest.ComponentSpec(" Pet:\n"+branches+ - " discriminator: {propertyName: kind, mapping: "+mapping+"}\n")+"x-lib: {}\n") - - branch := func(i string) ir.TypeID { return ir.TypeID("t/anon/components/schemas/Pet/oneOf/" + i) } - assert.Equal(t, map[string]ir.TypeID{"e": branch("0"), "m": branch("1"), "l": branch("2")}, - discriminatorOf(t, doc, componentID("Pet")).Mapping, mapping) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnresolvedRef, "/components/schemas/Pet/oneOf/0"), mapping) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnresolvedRef, "/components/schemas/Pet/oneOf/1"), mapping) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.CyclicRef, "/components/schemas/Pet/oneOf/2"), mapping) - } -} - -// TestDiscriminatorMapping_ThroughARefNamingTheSourceByItsFileName pins a -// pure $ref position whose $ref names the source by its file name, with -// external references off: a same-document reference, read as the source, so -// the mapping reaches the schema it names in either order of its entries. Read -// as another document, it would be refused, and the mapping would name whatever -// interned the next position first. -func TestDiscriminatorMapping_ThroughARefNamingTheSourceByItsFileName(t *testing.T) { - t.Parallel() - pet := func(mapping string) string { - return " Pet:\n type: object\n required: [kind]\n properties: {kind: {type: string}}\n" + - " discriminator: {propertyName: kind, mapping: {" + mapping + "}}\n" - } - const lib = "x-lib:\n Cat: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + - " CatAlias: {$ref: 'spec.yaml#/x-lib/Cat'}\n" - for _, mapping := range []string{"cat: '#/x-lib/Cat', alias: '#/x-lib/CatAlias'", - "alias: '#/x-lib/CatAlias', cat: '#/x-lib/Cat'"} { - doc, diags := parseFull(t, openapitest.ComponentSpec(pet(mapping))+lib) - openapitest.RequireNoErrorDiags(t, diags) - got := discriminatorOf(t, doc, componentID("Pet")).Mapping - assert.Equal(t, map[string]ir.TypeID{"cat": "t/anon/x-lib/Cat", "alias": "t/anon/x-lib/Cat"}, got, mapping) - } -} - -// TestDiscriminatorMapping_ToASubtypeThroughAPureRefTagsIt pins the subtype's -// side of GitHub #758: the mapping key that names a subtype through a pure -// $ref position is its discriminatorValue, in either order, not its name. -func TestDiscriminatorMapping_ToASubtypeThroughAPureRefTagsIt(t *testing.T) { - t.Parallel() - const base = ` Pet: - type: object - required: [kind] - properties: {kind: {type: string}} - discriminator: {propertyName: kind, mapping: {woofer: '#/components/schemas/Kennel/properties/Dog'}} -` - const owner = ` Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object} - Kennel: {type: object, properties: {Dog: {$ref: '#/components/schemas/Dog'}}} -` - for _, spec := range []string{base + owner, owner + base} { - doc, diags := parseFull(t, openapitest.ComponentSpec(spec)) - openapitest.RequireNoErrorDiags(t, diags) - dog, ok := doc.Types[componentID("Dog")].(*ir.Model) - require.True(t, ok) - assert.Equal(t, "woofer", dog.DiscriminatorValue) - } -} - -// TestDiscriminatorMapping_NamesWhatAVariantWrittenThereNames pins that the -// read-through stops exactly where the lowering gives a $ref position a node of -// its own. A branch carries the $ref beside one keyword at a time, and the -// mapping to it must name what the union's variant there names: the target, -// or the alias holding what the keyword says. A keyword the lowering keeps on -// an alias, but the read-through did not stop for, makes them differ. -func TestDiscriminatorMapping_NamesWhatAVariantWrittenThereNames(t *testing.T) { - t.Parallel() - for _, sibling := range []string{ - "", "description: d", "title: t", "deprecated: true", "example: {}", "examples: [{}]", - "x-ext: 1", "minProperties: 1", "readOnly: true", "not: {required: [x]}", - "oneOf: [{type: object}]", "allOf: [{type: object}]", "if: {required: [x]}", - "$comment: c", "externalDocs: {url: 'https://example.com'}", "nullable: true", - "properties: {extra: {type: string}}", "required: [kind]", "type: object", - } { - branch := "{$ref: '#/components/schemas/Cat'}" - if sibling != "" { - branch = "{$ref: '#/components/schemas/Cat', " + sibling + "}" - } - doc, _ := parseFull(t, openapitest.ComponentSpec( - " Pet:\n oneOf: ["+branch+"]\n discriminator: {propertyName: kind, mapping: {c: '#/components/schemas/Pet/oneOf/0'}}\n"+ - " Cat: {type: object, properties: {kind: {type: string}}}\n")) - pet, ok := doc.Types[componentID("Pet")].(*ir.Union) - require.True(t, ok, sibling) - require.Len(t, pet.Variants, 1, sibling) - assert.Equal(t, pet.Variants[0].Type.Target, pet.Discriminator.Mapping["c"], "beside %q", sibling) - } -} - -// TestDiscriminatorMapping_NamesTheVariantNamingThePosition pins a union whose -// variant is a $ref to the position its mapping names, or to a position on the -// way there. That $ref hoists an alias at the position, as a $ref to any -// position does, so the mapping names that alias and not the target past it, -// which is no variant (GitHub #758). Where no variant names the position, the -// mapping still reads through it. Each row is compiled with the union first and -// again last: both must map the tag to the variant's type, which pass.Validate -// accepts, and build the same registry. -func TestDiscriminatorMapping_NamesTheVariantNamingThePosition(t *testing.T) { - t.Parallel() - const ( - cat = " Cat: {type: object, properties: {kind: {type: string}}}\n" - catRef = "x-lib:\n CatRef: {$ref: '#/components/schemas/Cat'}\n" - ) - union := func(branch, discriminator string) string { - return " Pet:\n oneOf: [{$ref: '" + branch + "'}]\n" + - " discriminator: {propertyName: kind, " + discriminator + "}\n" - } - tests := []struct { - name string - version string - components []string - tail string - variant ir.TypeID // what the variant names - isDefault bool // the discriminator's defaultMapping, not its mapping c - }{ - { - name: "an extension's value", - components: []string{union("#/x-lib/CatRef", "mapping: {c: '#/x-lib/CatRef'}"), cat}, - tail: catRef, - variant: "t/anon/x-lib/CatRef", - }, - { - name: "a property", - components: []string{union("#/components/schemas/Lib/properties/c", - "mapping: {c: '#/components/schemas/Lib/properties/c'}"), cat, - " Lib: {type: object, properties: {c: {$ref: '#/components/schemas/Cat'}}}\n"}, - variant: "t/anon/components/schemas/Lib/properties/c", - }, - { - name: "a $defs entry", - components: []string{union("#/$defs/C", "mapping: {c: '#/$defs/C'}") + - " $defs: {C: {$ref: '#/components/schemas/Cat'}}\n", cat}, - variant: "t/anon/components/schemas/Pet/$defs/C", - }, - { - name: "another union's branch", - components: []string{union("#/components/schemas/Other/oneOf/0", - "mapping: {c: '#/components/schemas/Other/oneOf/0'}"), cat, - " Other: {oneOf: [{$ref: '#/components/schemas/Cat'}, {type: string}]}\n"}, - variant: "t/anon/components/schemas/Other/oneOf/0", - }, - { - name: "a 3.2 defaultMapping", - version: "3.2.0", - components: []string{union("#/x-lib/CatRef", "defaultMapping: '#/x-lib/CatRef'"), cat}, - tail: catRef, - variant: "t/anon/x-lib/CatRef", - isDefault: true, - }, - { - name: "the union's own branch, through to the position its $ref names", - components: []string{union("#/x-lib/A", "mapping: {c: '#/components/schemas/Pet/oneOf/0'}"), cat}, - tail: "x-lib:\n A: {$ref: '#/x-lib/B'}\n B: {$ref: '#/components/schemas/Cat'}\n", - variant: "t/anon/x-lib/A", - }, - { - name: "a position no variant names, read through to the variant", - components: []string{" Pet:\n oneOf: [{$ref: '#/components/schemas/Cat'}]\n" + - " discriminator: {propertyName: kind, mapping: {c: '#/x-lib/CatRef'}}\n", cat}, - tail: catRef, - variant: componentID("Cat"), - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - version := tc.version - if version == "" { - version = "3.1.0" - } - first, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, false)) - openapitest.RequireNoErrorDiags(t, diags) - last, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, true)) - openapitest.RequireNoErrorDiags(t, diags) - - for _, doc := range []*ir.Document{first, last} { - pet, ok := doc.Types[componentID("Pet")].(*ir.Union) - require.True(t, ok, "Pet is a union") - require.Len(t, pet.Variants, 1) - require.NotNil(t, pet.Discriminator) - assert.Equal(t, tc.variant, pet.Variants[0].Type.Target) - got := pet.Discriminator.Mapping["c"] - if tc.isDefault { - got = pet.Discriminator.Default - } - assert.Equal(t, pet.Variants[0].Type.Target, got, "the mapping names what the variant names") - assert.Empty(t, pass.Validate(doc), "every target is a variant") - } - assert.Empty(t, cmp.Diff(first.Types, last.Types), - "declaring the union before or after what its variant names must not change the registry") - }) - } -} - -// TestDiscriminatorMapping_HoistedTargetKeepsItsDiagnostics pins that what -// lowering a mapping target reports reaches the compile's diagnostics, for a -// mapping entry and a defaultMapping alike. The targets sit under not and if, -// which no declaration lowers, so the hoist is the only lowering that sees the -// format each declares beside a model: dropping its findings would lose them. -func TestDiscriminatorMapping_HoistedTargetKeepsItsDiagnostics(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.2.0", ` Pet: - type: object - required: [kind] - properties: {kind: {type: string}} - discriminator: - propertyName: kind - mapping: {n: '#/components/schemas/Kennel/not'} - defaultMapping: '#/components/schemas/Kennel/if' - Kennel: - type: object - not: {type: object, format: wat, properties: {a: {type: string}}} - if: {type: object, format: huh, properties: {b: {type: string}}} - then: {required: [b]} -`)) - openapitest.RequireNoErrorDiags(t, diags) - - pet, ok := doc.Types[componentID("Pet")].(*ir.Model) - require.True(t, ok, "Pet is a model") - require.NotNil(t, pet.Discriminator) - assert.Equal(t, ir.TypeID("t/anon/components/schemas/Kennel/not"), pet.Discriminator.Mapping["n"]) - assert.Equal(t, ir.TypeID("t/anon/components/schemas/Kennel/if"), pet.Discriminator.Default) - - for _, pointer := range []string{"/components/schemas/Kennel/not", "/components/schemas/Kennel/if"} { - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, pointer), - "the format beside the model hoisted at %s is reported", pointer) - } -} - -// TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype pins that a model -// beside a union kept verbatim does not keep a discriminator that routes to a -// type that is not its subtype, which pass.Validate rejects. Each route counts: -// a mapping target, the default, and a component a $ref branch names, which -// the discriminator routes to by name. Each "lowers to" row names a type whose -// own allOf names Pet but which lowers to no Model with that Base, which only -// the lowered document can tell. -func TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype(t *testing.T) { - t.Parallel() - const viaPet = "{allOf: [{$ref: '#/components/schemas/Pet'}]" - cases := []struct { - name, version, discriminator, rest, want string - }{ - {"mapped", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", cat + dog, `"mapping"`}, - {"named by branch", "3.1.0", "{propertyName: kind}", cat + dog, `"propertyName"`}, - {"one mapped subtype and one branch that is not", "3.1.0", - "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: " + viaPet + "}\n" + dog, `"mapping"`}, - {"default", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Other'}", - subtypes + " Other: {type: object}\n", `"defaultMapping"`}, - {"mixin only", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: {allOf: [{$ref: '#/components/schemas/Dog'}, {$ref: '#/components/schemas/Other'}]}\n" + - dog + " Other: {type: object}\n", `"mapping"`}, - {"position declaring an inline object", "3.1.0", - "{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}", - subtypes + " Holder: {type: object, properties: {pet: {type: object, properties: {kind: {type: string}}}}}\n", - `"mapping"`}, - {"component that is only an alias", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Alias'}}", - subtypes + " Alias: {$ref: '#/components/schemas/Cat'}\n", `"mapping"`}, - {"lowers to an enum", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: " + viaPet + ", enum: [{kind: cat}]}\n" + subDog, `"mapping"`}, - {"lowers to a literal", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: " + viaPet + ", const: {kind: cat}}\n" + subDog, `"mapping"`}, - {"lowers to an alias over its $ref", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: {$ref: '#/components/schemas/Other', allOf: [{$ref: '#/components/schemas/Pet'}]}\n" + - " Other: {type: object}\n" + subDog, `"mapping"`}, - {"lowers to a distributed union", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: " + viaPet + ", oneOf: [{$ref: '#/components/schemas/House'}, {$ref: '#/components/schemas/Wild'}]}\n" + - " House: {type: object}\n Wild: {type: object}\n" + subDog, `"mapping"`}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, c.version, petWith(c.discriminator), c.rest) { - assertMoved(t, doc, c.want) - } - }) - } -} - -// TestDiscriminatorBesideUnion_LeavesAMapWhenItMoves pins that a discriminator -// moving off a body whose one other member fact is its catch-all leaves a map, -// which is a MapT, as it is when no discriminator is written (ir-design §4.6). -func TestDiscriminatorBesideUnion_LeavesAMapWhenItMoves(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, "3.1.0", mapPet, cat+dog) { - assertMovedToMap(t, typeByName(doc, "Pet")) - assert.Empty(t, censusReports(doc.Diagnostics, "/components/schemas/Pet"), - "the map holds all the census asked about, and the move keeps the discriminator itself") - } -} - -// TestDiscriminatorBesideUnion_SettlesAMapAReferenceNamesFirst is -// TestDiscriminatorBesideUnion_SettlesAPositionAReferenceNamesFirst over a map -// body: the reference builds nothing, so the one settlement the declaration -// defers moves the discriminator and leaves the MapT, in either order. -func TestDiscriminatorBesideUnion_SettlesAMapAReferenceNamesFirst(t *testing.T) { - t.Parallel() - const at = "/components/schemas/Holder/properties/pet" - outside := " Outside: {$ref: '#/components/schemas/Holder/properties/pet'}\n" - holder := " Holder:\n type: object\n properties:\n pet:\n type: object\n" + - " additionalProperties: {type: string}\n discriminator: {propertyName: kind}\n" + - " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}]\n" - for _, doc := range discriminatedOrders(t, "3.1.0", outside, holder+cat+dog) { - assertMovedToMap(t, doc.Types["t/anon"+at]) - assert.Contains(t, openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, at+"/discriminator"), - "not a subtype", "one move, reported once") - assert.Empty(t, censusReports(doc.Diagnostics, at), "the map homes all the census asked about") - } -} - -// TestDiscriminatorBesideUnion_ARequiredNameKeepsTheBodyAModel pins a body -// whose catch-all sits beside a required name its properties do not declare: -// the name is in Model.Requires, which no map holds, so the discriminator's -// move leaves a Model, nothing kept verbatim for want of a home. It holds for -// a plain object, a composed one, and the object variant of a multi-typed body. -func TestDiscriminatorBesideUnion_ARequiredNameKeepsTheBodyAModel(t *testing.T) { - t.Parallel() - const ( - body = " additionalProperties: {type: string}\n required: [a]\n discriminator: {propertyName: kind}\n" + petBranches - pointer = "/components/schemas/Pet" - ) - for _, tc := range []struct { - name, pet string - node ir.TypeID - }{ - {"plain", " Pet:\n type: object\n" + body, componentID("Pet")}, - {"composed", " Pet:\n allOf: [{type: object}]\n" + body, componentID("Pet")}, - {"variant", " Pet:\n type: [object, string]\n" + body, "t/anon" + pointer + "/type/0"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, "3.1.0", tc.pet, cat+dog) { - m, ok := doc.Types[tc.node].(*ir.Model) - require.True(t, ok, "a required name keeps the body a Model; got %T", doc.Types[tc.node]) - assert.Nil(t, m.Discriminator, "the discriminator moved") - assert.Equal(t, []string{"a"}, m.Requires) - assert.NotContains(t, m.Unmodeled, "openapi:required") - assert.Empty(t, censusReports(doc.Diagnostics, pointer)) - } - }) - } -} - -// TestDiscriminatorBesideUnion_KeepsARoutingMapAModel pins the other outcome: -// a discriminator every route of which is a subtype stays, and a model that is -// a hierarchy's base is no map, whatever else it declares. -func TestDiscriminatorBesideUnion_KeepsARoutingMapAModel(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, "3.1.0", mapPet, subtypes) { - assertStayed(t, doc) - assert.NotNil(t, petModel(t, doc).AdditionalProps, "the catch-all stays beside the discriminator") - } -} - -// TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype pins the case the -// model keeps its discriminator beside a kept union: every route is a subtype, -// at any distance and however it is spelled. A branch no name reaches, inline -// or a position, is no route, so it does not move the discriminator either. -func TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype(t *testing.T) { - t.Parallel() - const grand = " Cat: {allOf: [{$ref: '#/components/schemas/Mid'}], type: object}\n" + - " Mid: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + - " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" - unnamed := " Pet:\n type: object\n discriminator: {propertyName: kind}\n" + - " oneOf: [{type: object, properties: {kind: {type: string}}}, {$ref: '#/components/schemas/Holder/properties/pet'}]\n" - cases := []struct { - name, version, pet, rest string - }{ - {"mapped", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), subtypes}, - {"named by branch", "3.1.0", petWith("{propertyName: kind}"), subtypes}, - {"mapped by bare name", "3.1.0", petWith("{propertyName: kind, mapping: {cat: Cat, dog: Dog}}"), subtypes}, - {"mapped through a position that is only a $ref", "3.1.0", - petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}"), - subtypes + " Holder: {type: object, properties: {pet: {$ref: '#/components/schemas/Cat'}}}\n"}, - {"grandchild", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), grand}, - {"default", "3.2.0", petWith("{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}"), subtypes}, - {"branches no name reaches", "3.1.0", unnamed, cat + " Holder: {type: object, properties: {pet: {type: object}}}\n"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, c.version, c.pet, c.rest) { - assertStayed(t, doc) - } - }) - } -} - -// TestDiscriminatorBesideUnion_StaysForASubtypeAtAnyDepth pins that no depth bound -// stands between a route and Pet: pass.Validate follows a hierarchy to any -// depth, so a bound here would move a discriminator it accepts. -func TestDiscriminatorBesideUnion_StaysForASubtypeAtAnyDepth(t *testing.T) { - t.Parallel() - var chain strings.Builder - for i := range 300 { - fmt.Fprintf(&chain, " C%d: {allOf: [{$ref: '#/components/schemas/C%d'}], type: object}\n", i, i+1) - } - chain.WriteString(" C300: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n") - pet := petWith("{propertyName: kind, mapping: {c: '#/components/schemas/C0'}}") - for _, doc := range discriminatedOrders(t, "3.1.0", pet, subtypes+chain.String()) { - assertStayed(t, doc) - } -} - -// TestDiscriminatorBesideUnion_ReportsTheMoveOnce pins the diagnostic: exactly -// one, at the discriminator, when it moves, in either declaration order, and -// none when it stays. -func TestDiscriminatorBesideUnion_ReportsTheMoveOnce(t *testing.T) { - t.Parallel() - pet := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") - for _, src := range []string{pet + cat + dog, cat + dog + pet} { - _, diags := parseFull(t, openapitest.ComponentSpec(src)) - assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, movedPointer), - "not a subtype of the model holding it") - } - _, diags := parseFull(t, openapitest.ComponentSpec(pet+subtypes)) - assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, movedPointer)) -} - -// TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled pins that the rule -// holds on every union kept verbatim beside a model, not only one its -// discriminator alone degrades, both ways. -func TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled(t *testing.T) { - t.Parallel() - for _, shape := range degradedShapes { - t.Run(shape.name, func(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), cat+dog) { - assertMoved(t, doc, `"mapping"`) - } - for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), subtypes) { - assertStayed(t, doc) - } - }) - } -} - -// TestDiscriminatorBesideUnion_AllOfBodyIsSettled pins the other model body a -// kept union can sit beside: one composed with allOf lowers through -// lowerAllOf, and its discriminator is settled the same way. -func TestDiscriminatorBesideUnion_AllOfBodyIsSettled(t *testing.T) { - t.Parallel() - pet := " Pet:\n allOf: [{$ref: '#/components/schemas/Base'}]\n" + - " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + petBranches - for _, doc := range discriminatedOrders(t, "3.1.0", pet, cat+dog+" Base: {type: object}\n") { - assertMoved(t, doc, `"mapping"`) - } -} - -// TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled pins the other model a -// discriminator beside a kept union can sit on: a multi-typed body lowers to a -// union whose object variant lowerUnion builds from the same schema, so the -// variant holds it. Routed to a non-subtype, it moves to the node keeping the -// union; with no route at all, it stays on the variant. -func TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled(t *testing.T) { - t.Parallel() - const variant = ir.TypeID("t/anon/components/schemas/Pet/type/0") - multi := func(discriminator, branches string) string { - return " Pet:\n type: [object, string]\n properties: {kind: {type: string}}\n" + - " discriminator: " + discriminator + "\n" + branches - } - cases := []struct { - name, pet string - moves bool - }{ - {"mapped to a non-subtype", multi("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", petBranches), true}, - {"no route", multi("{propertyName: kind}", " oneOf: [{type: object, properties: {kind: {type: string}}}, {type: string}]\n"), false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - for _, doc := range discriminatedOrders(t, "3.1.0", c.pet, cat+dog) { - owner, ok := doc.Types[componentID("Pet")].(*ir.Union) - require.True(t, ok, "a multi-typed body lowers to a union") - assert.Contains(t, owner.Unmodeled, "openapi:oneOf", "the union is kept on it") - held, ok := doc.Types[variant].(*ir.Model) - require.True(t, ok, "its object variant is a model") - assert.NotContains(t, held.Unmodeled, "openapi:discriminator", "the variant never keeps it verbatim") - if !c.moves { - assert.NotNil(t, held.Discriminator, "nothing routes anywhere, so it stays") - assert.NotContains(t, owner.Unmodeled, "openapi:discriminator") - continue - } - assert.Nil(t, held.Discriminator) - entry, ok := owner.Unmodeled["openapi:discriminator"] - require.True(t, ok, "it is kept beside the union it was written for") - assert.Equal(t, ir.Provenance{Pointer: movedPointer}, entry.Provenance) - assert.Contains(t, string(entry.Value), `"mapping"`) - } - }) - } -} - -// TestDiscriminatorBesideUnion_MovesOnlyWhatItCopied pins that the move never -// loses a discriminator it could not keep verbatim. One merged in through `<<` -// is read as a written one is (GitHub #395), so Pet lowers as it does with the -// discriminator written in place. One that converts to no JSON, through an -// extension written `.nan`, is reported as unpreservable and stays on the -// model, and nothing claims a copy never made. -func TestDiscriminatorBesideUnion_MovesOnlyWhatItCopied(t *testing.T) { - t.Parallel() - const disc = "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}" - written, _ := parseFull(t, openapitest.ComponentSpec(cat+dog+petWith(disc))) - merged, diags := parseFull(t, openapitest.ComponentSpec(cat+dog+ - " Pet:\n <<: {discriminator: "+disc+"}\n type: object\n"+petBranches)) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, cmp.Diff(typeByName(written, "Pet"), typeByName(merged, "Pet")), - "a merged discriminator lowers as a written one") - - doc, diags := parseFull(t, openapitest.ComponentSpec(cat+dog+ - petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}, x-weight: .nan}"))) - pet := petModel(t, doc) - require.NotNil(t, pet.Discriminator, "the model read it, so it is not lost") - assert.Equal(t, "kind", pet.Discriminator.PropertyName) - assert.NotContains(t, pet.Unmodeled, "openapi:discriminator") - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnpreservableConstruct, movedPointer), - "the failed copy is reported: %+v", diags) - assert.False(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { - return strings.Contains(d.Message, "beside the union it was written for") - }), "no move is announced") -} - -// TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute pins that a -// branch the discriminator cannot route to by name decides nothing, whichever -// way it is unreachable: a $ref to an undeclared component, or one into -// another document, though its fragment spells a component this one declares, -// Lone, which is no subtype. The unresolved $refs are error diagnostics, so -// the document is checked with pass.Validate directly. -func TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute(t *testing.T) { - t.Parallel() - const branches = " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Nope'}, " + - "{$ref: 'https://example.com/x.yaml#/components/schemas/Lone'}]\n" - const lone = " Lone: {type: object}\n" - for _, c := range []struct { - name, rest string - stays bool - }{ - {"beside a subtype", subtypes + lone, true}, - {"beside a non-subtype", cat + dog + lone, false}, - } { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - pet := degradedPet(branches) - docs := make([]*ir.Document, 0, 2) - for _, src := range []string{pet + c.rest, c.rest + pet} { - doc, _ := parseFull(t, openapitest.ComponentSpec(src)) - assert.Empty(t, pass.Validate(doc), "the document must validate") - docs = append(docs, doc) - if c.stays { - assertStayed(t, doc) - continue - } - assertMoved(t, doc, `"mapping"`) - } - assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...)) - assert.Equal(t, reported(docs[0].Diagnostics), reported(docs[1].Diagnostics)) - }) - } -} - -// TestDiscriminatorBesideUnion_CyclicRouteMoves pins that a route whose -// hierarchy loops without reaching Pet is no subtype, and the check ends. -func TestDiscriminatorBesideUnion_CyclicRouteMoves(t *testing.T) { - t.Parallel() - pet := petWith("{propertyName: kind, mapping: {loop: '#/components/schemas/Loop'}}") - rest := subtypes + - " Loop: {allOf: [{$ref: '#/components/schemas/LoopB'}]}\n" + - " LoopB: {allOf: [{$ref: '#/components/schemas/Loop'}]}\n" - for _, doc := range discriminatedOrders(t, "3.1.0", pet, rest) { - assertMoved(t, doc, `"loop"`) - } -} - -// TestDiscriminatorBesideUnion_SubtypeKeepsItsTag pins what a move leaves -// alone: a route that is a subtype still carries the tag the mapping gives it, -// since that is the value its instances carry on the wire. -func TestDiscriminatorBesideUnion_SubtypeKeepsItsTag(t *testing.T) { - t.Parallel() - pet := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog'}}") - rest := subCat + dog - for _, doc := range discriminatedOrders(t, "3.1.0", pet, rest) { - assertMoved(t, doc, `"dog"`) - sub, ok := typeByName(doc, "Cat").(*ir.Model) - require.True(t, ok) - assert.Equal(t, "cat", sub.DiscriminatorValue) - } -} - -// TestDiscriminatorBesideUnion_SettlesAnOperationsSchemaToo pins that the -// settlement waits for the operations as well as the components: a request -// body lowers in the service walk, after every component. -func TestDiscriminatorBesideUnion_SettlesAnOperationsSchemaToo(t *testing.T) { - t.Parallel() - spec := `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: - /pets: - post: - requestBody: - content: - application/json: - schema: - type: object - discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}} - oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}] - responses: - "204": {description: none} -components: - schemas: -` + cat + dog - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, pass.Validate(doc)) - const at = "/paths/~1pets/post/requestBody/content/application~1json/schema/discriminator" - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, at), "%+v", diags) -} - -// TestDiscriminatorBesideUnion_SettlesAPositionAReferenceNamesFirst pins a -// model a $ref names before its declaration lowers it: the reference builds -// nothing, so the declaration alone lowers the model and defers its settlement, -// which moves the discriminator off it once, in either order. -func TestDiscriminatorBesideUnion_SettlesAPositionAReferenceNamesFirst(t *testing.T) { - t.Parallel() - const at = "/components/schemas/Holder/properties/pet" - outside := " Outside: {$ref: '#/components/schemas/Holder/properties/pet'}\n" - holder := " Holder:\n type: object\n properties:\n pet:\n type: object\n" + - " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + - " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}]\n" - for _, doc := range discriminatedOrders(t, "3.1.0", outside, holder+cat+dog) { - pet, ok := doc.Types["t/anon"+at].(*ir.Model) - require.True(t, ok) - assert.Nil(t, pet.Discriminator) - assert.Contains(t, pet.Unmodeled, "openapi:discriminator") - assert.Contains(t, openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, at+"/discriminator"), - "not a subtype", "one move, reported once") - } -} - -// TestMapping_InAnotherDocumentIsReportedWhereItIsWritten pins the report of a -// discriminator mapping value another document writes that names nothing -// there: once, at the value in that document's Document.Sources entry, in the -// words a mapping in the source draws, so the message does not name the -// document its Provenance already does. The value naming what it declares -// resolves and draws nothing. -func TestMapping_InAnotherDocumentIsReportedWhereItIsWritten(t *testing.T) { - t.Parallel() - _, diags, _ := compileBeside(t, map[string]string{"pets.yaml": `Pet: - oneOf: [{$ref: '#/Cat'}] - discriminator: {propertyName: kind, mapping: {cat: '#/Cat', dog: '#/Dog'}} -Cat: {type: object, properties: {kind: {type: string}}} -`}, `openapi: 3.1.0 -info: {title: T, version: "1"} -paths: {} -components: - schemas: - A: {$ref: './pets.yaml#/Pet'} -`) - - assert.Empty(t, cmp.Diff([]ir.Diagnostic{{Severity: ir.SeverityError, Code: "openapi/unresolved-ref", - Message: `discriminator mapping "dog" references unresolved schema "#/Dog": ` + - "it names nothing in the document it points into", - Provenance: ir.Provenance{Source: 1, Pointer: "/Pet/discriminator/mapping/dog"}}}, - withCode(diags, "openapi/unresolved-ref"))) -} - -// TestDiscriminatorMapping_ToRawTargetsIsOrderInvariant pins that a mapping or -// defaultMapping naming a position the parsed model holds as raw YAML, an -// extension's value or an enum member, resolves to the type at that position -// whichever order the document declares things in (GitHub #757). Each row is -// written in the order that failed, with the mapping before any $ref to its -// target, and compiled again reversed. Neither compile may report an error, -// and both must produce the same registry. -func TestDiscriminatorMapping_ToRawTargetsIsOrderInvariant(t *testing.T) { - t.Parallel() - const ( - petSub = "allOf: [{$ref: '#/components/schemas/Pet'}], type: object" - cat = " Cat: {" + petSub + ", properties: {meow: {type: string}}}\n" - ) - pet := componentID("Pet") - anon := func(pointer string) ir.TypeID { return ir.TypeID("t/anon/" + pointer) } - model := func(base ir.TypeID, tag, pointer string) wantMapping { - return wantMapping{base: base, tag: tag, target: anon(pointer), kind: ir.KindModel} - } - tests := []struct { - name string - version string - components []string - tail string - wants []wantMapping - }{ - { - name: "an extension's value named by a mapping alone", - components: []string{rawBase("mapping: {c: '#/x-lib/Cat'}")}, - tail: "x-lib:\n" + cat, - wants: []wantMapping{model(pet, "c", "x-lib/Cat")}, - }, - { - name: "an extension's value also named by a $ref", - components: []string{ - rawBase("mapping: {c: '#/x-lib/Cat'}"), - " Holder: {$ref: '#/x-lib/Cat'}\n", - }, - tail: "x-lib:\n" + cat, - wants: []wantMapping{model(pet, "c", "x-lib/Cat")}, - }, - { - name: "an enum member", - components: []string{ - rawBase("mapping: {e: '#/components/schemas/Kennel/properties/e/enum/0'}"), - " Kennel:\n type: object\n properties:\n e:\n enum:\n" + - " - {type: object, properties: {wingspan: {type: number}}}\n", - }, - // A member that is an object is lowered to a literal, which no model - // subtypes, so only the resolution is pinned here, not a variant. - wants: []wantMapping{{pet, "e", anon("components/schemas/Kennel/properties/e/enum/0"), ir.KindLiteral}}, - }, - { - name: "a defaultMapping to an extension's value", - version: "3.2.0", - components: []string{rawBase("defaultMapping: '#/x-lib/Cat'")}, - tail: "x-lib:\n" + cat, - wants: []wantMapping{model(pet, "", "x-lib/Cat")}, - }, - { - name: "a mapping in a raw schema only a $ref reaches", - components: []string{" Holder: {$ref: '#/x-lib/Inner'}\n"}, - tail: "x-lib:\n" + - " Inner:\n type: object\n required: [tag]\n properties: {tag: {type: string}}\n" + - " discriminator: {propertyName: tag, mapping: {d: '#/x-lib/Dog'}}\n" + - " Dog: {allOf: [{$ref: '#/x-lib/Inner'}], type: object}\n", - wants: []wantMapping{model(anon("x-lib/Inner"), "d", "x-lib/Dog")}, - }, - { - name: "a mapping in a raw schema only another mapping reaches", - components: []string{rawBase("mapping: {i: '#/x-lib/Inner'}")}, - tail: "x-lib:\n" + - " Inner:\n allOf: [{$ref: '#/components/schemas/Pet'}]\n type: object\n" + - " required: [tag]\n properties: {tag: {type: string}}\n" + - " discriminator: {propertyName: tag, mapping: {d: '#/x-lib/Dog'}}\n" + - " Dog: {allOf: [{$ref: '#/x-lib/Inner'}], type: object}\n", - wants: []wantMapping{ - model(pet, "i", "x-lib/Inner"), - model(anon("x-lib/Inner"), "d", "x-lib/Dog"), - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - version := tc.version - if version == "" { - version = "3.1.0" - } - first, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, false)) - openapitest.RequireNoErrorDiags(t, diags) - last, diags := parseFull(t, rawSpec(version, tc.components, tc.tail, true)) - openapitest.RequireNoErrorDiags(t, diags) - - for _, doc := range []*ir.Document{first, last} { - for _, want := range tc.wants { - base, ok := doc.Types[want.base].(*ir.Model) - require.True(t, ok, "%s is a model", want.base) - require.NotNil(t, base.Discriminator, "%s is discriminated", want.base) - - got := base.Discriminator.Default - if want.tag != "" { - got = base.Discriminator.Mapping[want.tag] - } - assert.Equal(t, want.target, got, "%s %q", want.base, want.tag) - - target, ok := doc.Types[want.target] - require.True(t, ok, "the target %s is in the registry", want.target) - assert.Equal(t, want.kind, target.Kind(), "the kind of %s", want.target) - } - } - - assert.Empty(t, cmp.Diff(first.Types, last.Types), - "declaring the mapping before or after what names its target must not change the registry") - }) - } -} - -// discriminatorValues reads the DiscriminatorValue of every Model named in -// typeIDs off doc, so a mismatch anywhere in the set shows every id at once -// rather than only the first one asserted. -func discriminatorValues(t *testing.T, doc *ir.Document, typeIDs ...ir.TypeID) map[ir.TypeID]string { - t.Helper() - got := make(map[ir.TypeID]string, len(typeIDs)) - for _, id := range typeIDs { - m, ok := doc.Types[id].(*ir.Model) - require.True(t, ok, "%s should be a model", id) - got[id] = m.DiscriminatorValue - } - return got -} - -// discriminatorOf returns the discriminator the model or union id declares. -func discriminatorOf(t *testing.T, doc *ir.Document, id ir.TypeID) *ir.Discriminator { - t.Helper() - switch td := doc.Types[id].(type) { - case *ir.Model: - require.NotNil(t, td.Discriminator, id) - return td.Discriminator - case *ir.Union: - require.NotNil(t, td.Discriminator, id) - return td.Discriminator - default: - require.Failf(t, "not a discriminated type", "%s is a %T", id, td) - return nil - } -} - -const ( - cat = " Cat: {type: object, properties: {kind: {type: string}, meow: {type: boolean}}}\n" - dog = " Dog: {type: object, properties: {kind: {type: string}, bark: {type: boolean}}}\n" - - // subCat and subDog declare Cat and Dog as allOf subtypes of Pet. - subCat = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {meow: {type: boolean}}}]}\n" - subDog = " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {bark: {type: boolean}}}]}\n" - subtypes = subCat + subDog - - petBranches = " oneOf:\n - $ref: '#/components/schemas/Cat'\n - $ref: '#/components/schemas/Dog'\n" - - // movedPointer is where the diagnostic for a discriminator kept verbatim - // rather than on Pet is reported. - movedPointer = "/components/schemas/Pet/discriminator" -) - -// petWith declares Pet, a model whose oneOf names Cat and Dog, beside the -// discriminator text given. -func petWith(discriminator string) string { - return " Pet:\n type: object\n discriminator: " + discriminator + "\n" + petBranches -} - -// discriminatedOrders compiles pet and rest in both declaration orders, rest -// first second, and requires each to validate and the two to agree on their -// types and on what they report. A decision read off the registry while Pet -// lowers sees no subtype declared after it. -func discriminatedOrders(t *testing.T, version, pet, rest string) []*ir.Document { - t.Helper() - docs := make([]*ir.Document, 0, 2) - for _, src := range []string{pet + rest, rest + pet} { - doc, diags := parseFull(t, openapitest.ComponentSpecVer(version, src)) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, pass.Validate(doc), "the document must validate") - docs = append(docs, doc) - } - assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...), - "declaration order must not change the types") - assert.Equal(t, reported(docs[0].Diagnostics), reported(docs[1].Diagnostics), - "declaration order must not change what is reported, only the order it is reported in") - return docs -} - -// assertMoved requires Pet's discriminator to be kept verbatim, whole, rather -// than on the model, and want to appear in what was kept. -func assertMoved(t *testing.T, doc *ir.Document, want string) { - t.Helper() - pet := petModel(t, doc) - assert.Nil(t, pet.Discriminator, "a discriminator routing to a non-subtype stays off the model") - entry, ok := pet.Unmodeled["openapi:discriminator"] - require.True(t, ok, "the discriminator is kept verbatim") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Equal(t, ir.Provenance{Pointer: movedPointer}, entry.Provenance) - assert.Contains(t, string(entry.Value), `"propertyName":"kind"`, "the whole object is kept") - assert.Contains(t, string(entry.Value), want) -} - -// assertStayed requires Pet to hold its discriminator and to store it nowhere -// else. -func assertStayed(t *testing.T, doc *ir.Document) { - t.Helper() - pet := petModel(t, doc) - require.NotNil(t, pet.Discriminator, "every route is a subtype, so the model keeps it") - assert.Equal(t, "kind", pet.Discriminator.PropertyName) - assert.NotContains(t, pet.Unmodeled, "openapi:discriminator", "nothing is stored twice") -} - -// mapPet declares Pet as a map beside its union: a catch-all is its only member -// fact other than the discriminator. -const mapPet = " Pet:\n type: object\n additionalProperties: {type: string}\n" + - " discriminator: {propertyName: kind}\n" + petBranches - -// censusReports returns the messages of the census reports made at pointer, -// each naming the keywords that position kept for want of a home. -func censusReports(diags []ir.Diagnostic, pointer jsontext.Pointer) []string { - var out []string - for _, d := range diags { - if d.Code == diag.DegradedConstruct && d.Provenance.Pointer == pointer && - strings.HasPrefix(d.Message, "this position lowered to ") { - out = append(out, d.Message) - } - } - return out -} - -// assertMovedToMap requires td to be the MapT a map body is once its -// discriminator has moved, holding the union and the discriminator verbatim. -func assertMovedToMap(t *testing.T, td ir.TypeDef) { - t.Helper() - mt, ok := td.(*ir.MapT) - require.True(t, ok, "got %T", td) - assert.Equal(t, ir.TypeRef{Target: "t/prim/string"}, mt.Value) - assert.Contains(t, mt.Unmodeled, "openapi:oneOf", "the union is kept beside it") - assert.Contains(t, mt.Unmodeled, "openapi:discriminator", "and the discriminator with it") -} - -// degradedShapes are the cases besides the discriminator itself that keep a -// union verbatim beside a model, each with the branch text that makes the -// union that case. Each goes through lowerBesideUnmodeledUnion. -var degradedShapes = []struct { - name string - branches string -}{ - {"inline branch", " oneOf: [{$ref: '#/components/schemas/Cat'}, {type: object, properties: {kind: {type: string}}}]\n"}, - {"oneOf beside anyOf", " oneOf: [{$ref: '#/components/schemas/Cat'}]\n anyOf: [{$ref: '#/components/schemas/Dog'}]\n"}, - {"validation-only branches", " oneOf: [{required: [kind]}, {required: [x]}]\n"}, -} - -// degradedPet declares Pet, a model whose discriminator maps cat to Cat, -// beside the branches given. -func degradedPet(branches string) string { - return " Pet:\n type: object\n" + - " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + branches -} - -// petModel returns Pet, which lowers to a model beside its kept union. -func petModel(t *testing.T, doc *ir.Document) *ir.Model { - t.Helper() - pet, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok, "Pet lowers to a model beside its union") - assert.Contains(t, pet.Unmodeled, "openapi:oneOf", "the union is kept beside it") - return pet -} - -// rawBase is a discriminated Pet whose mapping is mapping, followed by whatever -// the row declares after it. -func rawBase(mapping string) string { - return " Pet:\n" + - " type: object\n" + - " required: [kind]\n" + - " properties: {kind: {type: string}}\n" + - " discriminator: {propertyName: kind, " + mapping + "}\n" -} - -// rawSpec writes components in the order given, and the root-level extensions -// tail after them. With reversed set it declares the components in the opposite -// order and the extensions ahead of them. -func rawSpec(version string, components []string, tail string, reversed bool) string { - if !reversed { - return openapitest.ComponentSpecVer(version, strings.Join(components, "")) + tail - } - rev := make([]string, len(components)) - for i, c := range components { - rev[len(components)-1-i] = c - } - doc := openapitest.ComponentSpecVer(version, strings.Join(rev, "")) - return strings.Replace(doc, "paths: {}\n", tail+"paths: {}\n", 1) -} - -// wantMapping names a discriminator, one of its mapping entries (or its default -// when tag is empty), and the type of the given kind that entry must resolve to. -type wantMapping struct { - base ir.TypeID - tag string - target ir.TypeID - kind ir.TypeKind -} diff --git a/compilers/openapi/internal/schema/helpers_test.go b/compilers/openapi/internal/schema/helpers_test.go index 790db859..24815bd6 100644 --- a/compilers/openapi/internal/schema/helpers_test.go +++ b/compilers/openapi/internal/schema/helpers_test.go @@ -248,3 +248,32 @@ func marshalForTest(t *testing.T, doc *ir.Document) string { require.NoError(t, err) return string(out) } + +// allOfConflictSpec builds a component whose two inline allOf branches each +// declare field v with the given flow-style schemas, for exercising per-keyword +// redeclaration conflict detection. +func allOfConflictSpec(schemaA, schemaB string) string { + return openapitest.ComponentSpec( + " T:\n" + + " allOf:\n" + + " - type: object\n" + + " properties: {v: " + schemaA + "}\n" + + " - type: object\n" + + " properties: {v: " + schemaB + "}\n") +} + +const ( + cat = " Cat: {type: object, properties: {kind: {type: string}, meow: {type: boolean}}}\n" + dog = " Dog: {type: object, properties: {kind: {type: string}, bark: {type: boolean}}}\n" + + // subCat and subDog declare Cat and Dog as allOf subtypes of Pet. + subCat = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {meow: {type: boolean}}}]}\n" + subDog = " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {bark: {type: boolean}}}]}\n" + subtypes = subCat + subDog + + petBranches = " oneOf:\n - $ref: '#/components/schemas/Cat'\n - $ref: '#/components/schemas/Dog'\n" + + // movedPointer is where the diagnostic for a discriminator kept verbatim + // rather than on Pet is reported. + movedPointer = "/components/schemas/Pet/discriminator" +) diff --git a/compilers/openapi/internal/schema/union_combinators_test.go b/compilers/openapi/internal/schema/union_combinators_test.go new file mode 100644 index 00000000..bd18dec7 --- /dev/null +++ b/compilers/openapi/internal/schema/union_combinators_test.go @@ -0,0 +1,325 @@ +package schema_test + +import ( + "maps" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/pass" +) + +// TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt pins that +// keeping a union verbatim does not cost the position the value constraints +// written beside it. The alias holding the union owns the pointer, so +// hoistDeclarationHome does not hoist the alias that would carry the bounds, +// and this alias must carry them itself (GitHub #343). Both reasons that keep a +// union hoist the same alias, and anyOf takes the same path as oneOf. The last +// case pins that a co-declared bound pair adds nothing beside the union. +func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) { + t.Parallel() + three := int64(3) + ten, five, zero := ir.BigVal("10"), ir.BigVal("5"), ir.BigVal("0") + cases := []struct { + name, schemas, unionKey string + reason ir.UnmodeledReason + want ir.Constraints + wantKept []string + }{ + { + name: "a validation-only union", + schemas: " A: {type: string, minLength: 3, oneOf: [{minLength: 1}, {minLength: 2}]}\n", + unionKey: "openapi:oneOf", + reason: ir.ReasonValidationOnly, + want: ir.Constraints{MinLength: &three}, + wantKept: []string{"openapi:oneOf"}, + }, + { + name: "a union kept as a degraded lowering", + schemas: " A: {type: number, minimum: 10, multipleOf: 5, oneOf: [{type: string}, {type: integer}]}\n", + unionKey: "openapi:oneOf", + reason: ir.ReasonDegradedLowering, + want: ir.Constraints{Min: &ten, MultipleOf: &five}, + wantKept: []string{"openapi:oneOf"}, + }, + { + name: "an anyOf kept in place of a oneOf", + schemas: " A: {type: string, minLength: 3, anyOf: [{minLength: 1}, {minLength: 2}]}\n", + unionKey: "openapi:anyOf", + reason: ir.ReasonValidationOnly, + want: ir.Constraints{MinLength: &three}, + wantKept: []string{"openapi:anyOf"}, + }, + { + // Both bound keywords reach a field, so the union is the only + // entry on the node: a co-declared pair adds nothing beside it. + name: "co-declared bounds beside a union", + schemas: " A: {type: number, minimum: 10, exclusiveMinimum: 0, oneOf: [{minLength: 1}, {minLength: 2}]}\n", + unionKey: "openapi:oneOf", + reason: ir.ReasonValidationOnly, + want: ir.Constraints{Min: &ten, ExclusiveMin: &zero}, + wantKept: []string{"openapi:oneOf"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(tc.schemas)) + openapitest.RequireNoErrorDiags(t, diags) + + sc, ok := typeByName(doc, "A").(*ir.Scalar) + require.True(t, ok, "the preserved union hoists an alias over the shared primitive") + entry, ok := sc.Unmodeled[tc.unionKey] + require.True(t, ok, "and keeps the union on it") + assert.Equal(t, tc.reason, entry.Reason) + require.NotNil(t, sc.Constraints, "while keeping the bounds written beside it") + assert.Empty(t, cmp.Diff(tc.want, *sc.Constraints)) + assert.Equal(t, tc.wantKept, unmodeledKeys(sc.Unmodeled), + "the union is the only entry kept on the node") + }) + } +} + +// TestUnionCombinators_PassedOverBranchSetIsKept covers the preference nothing +// used to record (GitHub #35). unionBranches takes oneOf whenever it is written +// and falls back to anyOf only when it is not, so a schema declaring both lost +// the anyOf outright — no variant, no Unmodeled entry, no diagnostic. The two +// conjoin, and one ir.Union carries one branch set, so the elected set stays the +// union and the other is kept beside it. +func TestUnionCombinators_PassedOverBranchSetIsKept(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: + oneOf: [{type: string}, {type: integer}] + anyOf: [{type: number}, {type: boolean}] +`)) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "S").(*ir.Union) + require.True(t, ok, "the elected branch set still becomes the union") + assert.Len(t, u.Variants, 2, "one variant per oneOf branch") + entry, ok := u.Unmodeled["openapi:anyOf"] + require.True(t, ok, "the set unionBranches passed over is kept; got %v", u.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `[{"type":"number"},{"type":"boolean"}]`, string(entry.Value)) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S"), + "lowered as its oneOf, with anyOf kept verbatim under Unmodeled") +} + +// TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf covers the second +// site of the same preference. A {X, null} oneOf normally collapses to nullable +// X, which resolves the position straight to X's own node — the shared string +// primitive here — leaving the co-declared anyOf nowhere of its own to sit; it +// was dropped there too. A schema writing both combinators is not nullable X in +// the first place, so it stays a Union and keeps the loser on it. +func TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: + oneOf: [{type: string}, {type: "null"}] + anyOf: [{type: number}, {type: boolean}] +`)) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "S").(*ir.Union) + require.True(t, ok, "the collapse is declined; got %v", typeByName(doc, "S")) + require.Len(t, u.Variants, 1, "the null branch still lifts off the variant list") + assert.Equal(t, ir.TypeID("t/prim/string"), u.Variants[0].Type.Target) + assert.Contains(t, u.Unmodeled, "openapi:anyOf", + "and the node the position now owns carries the set passed over") +} + +// TestUnionCombinators_UnpreservableIsNotAnnounced pins the pairing GitHub #144 +// exists for, at this site: a position may only announce what it actually kept. +// The anyOf here fails to convert, so the union lowers, the failure is reported +// at the keyword, and nothing claims the branch set survived. +func TestUnionCombinators_UnpreservableIsNotAnnounced(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: + oneOf: [{type: string}, {type: integer}] + anyOf: [{type: number, x-t: `+unpreservableValue+`}] +`)) + + u, ok := typeByName(doc, "S").(*ir.Union) + require.True(t, ok) + assert.NotContains(t, u.Unmodeled, "openapi:anyOf", "the conversion failed, so nothing was kept") + assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct), "the failure itself is reported") + assert.Empty(t, preservationClaims(diags), + "nothing was written under Unmodeled, so nothing may announce that it was") +} + +// TestUnionCombinators_KeepingIsOrderIndependent states the property over the +// whole document: declining the collapse gives this position a Union of its own +// and makes its branches ordinary union branches, so an outside $ref naming one +// of them must reach the same IR whichever is declared first. +// +// The branch writes a description deliberately. A bare `{type: string}` branch +// resolves through the union to the shared primitive, so the outside $ref alone +// hoists a node at the branch pointer and no hint ever has to agree with +// another. Pinning the union's own variant to that node puts both lowerings on +// the pointer (#181). +func TestUnionCombinators_KeepingIsOrderIndependent(t *testing.T) { + t.Parallel() + host := ` S: + oneOf: + - {type: string, description: the branch} + - {type: "null"} + anyOf: [{type: number}, {type: boolean}] +` + outsider := " Outsider: {$ref: '#/components/schemas/S/oneOf/0'}\n" + first, diags := parseFull(t, openapitest.ComponentSpec(outsider+host)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, openapitest.ComponentSpec(host+outsider)) + openapitest.RequireNoErrorDiags(t, diags) + + for _, doc := range []*ir.Document{first, last} { + u, ok := typeByName(doc, "S").(*ir.Union) + require.True(t, ok, "the position is the Union that keeps the passed-over set") + require.Len(t, u.Variants, 1, "the null branch still lifts off the variant list") + require.Equal(t, ir.TypeID("t/anon/components/schemas/S/oneOf/0"), u.Variants[0].Type.Target, + "the union reaches the branch's own node, or the $ref is the only lowering that does") + } + assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...), + "declaring the reference before or after the union must not change the IR") + assert.Empty(t, cmp.Diff(first.Types, last.Types), "nor any name hint in the registry") +} + +// TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition pins that a +// discriminator beside a oneOf/anyOf that collapses to a nullable reference is +// kept verbatim, whole, by whatever owns the position, with an info diagnostic, +// instead of vanishing (GitHub #595). The reference itself is unchanged. The +// component, the property, an array's items, additionalProperties, a parameter +// and a response body each reach the collapse by their own path. +func TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, collapseSpec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + cat := componentID("Cat") + + w, ok := typeByName(doc, "W").(*ir.Scalar) + require.True(t, ok, "the component is an alias over the nullable reference") + assert.Equal(t, ir.TypeRef{Target: cat, Nullable: true}, *w.Base, "the reference is unchanged") + entry, ok := w.Unmodeled["openapi:discriminator"] + require.True(t, ok, "the component keeps its discriminator") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, string(entry.Value), "defaultMapping", "the whole object is kept, not just the property name") + assert.Contains(t, string(entry.Value), `"mapping"`) + + holder, ok := typeByName(doc, "Holder").(*ir.Model) + require.True(t, ok) + require.Len(t, holder.Properties, 1) + assert.Equal(t, ir.TypeRef{Target: cat, Nullable: true}, holder.Properties[0].Type, "the property's reference is unchanged") + assert.Contains(t, holder.Properties[0].Unmodeled, "openapi:discriminator", "the property carrier keeps it") + + for _, id := range []ir.TypeID{"t/anon/components/schemas/L/items", "t/anon/components/schemas/M/additionalProperties"} { + node, ok := doc.Types[id] + require.True(t, ok, id) + assert.Contains(t, node.Common().Unmodeled, "openapi:discriminator", "%s keeps it on the node it hoists", id) + } + + for _, pointer := range []string{ + "/components/schemas/W", "/components/schemas/Holder/properties/w", + "/components/schemas/L/items", "/components/schemas/M/additionalProperties", + "/paths/~1p/get/parameters/0/schema", "/paths/~1p/get/responses/200/content/application~1json/schema", + } { + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), + "lowered to a nullable reference to its one non-null branch, which has no home for discriminator", pointer) + } + assert.Equal(t, 6, strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), + "one entry per position, including the parameter and the response, none duplicated") +} + +// TestCollapsedUnion_KeepsEveryCensusKeywordItCannotHold pins that the collapse +// takes the whole census a $ref site takes, not a discriminator rule: a format +// or a collection bound is no more at home on a nullable reference. A null +// type, which the reference's Nullable bit carries, is, so the component's +// alias keeps nothing for it. +func TestCollapsedUnion_KeepsEveryCensusKeywordItCannotHold(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} + Bounded: {` + nullableCat + `, format: uuid, maxItems: 2} + NullTyped: {` + nullableCat + `, type: 'null'} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + bounded, ok := typeByName(doc, "Bounded").(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, []string{"openapi:format", "openapi:maxItems"}, slices.Sorted(maps.Keys(bounded.Unmodeled))) + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, + "/components/schemas/Bounded"), "no home for format, maxItems") + + assert.Empty(t, typeByName(doc, "NullTyped").Common().Unmodeled) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/NullTyped")) +} + +// TestCollapsedUnion_WithoutACensusKeywordHoistsNothing pins that the same +// positions without one stay as they were: no entry, no diagnostic, and no +// node hoisted for a position that has none. +func TestCollapsedUnion_WithoutACensusKeywordHoistsNothing(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} + L: {type: array, items: {` + nullableCat + `}} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Zero(t, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "%+v", diags) + assert.NotContains(t, marshalForTest(t, doc), "openapi:discriminator") + assert.NotContains(t, doc.Types, ir.TypeID("t/anon/components/schemas/L/items"), "a bare nullable reference needs no node") +} + +// collapseSpec declares a nullable Cat at every position a schema can sit at +// with a discriminator beside it, plus a parameter and a response body. +const collapseSpec = `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /p: + get: + parameters: + - name: q + in: query + schema: + ` + nullableCat + ` + discriminator: {propertyName: kind} + responses: + "200": + description: ok + content: + application/json: + schema: + ` + nullableCat + ` + discriminator: {propertyName: kind} +components: + schemas: + Cat: {type: object, properties: {kind: {type: string}}} + W: + ` + nullableCat + ` + discriminator: {propertyName: kind, defaultMapping: '#/components/schemas/Cat', mapping: {cat: '#/components/schemas/Cat'}} + Holder: + type: object + properties: + w: + anyOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}] + discriminator: {propertyName: kind} + L: + type: array + items: + ` + nullableCat + ` + discriminator: {propertyName: kind} + M: + type: object + additionalProperties: + ` + nullableCat + ` + discriminator: {propertyName: kind} +` + +const nullableCat = "oneOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}]" diff --git a/compilers/openapi/internal/schema/union_oneof_test.go b/compilers/openapi/internal/schema/union_oneof_test.go new file mode 100644 index 00000000..6bd868c4 --- /dev/null +++ b/compilers/openapi/internal/schema/union_oneof_test.go @@ -0,0 +1,569 @@ +package schema_test + +import ( + "encoding/json/jsontext" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +func TestOneOf_WithDiscriminator(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Cat: + type: object + properties: + petType: {type: string} + Dog: + type: object + properties: + petType: {type: string} + Pet: + oneOf: + - {$ref: "#/components/schemas/Cat"} + - {$ref: "#/components/schemas/Dog"} + discriminator: + propertyName: petType + mapping: + cat: "#/components/schemas/Cat" +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + pet, ok := doc.Types[componentID("Pet")].(*ir.Union) + require.True(t, ok, "Pet should be a union") + assert.True(t, pet.Exclusive, "oneOf is exclusive") + assert.True(t, pet.WireTagged, "the discriminator's tag property names the variant (ir-design §4.4)") + require.Len(t, pet.Variants, 2, "both variants present") + require.NotNil(t, pet.Discriminator) + assert.Equal(t, "petType", pet.Discriminator.PropertyName) + assert.Equal(t, map[string]ir.TypeID{"cat": "t/openapi/components/schemas/Cat"}, + pet.Discriminator.Mapping) +} + +func TestAnyOf_IsNonExclusiveUnion(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` U: + anyOf: + - {type: string} + - {type: integer} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + u, ok := doc.Types[componentID("U")].(*ir.Union) + require.True(t, ok, "U should be a union") + assert.False(t, u.Exclusive, "anyOf is non-exclusive") + assert.False(t, u.WireTagged, "with no discriminator, validation infers the variant") + require.Len(t, u.Variants, 2) +} + +func TestOneOf_NullVariantCollapses(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` S: + type: object + properties: + p: + oneOf: + - {type: string} + - {type: "null"} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + s, ok := doc.Types[componentID("S")].(*ir.Model) + require.True(t, ok) + require.Len(t, s.Properties, 1) + assert.Equal(t, ir.TypeRef{Target: "t/prim/string", Nullable: true}, s.Properties[0].Type) + for id, def := range doc.Types { + _, isUnion := def.(*ir.Union) + assert.False(t, isUnion, "null-variant oneOf must not produce a union node: %s", id) + } +} + +func TestOneOf_ThreeVariantsWithNullStripsNullLiftsNullable(t *testing.T) { + t.Parallel() + // A oneOf with two non-null branches plus a null branch stays a Union of the + // two non-null variants (the null branch is NOT emitted as an `any` variant), + // and the enclosing ref becomes Nullable. + spec := openapitest.ComponentSpec(` S: + type: object + properties: + p: + oneOf: + - {type: string} + - {type: integer} + - {type: "null"} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + s := doc.Types[componentID("S")].(*ir.Model) + require.Len(t, s.Properties, 1) + ref := s.Properties[0].Type + assert.True(t, ref.Nullable, "the null branch lifts onto the enclosing ref") + + u, ok := doc.Types[ref.Target].(*ir.Union) + require.True(t, ok, "the non-null branches form a Union") + require.Len(t, u.Variants, 2, "the null branch is stripped, not emitted as a variant") + for _, v := range u.Variants { + assert.NotEqual(t, ir.TypeID("t/prim/any"), v.Type.Target, + "no variant degraded to any") + } +} + +func TestAnyOf_ThreeVariantsWithNull(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` N: + anyOf: + - {type: string} + - {type: integer} + - {type: 'null'} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + u := typeByName(doc, "N").(*ir.Union) + assert.Len(t, u.Variants, 2, "null branch stripped from variants") + assert.False(t, u.Exclusive, "anyOf is not exclusive") +} + +// TestOneOf_CoDeclaredCompositionDistributes is the regression from +// reference-learnings §B11: an allOf composition co-declared with a oneOf used +// to survive while the union was dropped to Unmodeled. Both must survive, and +// the shape that says so is a Union whose every variant carries the composition +// — `Base ∧ (A | B)` written as `(Base ∧ A) | (Base ∧ B)`. +func TestOneOf_CoDeclaredCompositionDistributes(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} + A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Combo: + allOf: [{$ref: '#/components/schemas/Base'}] + oneOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/B'} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "Combo").(*ir.Union) + require.True(t, ok, "the union is the schema's value, not a preserved sibling") + assert.True(t, u.Exclusive, "oneOf stays exclusive") + require.Len(t, u.Variants, 2) + assert.Empty(t, u.Unmodeled, "distribution loses nothing, so nothing is kept verbatim") + + for i, branch := range []string{"A", "B"} { + v := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.NotNil(t, v.Base, "the enclosing allOf's sole $ref stays Base (ir-design §4.3)") + assert.Equal(t, componentID("Base"), v.Base.Target) + require.Len(t, v.Mixins, 1, "the branch joins as a mixin, the composition already having a base") + assert.Equal(t, componentID(branch), v.Mixins[0].Target) + assert.Equal(t, ir.CanonicalWords(branch), u.Variants[i].Name.Hint, + "the variant is named after its branch, in the neutral words every name channel carries") + } + assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.CompositionLowering, ir.SeverityInfo), + "the reshaping is reported once; got %+v", diags) +} + +// TestAnyOf_CoDeclaredPropertiesDistributeAsBase covers the other classification +// arm: with no enclosing composition to take Base, the branch takes it, and an +// anyOf stays non-exclusive through distribution. +func TestAnyOf_CoDeclaredPropertiesDistributeAsBase(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Combo: + type: object + required: [kind] + additionalProperties: false + properties: {kind: {type: string}} + anyOf: + - {$ref: '#/components/schemas/A'} + - {$ref: '#/components/schemas/B'} +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "Combo").(*ir.Union) + require.True(t, ok) + assert.False(t, u.Exclusive, "anyOf stays non-exclusive") + require.Len(t, u.Variants, 2) + + v := doc.Types[u.Variants[0].Type.Target].(*ir.Model) + require.NotNil(t, v.Base, "with nothing composed yet, the branch becomes Base") + assert.Equal(t, componentID("A"), v.Base.Target) + assert.Empty(t, v.Mixins) + require.Len(t, v.Properties, 1, "the body's own properties ride on every variant") + assert.Equal(t, "kind", v.Properties[0].WireName) + assert.True(t, v.Properties[0].Required, "so does its required list") + assert.Equal(t, ir.AdditionalClosed, v.Additional, "and its openness") +} + +// TestOneOf_CoDeclaredVariantNotStolenByRefToBranch is the regression for the +// pointer collision: the composed variants used to be interned at the branch +// pointers, which a $ref elsewhere in the document denotes too. Whichever +// lowered first took the pointer, so an unrelated reference could leave one +// variant a bare alias of its branch while its siblings carried the body — the +// disagreement ir-design §4.3 forbids, decided by declaration order. +func TestOneOf_CoDeclaredVariantNotStolenByRefToBranch(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + branch int + outsiderFirst bool + }{ + {"outsider declared first", 0, true}, + {"outsider declared last", 0, false}, + {"ref to the second branch", 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, comboRefStealSpec(tc.branch, tc.outsiderFirst)) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "Combo").(*ir.Union) + require.True(t, ok) + require.Len(t, u.Variants, 2) + for i, branch := range []string{"A", "B"} { + v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok, "variant %d keeps the composed body", i) + require.NotNil(t, v.Base) + assert.Equal(t, componentID("Base"), v.Base.Target) + require.Len(t, v.Mixins, 1) + assert.Equal(t, componentID(branch), v.Mixins[0].Target) + } + + // The outside reference gets the branch schema, which is what its + // pointer names — never the conjunction the variant stands for. + outsider, ok := typeByName(doc, "Outsider").(*ir.Model) + require.True(t, ok) + require.Len(t, outsider.Properties, 1) + branchNode, ok := doc.Types[outsider.Properties[0].Type.Target].(*ir.Scalar) + require.True(t, ok, "the branch pointer hoists an alias of the branch's target") + require.NotNil(t, branchNode.Base) + assert.Equal(t, componentID([]string{"A", "B"}[tc.branch]), branchNode.Base.Target) + }) + } +} + +// TestOneOf_CoDeclaredDistributionIsOrderIndependent states the property over +// the whole compiled document: the same components in two declaration orders +// compile to the same IR. Each case permutes a site the distribution shares +// with something outside it: an outside $ref aimed at either union branch, and +// the discriminated base the variants take their tag from. +// +// These cases permute only components that emit no diagnostics, since +// diagnostics are appended in traversal order and so differ in order even when +// they match. The registry is also compared on its own, with nothing excluded. +func TestOneOf_CoDeclaredDistributionIsOrderIndependent(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + // spec writes the document with the permuted component declared first. + spec func(first bool) string + }{ + {"outside ref at the first branch", func(f bool) string { return comboRefStealSpec(0, f) }}, + {"outside ref at the second branch", func(f bool) string { return comboRefStealSpec(1, f) }}, + {"discriminated base", comboDiscriminatedSpec}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + first, diags := parseFull(t, tc.spec(true)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, tc.spec(false)) + openapitest.RequireNoErrorDiags(t, diags) + + assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...), + "declaring the permuted component before or after the union must not change the IR") + assert.Empty(t, cmp.Diff(first.Types, last.Types), "nor any name hint in the registry") + }) + } +} + +// TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue pins the tag the variants +// inherit. The enclosing schema is an allOf subtype of a discriminated base, so +// every variant is written on the wire with that subtype's tag — and the tag +// comes from the base's mapping entry for the *enclosing* schema, since that is +// the node the mapping names. +func TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, comboDiscriminatedSpec(true)) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "Combo").(*ir.Union) + require.True(t, ok) + require.Len(t, u.Variants, 2) + for i := range u.Variants { + v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok) + assert.Equal(t, "combo", v.DiscriminatorValue, + "variant %d carries the mapping key naming the enclosing schema, not its own hint", i) + } +} + +// TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue extends +// GitHub #517 to buildComposedVariant, the union-branch caller of +// subtypeDiscriminatorValue: the enclosing schema of this co-declared oneOf is +// hoisted inline (a property, not a component), so it has no schema name and +// its distributed variants get no implicit tag either — the same rule the allOf +// case pins, at the other of the two call sites the fix touches. Before the fix +// both variants took the property key "Combo" as their tag, a value the document +// never assigned and one a component named Combo would also answer to. +func TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Pet: + type: object + required: [kind] + properties: + kind: {type: string} + discriminator: + propertyName: kind + A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Kennel: + type: object + properties: + Combo: + allOf: + - $ref: '#/components/schemas/Pet' + oneOf: + - $ref: '#/components/schemas/A' + - $ref: '#/components/schemas/B' +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := doc.Types[ir.TypeID("t/anon/components/schemas/Kennel/properties/Combo")].(*ir.Union) + require.True(t, ok, "the co-declared oneOf hoists a union at the inline property") + require.Len(t, u.Variants, 2) + for i := range u.Variants { + v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok) + assert.Empty(t, v.DiscriminatorValue, + "variant %d has no mapping entry and its enclosing schema has no name to fall back to", i) + } +} + +// TestOneOf_CoDeclaredAdditionalPropsHintNamesTheBody covers the naming of the +// one node every variant shares. additionalProperties is declared once, on the +// enclosing schema, and lowers at its pointer — so the first variant to reach it +// must not stamp its own branch name on the shared node. +func TestOneOf_CoDeclaredAdditionalPropsHintNamesTheBody(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Combo: + type: object + properties: {kind: {type: string}} + additionalProperties: {type: object, properties: {v: {type: string}}} + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + u := typeByName(doc, "Combo").(*ir.Union) + v, ok := doc.Types[u.Variants[0].Type.Target].(*ir.Model) + require.True(t, ok) + require.NotNil(t, v.AdditionalProps) + assert.Equal(t, "combo_value", doc.Types[v.AdditionalProps.Value.Target].Common().Name.Hint) +} + +// TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten pins what §4.3 says +// Base/Mixins may name. JSON Schema lets a composition conjoin any schema, so a +// branch can reference a scalar, an enum or another union; the referent is +// carried as written, because narrowing it would mean dropping a branch the +// source declared. fillAllOf classifies an allOf entry the same way. +func TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Scalar: {type: string, minLength: 2} + Choice: {enum: [a, b]} + Combo: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/Scalar'}, {$ref: '#/components/schemas/Choice'}] +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + u, ok := typeByName(doc, "Combo").(*ir.Union) + require.True(t, ok) + require.Len(t, u.Variants, 2) + for i, want := range []string{"Scalar", "Choice"} { + v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok) + require.NotNil(t, v.Base, "variant %d conjoins its branch", i) + assert.Equal(t, componentID(want), v.Base.Target) + } + assert.IsType(t, &ir.Scalar{}, doc.Types[componentID("Scalar")]) + assert.IsType(t, &ir.Enum{}, doc.Types[componentID("Choice")]) +} + +// TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed covers the branches +// that are $ref-shaped but name nothing this compilation can resolve. Each +// would otherwise half-distribute — the variant carrying the body while its +// branch vanished — so all of them keep the verbatim lowering, which loses +// nothing, and each still reports the broken reference. +// +// It drives the lowering alone (lowerSpecOnly), so the report checked is the +// lowering's own, at the branch pointer, with the reason the bindings give. A +// full compile +// keeps the load phase's report of the same $ref instead; see +// TestCompile_AResolutionFailureIsReportedOnceAtItsRef. +func TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} + Undeclared: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/Ghost'}, {$ref: '#/components/schemas/A'}] + CrossDocument: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: 'other.yaml#/components/schemas/A'}] + EmptyRef: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: ''}] + NoSuchPointer: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/A/properties/missing'}] +`) + doc, diags := lowerSpecOnly(t, spec) + + for name, why := range map[string]string{ + "Undeclared": `"#/components/schemas/Ghost" is unresolved: it names nothing in the document it points into`, + "CrossDocument": `"other.yaml#/components/schemas/A" is unresolved: it names another document, and external references are not read`, + "EmptyRef": `"" is unresolved: it names a position that holds no schema`, + "NoSuchPointer": `"#/components/schemas/A/properties/missing" is unresolved: it names nothing in the document it points into`, + } { + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s keeps its structural body rather than becoming a union", name) + entry, ok := m.Unmodeled["openapi:oneOf"] + require.True(t, ok, "%s keeps every branch verbatim", name) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, "%s", name) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/"+name), + "names no referent this compilation resolves", name) + // One error per offending branch, at the branch itself. + assert.Equal(t, "union branch $ref "+why+"; the branch is kept verbatim", + openapitest.DiagMessageAt(t, diags, diag.UnresolvedRef, ir.SeverityError, "/components/schemas/"+name+"/oneOf/0"), + name) + } + for _, d := range diags { + assert.NotEqual(t, jsontext.Pointer("/components/schemas/Undeclared/oneOf/1"), d.Provenance.Pointer, + "the branch beside the broken one resolves, so nothing is reported against it") + } +} + +// TestOneOf_CoDeclaredNotDistributedReasons pins the five shapes distribution +// declines, each kept verbatim under ReasonDegradedLowering with its own reason +// in the diagnostic (docs/compilers/openapi.md#union-beside-structure). +// Half-distributing any of them would leave a Union whose variants disagree +// about whether they carry the body. The messages are asserted, not just the +// code: the reasons are the only thing telling the otherwise identical +// diagnostics apart. +func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} + NotAModel: + const: fixed + oneOf: [{$ref: '#/components/schemas/A'}] + InlineBranch: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/A'}, {type: string}] + NullBranch: + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/A'}, {type: 'null'}] + BothCombinators: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/A'}] + anyOf: [{$ref: '#/components/schemas/A'}] + Discriminated: + type: object + properties: {kind: {type: string}} + discriminator: {propertyName: kind} + oneOf: [{$ref: '#/components/schemas/A'}] +`) + doc, diags := schema.LowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + for name, reason := range map[string]string{ + "NotAModel": "the body is not a model, so it carries no composition to distribute into", + "InlineBranch": "a branch is written inline, so it names no referent to conjoin the body with", + "NullBranch": "a branch is written inline, so it names no referent to conjoin the body with", + "BothCombinators": "oneOf and anyOf are both declared, so distributing either would drop the other", + "Discriminated": "a declared discriminator binds the branches by name, which distributing them would break", + } { + entry, ok := typeByName(doc, name).Common().Unmodeled["openapi:oneOf"] + require.True(t, ok, "%s keeps its union verbatim", name) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, "%s", name) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/"+name), + reason, name) + } + both := typeByName(doc, "BothCombinators").(*ir.Model) + _, ok := both.Unmodeled["openapi:anyOf"] + assert.True(t, ok, "the second combinator is kept too, which is why neither is distributed") + nullBranch := typeByName(doc, "NullBranch").(*ir.Model) + assert.Contains(t, string(nullBranch.Unmodeled["openapi:oneOf"].Value), `"null"`, + "a null branch is written inline, so it blocks distribution rather than lifting to Nullable") + // The sixth is settling's, once the registry is whole: Discriminated's + // discriminator routes to no subtype of its model, so it stays beside the + // union it was written for. + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, + "/components/schemas/Discriminated/discriminator"), + "not a subtype of the model holding it") + assert.Equal(t, 6, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "each declined shape is reported once, and the kept discriminator once; got %+v", diags) +} + +// comboRefStealSpec is the co-declared union of Base with A|B, plus one $ref +// aimed at a union branch's own pointer. The two orders of the same four +// components must lower identically: the branch pointer denotes the branch +// schema in both, and the variants are Morphic's own nodes in both. +func comboRefStealSpec(branch int, outsiderFirst bool) string { + outsider := fmt.Sprintf( + " Outsider: {type: object, properties: {x: {$ref: '#/components/schemas/Combo/oneOf/%d'}}}\n", branch) + combo := ` Base: {type: object, properties: {id: {type: string}}} + A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Combo: + allOf: [{$ref: '#/components/schemas/Base'}] + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +` + if outsiderFirst { + return openapitest.ComponentSpec(outsider + combo) + } + return openapitest.ComponentSpec(combo + outsider) +} + +// comboDiscriminatedSpec is the co-declared union of a discriminated Base with +// A|B. Every variant is written on the wire with the enclosing schema's tag, +// which the base's mapping supplies, so the base and the schemas naming it are +// permutable against each other — components lower in source order. +func comboDiscriminatedSpec(baseFirst bool) string { + base := ` Base: + type: object + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: {combo: '#/components/schemas/Combo'} +` + rest := ` A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Combo: + allOf: [{$ref: '#/components/schemas/Base'}] + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] +` + if baseFirst { + return openapitest.ComponentSpec(base + rest) + } + return openapitest.ComponentSpec(rest + base) +} diff --git a/compilers/openapi/internal/schema/union_test.go b/compilers/openapi/internal/schema/union_test.go index 56d6ec72..a1e73c6f 100644 --- a/compilers/openapi/internal/schema/union_test.go +++ b/compilers/openapi/internal/schema/union_test.go @@ -2,8 +2,6 @@ package schema_test import ( "encoding/json/jsontext" - "fmt" - "maps" "slices" "strings" "testing" @@ -16,7 +14,6 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" - "github.com/dexpace/morphic/pass" ) // TestDistributedUnion_EachVariantCarriesTheRequiresOfItsBody pins that every @@ -255,76 +252,6 @@ components: assert.Equal(t, "cat_yaml", pet.Variants[1].Name.Hint, "the wrapped branch is named for its $ref") } -func TestOneOf_WithDiscriminator(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Cat: - type: object - properties: - petType: {type: string} - Dog: - type: object - properties: - petType: {type: string} - Pet: - oneOf: - - {$ref: "#/components/schemas/Cat"} - - {$ref: "#/components/schemas/Dog"} - discriminator: - propertyName: petType - mapping: - cat: "#/components/schemas/Cat" -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - pet, ok := doc.Types[componentID("Pet")].(*ir.Union) - require.True(t, ok, "Pet should be a union") - assert.True(t, pet.Exclusive, "oneOf is exclusive") - assert.True(t, pet.WireTagged, "the discriminator's tag property names the variant (ir-design §4.4)") - require.Len(t, pet.Variants, 2, "both variants present") - require.NotNil(t, pet.Discriminator) - assert.Equal(t, "petType", pet.Discriminator.PropertyName) - assert.Equal(t, map[string]ir.TypeID{"cat": "t/openapi/components/schemas/Cat"}, - pet.Discriminator.Mapping) -} - -func TestAnyOf_IsNonExclusiveUnion(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` U: - anyOf: - - {type: string} - - {type: integer} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - u, ok := doc.Types[componentID("U")].(*ir.Union) - require.True(t, ok, "U should be a union") - assert.False(t, u.Exclusive, "anyOf is non-exclusive") - assert.False(t, u.WireTagged, "with no discriminator, validation infers the variant") - require.Len(t, u.Variants, 2) -} - -func TestOneOf_NullVariantCollapses(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` S: - type: object - properties: - p: - oneOf: - - {type: string} - - {type: "null"} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - s, ok := doc.Types[componentID("S")].(*ir.Model) - require.True(t, ok) - require.Len(t, s.Properties, 1) - assert.Equal(t, ir.TypeRef{Target: "t/prim/string", Nullable: true}, s.Properties[0].Type) - for id, def := range doc.Types { - _, isUnion := def.(*ir.Union) - assert.False(t, isUnion, "null-variant oneOf must not produce a union node: %s", id) - } -} - // TestOneOfAnyOf_NullOnlyLowersToNullableAny covers a oneOf/anyOf whose every // branch is a bare `type: null` schema (GitHub #416). nullUnionCollapse has no // non-null branch to collapse a set like this onto, so without a guard for the @@ -367,51 +294,6 @@ func TestOneOfAnyOf_NullOnlyLowersToNullableAny(t *testing.T) { } } -func TestOneOf_ThreeVariantsWithNullStripsNullLiftsNullable(t *testing.T) { - t.Parallel() - // A oneOf with two non-null branches plus a null branch stays a Union of the - // two non-null variants (the null branch is NOT emitted as an `any` variant), - // and the enclosing ref becomes Nullable. - spec := openapitest.ComponentSpec(` S: - type: object - properties: - p: - oneOf: - - {type: string} - - {type: integer} - - {type: "null"} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - s := doc.Types[componentID("S")].(*ir.Model) - require.Len(t, s.Properties, 1) - ref := s.Properties[0].Type - assert.True(t, ref.Nullable, "the null branch lifts onto the enclosing ref") - - u, ok := doc.Types[ref.Target].(*ir.Union) - require.True(t, ok, "the non-null branches form a Union") - require.Len(t, u.Variants, 2, "the null branch is stripped, not emitted as a variant") - for _, v := range u.Variants { - assert.NotEqual(t, ir.TypeID("t/prim/any"), v.Type.Target, - "no variant degraded to any") - } -} - -func TestAnyOf_ThreeVariantsWithNull(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` N: - anyOf: - - {type: string} - - {type: integer} - - {type: 'null'} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - u := typeByName(doc, "N").(*ir.Union) - assert.Len(t, u.Variants, 2, "null branch stripped from variants") - assert.False(t, u.Exclusive, "anyOf is not exclusive") -} - func TestUnion_VariantHints(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` U: @@ -630,159 +512,6 @@ func TestUnion_ADecoratedBranchIsHintedTheSameInEitherOrder(t *testing.T) { assert.Empty(t, cmp.Diff(first.Types[branch], last.Types[branch]), "the branch's node") } -// TestOneOf_CoDeclaredCompositionDistributes is the regression from -// reference-learnings §B11: an allOf composition co-declared with a oneOf used -// to survive while the union was dropped to Unmodeled. Both must survive, and -// the shape that says so is a Union whose every variant carries the composition -// — `Base ∧ (A | B)` written as `(Base ∧ A) | (Base ∧ B)`. -func TestOneOf_CoDeclaredCompositionDistributes(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} - A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Combo: - allOf: [{$ref: '#/components/schemas/Base'}] - oneOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/B'} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "Combo").(*ir.Union) - require.True(t, ok, "the union is the schema's value, not a preserved sibling") - assert.True(t, u.Exclusive, "oneOf stays exclusive") - require.Len(t, u.Variants, 2) - assert.Empty(t, u.Unmodeled, "distribution loses nothing, so nothing is kept verbatim") - - for i, branch := range []string{"A", "B"} { - v := doc.Types[u.Variants[i].Type.Target].(*ir.Model) - require.NotNil(t, v.Base, "the enclosing allOf's sole $ref stays Base (ir-design §4.3)") - assert.Equal(t, componentID("Base"), v.Base.Target) - require.Len(t, v.Mixins, 1, "the branch joins as a mixin, the composition already having a base") - assert.Equal(t, componentID(branch), v.Mixins[0].Target) - assert.Equal(t, ir.CanonicalWords(branch), u.Variants[i].Name.Hint, - "the variant is named after its branch, in the neutral words every name channel carries") - } - assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.CompositionLowering, ir.SeverityInfo), - "the reshaping is reported once; got %+v", diags) -} - -// TestAnyOf_CoDeclaredPropertiesDistributeAsBase covers the other classification -// arm: with no enclosing composition to take Base, the branch takes it, and an -// anyOf stays non-exclusive through distribution. -func TestAnyOf_CoDeclaredPropertiesDistributeAsBase(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Combo: - type: object - required: [kind] - additionalProperties: false - properties: {kind: {type: string}} - anyOf: - - {$ref: '#/components/schemas/A'} - - {$ref: '#/components/schemas/B'} -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "Combo").(*ir.Union) - require.True(t, ok) - assert.False(t, u.Exclusive, "anyOf stays non-exclusive") - require.Len(t, u.Variants, 2) - - v := doc.Types[u.Variants[0].Type.Target].(*ir.Model) - require.NotNil(t, v.Base, "with nothing composed yet, the branch becomes Base") - assert.Equal(t, componentID("A"), v.Base.Target) - assert.Empty(t, v.Mixins) - require.Len(t, v.Properties, 1, "the body's own properties ride on every variant") - assert.Equal(t, "kind", v.Properties[0].WireName) - assert.True(t, v.Properties[0].Required, "so does its required list") - assert.Equal(t, ir.AdditionalClosed, v.Additional, "and its openness") -} - -// TestOneOf_CoDeclaredVariantNotStolenByRefToBranch is the regression for the -// pointer collision: the composed variants used to be interned at the branch -// pointers, which a $ref elsewhere in the document denotes too. Whichever -// lowered first took the pointer, so an unrelated reference could leave one -// variant a bare alias of its branch while its siblings carried the body — the -// disagreement ir-design §4.3 forbids, decided by declaration order. -func TestOneOf_CoDeclaredVariantNotStolenByRefToBranch(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - branch int - outsiderFirst bool - }{ - {"outsider declared first", 0, true}, - {"outsider declared last", 0, false}, - {"ref to the second branch", 1, true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, comboRefStealSpec(tc.branch, tc.outsiderFirst)) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "Combo").(*ir.Union) - require.True(t, ok) - require.Len(t, u.Variants, 2) - for i, branch := range []string{"A", "B"} { - v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) - require.True(t, ok, "variant %d keeps the composed body", i) - require.NotNil(t, v.Base) - assert.Equal(t, componentID("Base"), v.Base.Target) - require.Len(t, v.Mixins, 1) - assert.Equal(t, componentID(branch), v.Mixins[0].Target) - } - - // The outside reference gets the branch schema, which is what its - // pointer names — never the conjunction the variant stands for. - outsider, ok := typeByName(doc, "Outsider").(*ir.Model) - require.True(t, ok) - require.Len(t, outsider.Properties, 1) - branchNode, ok := doc.Types[outsider.Properties[0].Type.Target].(*ir.Scalar) - require.True(t, ok, "the branch pointer hoists an alias of the branch's target") - require.NotNil(t, branchNode.Base) - assert.Equal(t, componentID([]string{"A", "B"}[tc.branch]), branchNode.Base.Target) - }) - } -} - -// TestOneOf_CoDeclaredDistributionIsOrderIndependent states the property over -// the whole compiled document: the same components in two declaration orders -// compile to the same IR. Each case permutes a site the distribution shares -// with something outside it: an outside $ref aimed at either union branch, and -// the discriminated base the variants take their tag from. -// -// These cases permute only components that emit no diagnostics, since -// diagnostics are appended in traversal order and so differ in order even when -// they match. The registry is also compared on its own, with nothing excluded. -func TestOneOf_CoDeclaredDistributionIsOrderIndependent(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - // spec writes the document with the permuted component declared first. - spec func(first bool) string - }{ - {"outside ref at the first branch", func(f bool) string { return comboRefStealSpec(0, f) }}, - {"outside ref at the second branch", func(f bool) string { return comboRefStealSpec(1, f) }}, - {"discriminated base", comboDiscriminatedSpec}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - first, diags := parseFull(t, tc.spec(true)) - openapitest.RequireNoErrorDiags(t, diags) - last, diags := parseFull(t, tc.spec(false)) - openapitest.RequireNoErrorDiags(t, diags) - - assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...), - "declaring the permuted component before or after the union must not change the IR") - assert.Empty(t, cmp.Diff(first.Types, last.Types), "nor any name hint in the registry") - }) - } -} - // TestNullCollapse_BranchHintIsOrderIndependent is // TestComposition_BranchAliasIsOrderIndependent's rule at the site the collapse // takes instead. The @@ -811,421 +540,6 @@ func TestNullCollapse_BranchHintIsOrderIndependent(t *testing.T) { assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...), "nor the rest of the document") } -// TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue pins the tag the variants -// inherit. The enclosing schema is an allOf subtype of a discriminated base, so -// every variant is written on the wire with that subtype's tag — and the tag -// comes from the base's mapping entry for the *enclosing* schema, since that is -// the node the mapping names. -func TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, comboDiscriminatedSpec(true)) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "Combo").(*ir.Union) - require.True(t, ok) - require.Len(t, u.Variants, 2) - for i := range u.Variants { - v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) - require.True(t, ok) - assert.Equal(t, "combo", v.DiscriminatorValue, - "variant %d carries the mapping key naming the enclosing schema, not its own hint", i) - } -} - -// TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue extends -// GitHub #517 to buildComposedVariant, the union-branch caller of -// subtypeDiscriminatorValue: the enclosing schema of this co-declared oneOf is -// hoisted inline (a property, not a component), so it has no schema name and -// its distributed variants get no implicit tag either — the same rule the allOf -// case pins, at the other of the two call sites the fix touches. Before the fix -// both variants took the property key "Combo" as their tag, a value the document -// never assigned and one a component named Combo would also answer to. -func TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Pet: - type: object - required: [kind] - properties: - kind: {type: string} - discriminator: - propertyName: kind - A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Kennel: - type: object - properties: - Combo: - allOf: - - $ref: '#/components/schemas/Pet' - oneOf: - - $ref: '#/components/schemas/A' - - $ref: '#/components/schemas/B' -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := doc.Types[ir.TypeID("t/anon/components/schemas/Kennel/properties/Combo")].(*ir.Union) - require.True(t, ok, "the co-declared oneOf hoists a union at the inline property") - require.Len(t, u.Variants, 2) - for i := range u.Variants { - v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) - require.True(t, ok) - assert.Empty(t, v.DiscriminatorValue, - "variant %d has no mapping entry and its enclosing schema has no name to fall back to", i) - } -} - -// TestOneOf_CoDeclaredAdditionalPropsHintNamesTheBody covers the naming of the -// one node every variant shares. additionalProperties is declared once, on the -// enclosing schema, and lowers at its pointer — so the first variant to reach it -// must not stamp its own branch name on the shared node. -func TestOneOf_CoDeclaredAdditionalPropsHintNamesTheBody(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Combo: - type: object - properties: {kind: {type: string}} - additionalProperties: {type: object, properties: {v: {type: string}}} - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - u := typeByName(doc, "Combo").(*ir.Union) - v, ok := doc.Types[u.Variants[0].Type.Target].(*ir.Model) - require.True(t, ok) - require.NotNil(t, v.AdditionalProps) - assert.Equal(t, "combo_value", doc.Types[v.AdditionalProps.Value.Target].Common().Name.Hint) -} - -// TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten pins what §4.3 says -// Base/Mixins may name. JSON Schema lets a composition conjoin any schema, so a -// branch can reference a scalar, an enum or another union; the referent is -// carried as written, because narrowing it would mean dropping a branch the -// source declared. fillAllOf classifies an allOf entry the same way. -func TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Scalar: {type: string, minLength: 2} - Choice: {enum: [a, b]} - Combo: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/Scalar'}, {$ref: '#/components/schemas/Choice'}] -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "Combo").(*ir.Union) - require.True(t, ok) - require.Len(t, u.Variants, 2) - for i, want := range []string{"Scalar", "Choice"} { - v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) - require.True(t, ok) - require.NotNil(t, v.Base, "variant %d conjoins its branch", i) - assert.Equal(t, componentID(want), v.Base.Target) - } - assert.IsType(t, &ir.Scalar{}, doc.Types[componentID("Scalar")]) - assert.IsType(t, &ir.Enum{}, doc.Types[componentID("Choice")]) -} - -// TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed covers the branches -// that are $ref-shaped but name nothing this compilation can resolve. Each -// would otherwise half-distribute — the variant carrying the body while its -// branch vanished — so all of them keep the verbatim lowering, which loses -// nothing, and each still reports the broken reference. -// -// It drives the lowering alone (lowerSpecOnly), so the report checked is the -// lowering's own, at the branch pointer, with the reason the bindings give. A -// full compile -// keeps the load phase's report of the same $ref instead; see -// TestCompile_AResolutionFailureIsReportedOnceAtItsRef. -func TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} - Undeclared: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/Ghost'}, {$ref: '#/components/schemas/A'}] - CrossDocument: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: 'other.yaml#/components/schemas/A'}] - EmptyRef: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: ''}] - NoSuchPointer: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/A/properties/missing'}] -`) - doc, diags := lowerSpecOnly(t, spec) - - for name, why := range map[string]string{ - "Undeclared": `"#/components/schemas/Ghost" is unresolved: it names nothing in the document it points into`, - "CrossDocument": `"other.yaml#/components/schemas/A" is unresolved: it names another document, and external references are not read`, - "EmptyRef": `"" is unresolved: it names a position that holds no schema`, - "NoSuchPointer": `"#/components/schemas/A/properties/missing" is unresolved: it names nothing in the document it points into`, - } { - m, ok := typeByName(doc, name).(*ir.Model) - require.True(t, ok, "%s keeps its structural body rather than becoming a union", name) - entry, ok := m.Unmodeled["openapi:oneOf"] - require.True(t, ok, "%s keeps every branch verbatim", name) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, "%s", name) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/"+name), - "names no referent this compilation resolves", name) - // One error per offending branch, at the branch itself. - assert.Equal(t, "union branch $ref "+why+"; the branch is kept verbatim", - openapitest.DiagMessageAt(t, diags, diag.UnresolvedRef, ir.SeverityError, "/components/schemas/"+name+"/oneOf/0"), - name) - } - for _, d := range diags { - assert.NotEqual(t, jsontext.Pointer("/components/schemas/Undeclared/oneOf/1"), d.Provenance.Pointer, - "the branch beside the broken one resolves, so nothing is reported against it") - } -} - -// TestOneOf_CoDeclaredNotDistributedReasons pins the five shapes distribution -// declines, each kept verbatim under ReasonDegradedLowering with its own reason -// in the diagnostic (docs/compilers/openapi.md#union-beside-structure). -// Half-distributing any of them would leave a Union whose variants disagree -// about whether they carry the body. The messages are asserted, not just the -// code: the reasons are the only thing telling the otherwise identical -// diagnostics apart. -func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` A: {type: object, properties: {a: {type: string}}} - NotAModel: - const: fixed - oneOf: [{$ref: '#/components/schemas/A'}] - InlineBranch: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/A'}, {type: string}] - NullBranch: - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/A'}, {type: 'null'}] - BothCombinators: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/A'}] - anyOf: [{$ref: '#/components/schemas/A'}] - Discriminated: - type: object - properties: {kind: {type: string}} - discriminator: {propertyName: kind} - oneOf: [{$ref: '#/components/schemas/A'}] -`) - doc, diags := schema.LowerSpec(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - for name, reason := range map[string]string{ - "NotAModel": "the body is not a model, so it carries no composition to distribute into", - "InlineBranch": "a branch is written inline, so it names no referent to conjoin the body with", - "NullBranch": "a branch is written inline, so it names no referent to conjoin the body with", - "BothCombinators": "oneOf and anyOf are both declared, so distributing either would drop the other", - "Discriminated": "a declared discriminator binds the branches by name, which distributing them would break", - } { - entry, ok := typeByName(doc, name).Common().Unmodeled["openapi:oneOf"] - require.True(t, ok, "%s keeps its union verbatim", name) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, "%s", name) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/"+name), - reason, name) - } - both := typeByName(doc, "BothCombinators").(*ir.Model) - _, ok := both.Unmodeled["openapi:anyOf"] - assert.True(t, ok, "the second combinator is kept too, which is why neither is distributed") - nullBranch := typeByName(doc, "NullBranch").(*ir.Model) - assert.Contains(t, string(nullBranch.Unmodeled["openapi:oneOf"].Value), `"null"`, - "a null branch is written inline, so it blocks distribution rather than lifting to Nullable") - // The sixth is settling's, once the registry is whole: Discriminated's - // discriminator routes to no subtype of its model, so it stays beside the - // union it was written for. - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, - "/components/schemas/Discriminated/discriminator"), - "not a subtype of the model holding it") - assert.Equal(t, 6, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "each declined shape is reported once, and the kept discriminator once; got %+v", diags) -} - -// TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt pins that -// keeping a union verbatim does not cost the position the value constraints -// written beside it. The alias holding the union owns the pointer, so -// hoistDeclarationHome does not hoist the alias that would carry the bounds, -// and this alias must carry them itself (GitHub #343). Both reasons that keep a -// union hoist the same alias, and anyOf takes the same path as oneOf. The last -// case pins that a co-declared bound pair adds nothing beside the union. -func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) { - t.Parallel() - three := int64(3) - ten, five, zero := ir.BigVal("10"), ir.BigVal("5"), ir.BigVal("0") - cases := []struct { - name, schemas, unionKey string - reason ir.UnmodeledReason - want ir.Constraints - wantKept []string - }{ - { - name: "a validation-only union", - schemas: " A: {type: string, minLength: 3, oneOf: [{minLength: 1}, {minLength: 2}]}\n", - unionKey: "openapi:oneOf", - reason: ir.ReasonValidationOnly, - want: ir.Constraints{MinLength: &three}, - wantKept: []string{"openapi:oneOf"}, - }, - { - name: "a union kept as a degraded lowering", - schemas: " A: {type: number, minimum: 10, multipleOf: 5, oneOf: [{type: string}, {type: integer}]}\n", - unionKey: "openapi:oneOf", - reason: ir.ReasonDegradedLowering, - want: ir.Constraints{Min: &ten, MultipleOf: &five}, - wantKept: []string{"openapi:oneOf"}, - }, - { - name: "an anyOf kept in place of a oneOf", - schemas: " A: {type: string, minLength: 3, anyOf: [{minLength: 1}, {minLength: 2}]}\n", - unionKey: "openapi:anyOf", - reason: ir.ReasonValidationOnly, - want: ir.Constraints{MinLength: &three}, - wantKept: []string{"openapi:anyOf"}, - }, - { - // Both bound keywords reach a field, so the union is the only - // entry on the node: a co-declared pair adds nothing beside it. - name: "co-declared bounds beside a union", - schemas: " A: {type: number, minimum: 10, exclusiveMinimum: 0, oneOf: [{minLength: 1}, {minLength: 2}]}\n", - unionKey: "openapi:oneOf", - reason: ir.ReasonValidationOnly, - want: ir.Constraints{Min: &ten, ExclusiveMin: &zero}, - wantKept: []string{"openapi:oneOf"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(tc.schemas)) - openapitest.RequireNoErrorDiags(t, diags) - - sc, ok := typeByName(doc, "A").(*ir.Scalar) - require.True(t, ok, "the preserved union hoists an alias over the shared primitive") - entry, ok := sc.Unmodeled[tc.unionKey] - require.True(t, ok, "and keeps the union on it") - assert.Equal(t, tc.reason, entry.Reason) - require.NotNil(t, sc.Constraints, "while keeping the bounds written beside it") - assert.Empty(t, cmp.Diff(tc.want, *sc.Constraints)) - assert.Equal(t, tc.wantKept, unmodeledKeys(sc.Unmodeled), - "the union is the only entry kept on the node") - }) - } -} - -// TestUnionCombinators_PassedOverBranchSetIsKept covers the preference nothing -// used to record (GitHub #35). unionBranches takes oneOf whenever it is written -// and falls back to anyOf only when it is not, so a schema declaring both lost -// the anyOf outright — no variant, no Unmodeled entry, no diagnostic. The two -// conjoin, and one ir.Union carries one branch set, so the elected set stays the -// union and the other is kept beside it. -func TestUnionCombinators_PassedOverBranchSetIsKept(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: - oneOf: [{type: string}, {type: integer}] - anyOf: [{type: number}, {type: boolean}] -`)) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "S").(*ir.Union) - require.True(t, ok, "the elected branch set still becomes the union") - assert.Len(t, u.Variants, 2, "one variant per oneOf branch") - entry, ok := u.Unmodeled["openapi:anyOf"] - require.True(t, ok, "the set unionBranches passed over is kept; got %v", u.Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, `[{"type":"number"},{"type":"boolean"}]`, string(entry.Value)) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S"), - "lowered as its oneOf, with anyOf kept verbatim under Unmodeled") -} - -// TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf covers the second -// site of the same preference. A {X, null} oneOf normally collapses to nullable -// X, which resolves the position straight to X's own node — the shared string -// primitive here — leaving the co-declared anyOf nowhere of its own to sit; it -// was dropped there too. A schema writing both combinators is not nullable X in -// the first place, so it stays a Union and keeps the loser on it. -func TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: - oneOf: [{type: string}, {type: "null"}] - anyOf: [{type: number}, {type: boolean}] -`)) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "S").(*ir.Union) - require.True(t, ok, "the collapse is declined; got %v", typeByName(doc, "S")) - require.Len(t, u.Variants, 1, "the null branch still lifts off the variant list") - assert.Equal(t, ir.TypeID("t/prim/string"), u.Variants[0].Type.Target) - assert.Contains(t, u.Unmodeled, "openapi:anyOf", - "and the node the position now owns carries the set passed over") -} - -// TestUnionCombinators_UnpreservableIsNotAnnounced pins the pairing GitHub #144 -// exists for, at this site: a position may only announce what it actually kept. -// The anyOf here fails to convert, so the union lowers, the failure is reported -// at the keyword, and nothing claims the branch set survived. -func TestUnionCombinators_UnpreservableIsNotAnnounced(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, openapitest.ComponentSpec(` S: - oneOf: [{type: string}, {type: integer}] - anyOf: [{type: number, x-t: `+unpreservableValue+`}] -`)) - - u, ok := typeByName(doc, "S").(*ir.Union) - require.True(t, ok) - assert.NotContains(t, u.Unmodeled, "openapi:anyOf", "the conversion failed, so nothing was kept") - assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct), "the failure itself is reported") - assert.Empty(t, preservationClaims(diags), - "nothing was written under Unmodeled, so nothing may announce that it was") -} - -// TestUnionCombinators_KeepingIsOrderIndependent states the property over the -// whole document: declining the collapse gives this position a Union of its own -// and makes its branches ordinary union branches, so an outside $ref naming one -// of them must reach the same IR whichever is declared first. -// -// The branch writes a description deliberately. A bare `{type: string}` branch -// resolves through the union to the shared primitive, so the outside $ref alone -// hoists a node at the branch pointer and no hint ever has to agree with -// another. Pinning the union's own variant to that node puts both lowerings on -// the pointer (#181). -func TestUnionCombinators_KeepingIsOrderIndependent(t *testing.T) { - t.Parallel() - host := ` S: - oneOf: - - {type: string, description: the branch} - - {type: "null"} - anyOf: [{type: number}, {type: boolean}] -` - outsider := " Outsider: {$ref: '#/components/schemas/S/oneOf/0'}\n" - first, diags := parseFull(t, openapitest.ComponentSpec(outsider+host)) - openapitest.RequireNoErrorDiags(t, diags) - last, diags := parseFull(t, openapitest.ComponentSpec(host+outsider)) - openapitest.RequireNoErrorDiags(t, diags) - - for _, doc := range []*ir.Document{first, last} { - u, ok := typeByName(doc, "S").(*ir.Union) - require.True(t, ok, "the position is the Union that keeps the passed-over set") - require.Len(t, u.Variants, 1, "the null branch still lifts off the variant list") - require.Equal(t, ir.TypeID("t/anon/components/schemas/S/oneOf/0"), u.Variants[0].Type.Target, - "the union reaches the branch's own node, or the $ref is the only lowering that does") - } - assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...), - "declaring the reference before or after the union must not change the IR") - assert.Empty(t, cmp.Diff(first.Types, last.Types), "nor any name hint in the registry") -} - func TestLower_ConstraintOnlyUnionIsValidationOnly(t *testing.T) { t.Parallel() // The "exactly one of" idiom co-declares object structure with a oneOf whose @@ -1419,92 +733,6 @@ func TestUnionSiblings_UnpreservableIsReportedNotClaimed(t *testing.T) { "nothing converted, so nothing may say it was kept") } -// TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition pins that a -// discriminator beside a oneOf/anyOf that collapses to a nullable reference is -// kept verbatim, whole, by whatever owns the position, with an info diagnostic, -// instead of vanishing (GitHub #595). The reference itself is unchanged. The -// component, the property, an array's items, additionalProperties, a parameter -// and a response body each reach the collapse by their own path. -func TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, collapseSpec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, pass.Validate(doc)) - cat := componentID("Cat") - - w, ok := typeByName(doc, "W").(*ir.Scalar) - require.True(t, ok, "the component is an alias over the nullable reference") - assert.Equal(t, ir.TypeRef{Target: cat, Nullable: true}, *w.Base, "the reference is unchanged") - entry, ok := w.Unmodeled["openapi:discriminator"] - require.True(t, ok, "the component keeps its discriminator") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, string(entry.Value), "defaultMapping", "the whole object is kept, not just the property name") - assert.Contains(t, string(entry.Value), `"mapping"`) - - holder, ok := typeByName(doc, "Holder").(*ir.Model) - require.True(t, ok) - require.Len(t, holder.Properties, 1) - assert.Equal(t, ir.TypeRef{Target: cat, Nullable: true}, holder.Properties[0].Type, "the property's reference is unchanged") - assert.Contains(t, holder.Properties[0].Unmodeled, "openapi:discriminator", "the property carrier keeps it") - - for _, id := range []ir.TypeID{"t/anon/components/schemas/L/items", "t/anon/components/schemas/M/additionalProperties"} { - node, ok := doc.Types[id] - require.True(t, ok, id) - assert.Contains(t, node.Common().Unmodeled, "openapi:discriminator", "%s keeps it on the node it hoists", id) - } - - for _, pointer := range []string{ - "/components/schemas/W", "/components/schemas/Holder/properties/w", - "/components/schemas/L/items", "/components/schemas/M/additionalProperties", - "/paths/~1p/get/parameters/0/schema", "/paths/~1p/get/responses/200/content/application~1json/schema", - } { - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), - "lowered to a nullable reference to its one non-null branch, which has no home for discriminator", pointer) - } - assert.Equal(t, 6, strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), - "one entry per position, including the parameter and the response, none duplicated") -} - -// TestCollapsedUnion_KeepsEveryCensusKeywordItCannotHold pins that the collapse -// takes the whole census a $ref site takes, not a discriminator rule: a format -// or a collection bound is no more at home on a nullable reference. A null -// type, which the reference's Nullable bit carries, is, so the component's -// alias keeps nothing for it. -func TestCollapsedUnion_KeepsEveryCensusKeywordItCannotHold(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} - Bounded: {` + nullableCat + `, format: uuid, maxItems: 2} - NullTyped: {` + nullableCat + `, type: 'null'} -`) - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - - bounded, ok := typeByName(doc, "Bounded").(*ir.Scalar) - require.True(t, ok) - assert.Equal(t, []string{"openapi:format", "openapi:maxItems"}, slices.Sorted(maps.Keys(bounded.Unmodeled))) - assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, - "/components/schemas/Bounded"), "no home for format, maxItems") - - assert.Empty(t, typeByName(doc, "NullTyped").Common().Unmodeled) - assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/NullTyped")) -} - -// TestCollapsedUnion_WithoutACensusKeywordHoistsNothing pins that the same -// positions without one stay as they were: no entry, no diagnostic, and no -// node hoisted for a position that has none. -func TestCollapsedUnion_WithoutACensusKeywordHoistsNothing(t *testing.T) { - t.Parallel() - spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} - L: {type: array, items: {` + nullableCat + `}} -`) - doc, diags := parseFull(t, spec) - openapitest.RequireNoErrorDiags(t, diags) - assert.Zero(t, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "%+v", diags) - assert.NotContains(t, marshalForTest(t, doc), "openapi:discriminator") - assert.NotContains(t, doc.Types, ir.TypeID("t/anon/components/schemas/L/items"), "a bare nullable reference needs no node") -} - // TestNullOnlyUnion_ReportsItsCensusAsABareNullSchemaDoes pins that a null-only // branch set, which lowers as a bare `{type: null}` schema does, names what a // keyword it cannot hold sits beside in the same words: the node the position @@ -1539,50 +767,6 @@ func variantsNamingTheirNodes(t *testing.T, doc *ir.Document, labeled map[string return got } -// comboRefStealSpec is the co-declared union of Base with A|B, plus one $ref -// aimed at a union branch's own pointer. The two orders of the same four -// components must lower identically: the branch pointer denotes the branch -// schema in both, and the variants are Morphic's own nodes in both. -func comboRefStealSpec(branch int, outsiderFirst bool) string { - outsider := fmt.Sprintf( - " Outsider: {type: object, properties: {x: {$ref: '#/components/schemas/Combo/oneOf/%d'}}}\n", branch) - combo := ` Base: {type: object, properties: {id: {type: string}}} - A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Combo: - allOf: [{$ref: '#/components/schemas/Base'}] - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -` - if outsiderFirst { - return openapitest.ComponentSpec(outsider + combo) - } - return openapitest.ComponentSpec(combo + outsider) -} - -// comboDiscriminatedSpec is the co-declared union of a discriminated Base with -// A|B. Every variant is written on the wire with the enclosing schema's tag, -// which the base's mapping supplies, so the base and the schemas naming it are -// permutable against each other — components lower in source order. -func comboDiscriminatedSpec(baseFirst bool) string { - base := ` Base: - type: object - properties: {kind: {type: string}} - discriminator: - propertyName: kind - mapping: {combo: '#/components/schemas/Combo'} -` - rest := ` A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}} - Combo: - allOf: [{$ref: '#/components/schemas/Base'}] - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] -` - if baseFirst { - return openapitest.ComponentSpec(base + rest) - } - return openapitest.ComponentSpec(rest + base) -} - // nullCollapseSpec writes the ordinary single-combinator `{X, null}` collapse // with an outside component naming its surviving branch's pointer. The branch // declares a description on purpose: a bare `{type: string}` branch resolves to @@ -1599,50 +783,3 @@ func nullCollapseSpec(hostFirst bool) string { } return openapitest.ComponentSpec(outside + host) } - -// collapseSpec declares a nullable Cat at every position a schema can sit at -// with a discriminator beside it, plus a parameter and a response body. -const collapseSpec = `openapi: 3.2.0 -info: {title: T, version: "1"} -paths: - /p: - get: - parameters: - - name: q - in: query - schema: - ` + nullableCat + ` - discriminator: {propertyName: kind} - responses: - "200": - description: ok - content: - application/json: - schema: - ` + nullableCat + ` - discriminator: {propertyName: kind} -components: - schemas: - Cat: {type: object, properties: {kind: {type: string}}} - W: - ` + nullableCat + ` - discriminator: {propertyName: kind, defaultMapping: '#/components/schemas/Cat', mapping: {cat: '#/components/schemas/Cat'}} - Holder: - type: object - properties: - w: - anyOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}] - discriminator: {propertyName: kind} - L: - type: array - items: - ` + nullableCat + ` - discriminator: {propertyName: kind} - M: - type: object - additionalProperties: - ` + nullableCat + ` - discriminator: {propertyName: kind} -` - -const nullableCat = "oneOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}]" From 99845864bc9799630678db2a2532e1c59e142782 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 03:00:01 +0300 Subject: [PATCH 07/18] test: mark conformance helpers, and only helpers, with t.Helper A conformance case's assertion is the body TestConformance runs for it, so a failure inside it is most use reported where it happens. Ten case bodies called t.Helper, which sent every failure in them to TestConformance's loop instead: a failure planted in assertSelfURI was reported at conformance_test.go:51, and is now at the line that fails. Eleven assertions that another assertion calls did not call it, so their failures pointed inside the helper rather than at the case using it; they now do. --- compilers/openapi/e2e/conformance_docs_test.go | 2 +- compilers/openapi/e2e/conformance_pathitems_test.go | 1 - compilers/openapi/e2e/conformance_scalars_test.go | 3 +++ compilers/openapi/e2e/conformance_security_test.go | 2 -- compilers/openapi/e2e/conformance_types_test.go | 1 - compilers/openapi/e2e/conformance_unmodeled_test.go | 7 ++++++- compilers/openapi/e2e/conformance_validation_test.go | 4 ---- compilers/openapi/e2e/promotion_test.go | 1 + 8 files changed, 11 insertions(+), 10 deletions(-) diff --git a/compilers/openapi/e2e/conformance_docs_test.go b/compilers/openapi/e2e/conformance_docs_test.go index 44d65296..d7a8512c 100644 --- a/compilers/openapi/e2e/conformance_docs_test.go +++ b/compilers/openapi/e2e/conformance_docs_test.go @@ -102,7 +102,6 @@ func assertResponseExamples(t *testing.T, doc *ir.Document) { // witnesses no matrix row: the rows are what an API can express, and the URI a // description gives itself is not part of the API it describes. func assertSelfURI(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - t.Helper() require.Len(t, doc.Sources, 1) assert.Equal(t, "openapi@3.2", doc.Sources[0].Format) assert.Equal(t, "https://example.com/api/openapi.yaml", doc.Sources[0].SelfURI) @@ -240,6 +239,7 @@ func assertRefSiteDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // $ref: they override the declaration it names at every position, and a use // site's own override them in turn. func assertAliasHopDocs(t *testing.T, doc *ir.Document) { + t.Helper() sprocket, ok := opByName(doc, "makeSprocket") require.True(t, ok) limit, ok := paramByName(sprocket, "limit") diff --git a/compilers/openapi/e2e/conformance_pathitems_test.go b/compilers/openapi/e2e/conformance_pathitems_test.go index ec94cc4f..292c86de 100644 --- a/compilers/openapi/e2e/conformance_pathitems_test.go +++ b/compilers/openapi/e2e/conformance_pathitems_test.go @@ -146,7 +146,6 @@ func assertPathItemAnchoredKey(t *testing.T, doc *ir.Document, diags []ir.Diagno // included — which is why the summary is asserted on the referenced item's // operation as well as on the sibling's (GitHub #577). func assertPathItemRefSiblings(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() sibling, ok := opByName(doc, "putA") require.True(t, ok, "the operation written beside the $ref reaches the IR") assert.Equal(t, ir.OpID("op/openapi/paths/~1a/put"), sibling.ID) diff --git a/compilers/openapi/e2e/conformance_scalars_test.go b/compilers/openapi/e2e/conformance_scalars_test.go index 29d62e17..c0dabb09 100644 --- a/compilers/openapi/e2e/conformance_scalars_test.go +++ b/compilers/openapi/e2e/conformance_scalars_test.go @@ -187,6 +187,7 @@ func assertSecretFormat(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { // Property.Secret is the home, and the wrappers whose type reaches the // Sensitive node instead. func assertSecretCarriers(t *testing.T, doc *ir.Document) { + t.Helper() login := secretModel(t, doc, "Login") for _, tc := range []struct { wire string @@ -235,6 +236,7 @@ func assertSecretCarriers(t *testing.T, doc *ir.Document) { // assertSecretNodes covers the positions with no carrier: each owns a node, an // alias over the bare type where the shape alone would own none. func assertSecretNodes(t *testing.T, doc *ir.Document) { + t.Helper() arr, ok := propByWire(secretModel(t, doc, "Login"), "arr") require.True(t, ok) list, ok := doc.Types[arr.Type.Target].(*ir.List) @@ -275,6 +277,7 @@ func assertSecretNodes(t *testing.T, doc *ir.Document) { // assertSecretOperation covers the parameters, bodies and headers. func assertSecretOperation(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() op, ok := opByName(doc, "login") require.True(t, ok) diff --git a/compilers/openapi/e2e/conformance_security_test.go b/compilers/openapi/e2e/conformance_security_test.go index a95fa555..9c494a21 100644 --- a/compilers/openapi/e2e/conformance_security_test.go +++ b/compilers/openapi/e2e/conformance_security_test.go @@ -82,7 +82,6 @@ func assertSchemeDetail(t *testing.T, doc *ir.Document) { // never for a scheme whose metadata URL states its endpoints or that declares a // flow. Every entry interns either way (GitHub #646). func assertOAuth2NoFlow(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() const code = "openapi/oauth2-no-flow" reported := map[string]bool{"emptyFlows": true, "unknownFlowsOnly": true} byPointer := map[jsontext.Pointer]ir.AuthScheme{} @@ -118,7 +117,6 @@ func assertOAuth2NoFlow(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { // at its own pointer, and that scopes keep the order the document writes them // (GitHub #865, #639). func assertOAuthFlowFields(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() byName := make(map[string]ir.AuthScheme, len(doc.Auth)) for _, s := range doc.Auth { byName[s.Name.Source] = s diff --git a/compilers/openapi/e2e/conformance_types_test.go b/compilers/openapi/e2e/conformance_types_test.go index c0382b53..f35f4cce 100644 --- a/compilers/openapi/e2e/conformance_types_test.go +++ b/compilers/openapi/e2e/conformance_types_test.go @@ -484,7 +484,6 @@ func assertMaps(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // pointer and under the same hint. All of it is modeled, so no tuple keeps // anything beside itself or reports a degradation. func assertTuples(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() i64 := func(v int64) *int64 { return &v } pair := []ir.TypeRef{{Target: "t/prim/string"}, {Target: "t/prim/integer"}} restID := ir.TypeID("t/anon/components/schemas/PairWithRest/items") diff --git a/compilers/openapi/e2e/conformance_unmodeled_test.go b/compilers/openapi/e2e/conformance_unmodeled_test.go index 3605b331..91c158f6 100644 --- a/compilers/openapi/e2e/conformance_unmodeled_test.go +++ b/compilers/openapi/e2e/conformance_unmodeled_test.go @@ -100,6 +100,7 @@ func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic // has none of their fields, and each below reached no field, no Unmodeled entry // and no diagnostic. func assertRefSiteKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() for name, want := range map[string]struct{ keyword, target, raw string }{ "RefFormat": {"format", "RefTargetStr", `"email"`}, "RefEnum": {"enum", "RefTargetStr", `["a","b"]`}, @@ -191,6 +192,7 @@ func assertUnionBesideRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) // which is why the two allOf cases differ: a composed Model asserts `object` // already and loses nothing, and asserts nothing about `string` at all. func assertElectedLoweringKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() scalarType, ok := doc.Types[namedID("AllOfWithScalarType")].(*ir.Model) require.True(t, ok) assert.JSONEq(t, `"string"`, @@ -425,6 +427,7 @@ func assertAllOfNarrowingRedeclaration(t *testing.T, doc *ir.Document, diags []i // over its bare kind in either order. Each is lossless: no diagnostic names a // property of these models and nothing is kept. func assertNarrowingChainsAndScalars(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() for _, model := range []string{"ChainNarrowestFirst", "ChainNarrowestMiddle", "ChainNarrowestLast"} { p := narrowedProp(t, doc, model) assert.Equal(t, ir.TypeID("t/prim/int32"), p.Type.Target, "%s: the narrowest member wins", model) @@ -450,6 +453,7 @@ func assertNarrowingChainsAndScalars(t *testing.T, doc *ir.Document, diags []ir. // adopted from a non-first branch and refused when the integer arrives. Either // way the entry is keyed at the branch that wrote the default, with one info. func assertRefusedLateDefault(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() for _, tc := range []struct{ model, branch string }{ {"RefusedLateDefaultFirst", "2"}, {"RefusedLateDefaultMiddle", "2"}, {"RefusedLateDefaultLast", "1"}, } { @@ -482,6 +486,7 @@ func diagsUnder(diags []ir.Diagnostic, prefix string) []ir.Diagnostic { // refuses lands nowhere on the merged field: the narrow type wins in either // order, and the broad branch is kept whole at its own pointer with one info. func assertInadmissibleDefault(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() for model, broadBranch := range map[string]string{"InadmissibleDefaultBroadFirst": "0", "InadmissibleDefaultNarrowFirst": "1"} { p := narrowedProp(t, doc, model) ptr := "/components/schemas/" + model + "/allOf/" + broadBranch + "/properties/p" @@ -544,6 +549,7 @@ func assertAllOfRefBranchSiblings(t *testing.T, doc *ir.Document, diags []ir.Dia // as the other two $ref sites: refSiteUnhomedKeywords (allOf) and // preserveUnionSiblings (oneOf/anyOf), reused rather than duplicated. func assertAllOfRefBranchUnion(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() union, ok := doc.Types[namedID("UnionBranch")].(*ir.Model) require.True(t, ok) require.NotNil(t, union.Base, "the branch still composes as Base") @@ -1063,7 +1069,6 @@ func indexByParam(bindings []ir.HTTPParamBinding) map[string]ir.HTTPParamBinding // come in the parser's order. The spec writes no alias, so the order oracle // can permute it; alias keys are the readers' own tests'. func assertYAMLMergeKeys(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() op, ok := opByName(doc, "getMerged") require.True(t, ok) assertKept(t, op.Unmodeled, []keptEntry{ diff --git a/compilers/openapi/e2e/conformance_validation_test.go b/compilers/openapi/e2e/conformance_validation_test.go index 1e6fc143..236733d1 100644 --- a/compilers/openapi/e2e/conformance_validation_test.go +++ b/compilers/openapi/e2e/conformance_validation_test.go @@ -25,7 +25,6 @@ func assertNoValidationFinding(t *testing.T, diags []ir.Diagnostic) { // written: the empty server URL, the parameter the document named "", and each // security scheme with an empty name or URL (GitHub #622). func assertEmptyRequiredValues(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() assertNoValidationFinding(t, diags) require.Len(t, doc.Servers, 2) assert.Empty(t, doc.Servers[0].URLTemplate) @@ -50,7 +49,6 @@ func assertEmptyRequiredValues(t *testing.T, doc *ir.Document, diags []ir.Diagno // none, and a response declaring no description lowers undocumented, inline // and as a component alike (GitHub #622). func assertRelaxedRequiredFields(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() assertNoValidationFinding(t, diags) byName := map[string]ir.Operation{} for _, op := range allOperations(doc) { @@ -74,7 +72,6 @@ func assertRelaxedRequiredFields(t *testing.T, doc *ir.Document, diags []ir.Diag // its description and its x-*, and is warned once at each declaration, // whichever operations share it (GitHub #622). func assertEmptyRequestContent(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() const code = "openapi/empty-request-content" byName := map[string]ir.Operation{} for _, op := range allOperations(doc) { @@ -110,7 +107,6 @@ func assertEmptyRequestContent(t *testing.T, doc *ir.Document, diags []ir.Diagno // written at, and a finding about a scheme reached that way is placed where its // field is written, as for the scheme written inline (#879). func assertReachedOutsideModel(t *testing.T, _ *ir.Document, diags []ir.Diagnostic) { - t.Helper() const later = "openapi/later-version-construct" written := []string{ "/x-lib/Pet/xml/nodeType", "/x-lib/Owner/xml/nodeType", "/x-lib/Cat/xml/nodeType", "/x-responses/Listed/summary", diff --git a/compilers/openapi/e2e/promotion_test.go b/compilers/openapi/e2e/promotion_test.go index 3100aefb..5526221e 100644 --- a/compilers/openapi/e2e/promotion_test.go +++ b/compilers/openapi/e2e/promotion_test.go @@ -140,6 +140,7 @@ func assertEnumOpenness(t *testing.T, doc *ir.Document) { // was deprecated annotates nothing, and a value that is not text is a document // meaning something else by the key. func assertPromotionDeclined(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() live, ok := opByName(doc, "getY") require.True(t, ok) assert.Nil(t, live.Deprecation, "the operation is not deprecated") From bdec6ff5787dd2e9cbd057e1f11c564b061de2fb Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 03:00:58 +0300 Subject: [PATCH 08/18] docs: say how a test reaches internals, is named and shares helpers CONTRIBUTING's "Where a test goes" gains the three rules this change applies. A test is external and reaches what it must of its package's internals through export_test.go, staying inside the package only for an unexported field or method, a generic, a variable it assigns, or a package whose aliases would be a second API. A test is named for the function or type it tests, or for the construct or rule it pins, and asserting helpers call t.Helper except a conformance case's own body. A helper several files share has one home, in the package's helpers file or, across packages, in openapitest or irtest. The root guidance's testing summary points at the section. --- CLAUDE.md | 10 ++++++---- CONTRIBUTING.md | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3fdbb701..cf7da188 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,10 +301,12 @@ below are the ones most likely to bite in this codebase — the full guide gover - **Naming:** MixedCaps; scope-proportional length; no `Get` prefix on getters; initialisms in one consistent case; package names short lowercase nouns; never shadow builtins or imports. - **Testing:** table-driven and flat; `TestFunc_Scenario` names; `testify/require` for - preconditions, `assert` for values; compare with `cmp.Diff`, never `reflect.DeepEqual`; - golden files for complex expected output; `t.Helper()` in helpers; `t.Cleanup()` over - `defer`; external test packages (`package foo_test`) preferred; where goroutines exist, the - goroutine leak check under Testing strategy above (the styleguide names `goleak`; #568). + preconditions, `assert` for values; compare with `cmp.Diff`, never `reflect.DeepEqual`; golden + files for complex expected output; `t.Helper()` in helpers; `t.Cleanup()` over `defer`; external + test packages (`package foo_test`) preferred, reaching internals through the package's + `export_test.go`, and an in-package test file named `foo_internal_test.go` (CONTRIBUTING's "Where + a test goes"); where goroutines exist, the goroutine leak check under Testing strategy above (the + styleguide names `goleak`; #568). - **Packages:** one package per directory; `internal/` aggressively for implementation detail; no `utils`/`helpers`/`common`; `doc.go` for package docs; imports in three `gci` groups (stdlib, external, local); no dot imports. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50207854..9525761b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,28 @@ Read the documents for your layer, and skip the rest until you need them. as `ir/nofloat_test.go` is, and in a package of one source file a test file needs no prefix. *Fails if missed:* `make coverage`, for a test in another package than its code, since coverage counts only a package's own tests. Nothing fails for a test in the wrong file of its package. +- **A test is external, `package x_test`, unless it cannot be.** It reaches what it must of the + package's internals through the package's `export_test.go`, which names each one once, under its + exported spelling or, where that is taken, a name that says what it adds, as + `irtest.CompareGoldenOn` does. A test that needs an unexported field or method, a generic, or a + variable it assigns stays inside the package, in `x_internal_test.go` beside `x_test.go`, and so + do the tests of a package that reach so many internals the aliases would be a second API, as + `schema`'s and `operation`'s do. A fixture both sides use is declared once, exported, in + `export_test.go`, as `schema.Lowerer` is. + *Fails if missed:* nothing. A test written inside the package that could be external compiles and + runs. +- **A test is named for what it tests.** A `TestFunc_Scenario` name is a function's or a type's, and + the file follows from it as above. A test that pins a construct or a lowering rule may take the + construct's name instead, as the `TestPathItemRefSiblings` tests do, and then sits in the file of + the construct or of the entry point it runs through. A function that asserts on a test's behalf + calls `t.Helper`, except a conformance case's assertion, which is the case's own body and so + reports the line that fails. + *Fails if missed:* nothing checks a test's name or its `t.Helper`. +- **A helper several test files share has one home:** its package's `helpers_test.go`, or + `helpers_internal_test.go` inside the package; and a helper several packages share is in + `compilers/openapi/internal/openapitest` or `ir/irtest`, as `openapitest.SchemaFromYAML` and + `irtest.RepoRoot` are, when all of them can import it. + *Fails if missed:* nothing. A copy compiles beside the original. - **An end-to-end test of the OpenAPI compiler goes in `compilers/openapi/e2e`,** in a file named for the construct or the property it checks: a test of what the compiler does as a whole, which no one package decides, such as the conformance corpus, the goldens, fuzzing, benchmarks and a From 538dc18b77c9121d134cb9e85eef9785415a52c8 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 05:28:00 +0300 Subject: [PATCH 09/18] test: move out the in-package tests that need no internals Some in-package test files reach nothing that an export_test.go alias cannot carry: all of detect's, spelling's and value's, oas's code and format tests, and every nodeview and sourceindex test file except the one that reads a View's or a Measure's fields. They become external tests that reach what they need through their package's export_test.go, as the rest of the suite does. Three fixtures are used on both sides of their package's boundary, so each is declared once, exported, in export_test.go: nodeview's YAMLDoc and ComponentsDoc, and sourceindex's Decode. Each package's tests and subtests are identical before and after, compared with go test -json, and each package keeps full statement coverage. --- .../openapi/internal/detect/declares_test.go | 10 ++- .../openapi/internal/detect/export_test.go | 28 +++++++ .../openapi/internal/detect/helpers_test.go | 5 +- .../openapi/internal/detect/probe_test.go | 43 +++++----- .../internal/detect/recognize_reading_test.go | 9 ++- .../openapi/internal/detect/recognize_test.go | 15 ++-- .../openapi/internal/detect/version_test.go | 10 ++- .../openapi/internal/nodeview/export_test.go | 37 +++++++++ ...lookup_internal_test.go => lookup_test.go} | 63 ++++++++------- .../nodeview_documentpath_bench_test.go | 28 +------ .../nodeview/nodeview_internal_test.go | 16 +--- ...nternal_test.go => nodeview_model_test.go} | 14 ++-- .../{walk_internal_test.go => walk_test.go} | 51 ++++++------ .../internal/oas/code_internal_test.go | 14 ---- compilers/openapi/internal/oas/code_test.go | 16 ++++ compilers/openapi/internal/oas/export_test.go | 10 +++ .../internal/oas/format_internal_test.go | 16 ---- compilers/openapi/internal/oas/format_test.go | 18 +++++ .../internal/sourceindex/export_test.go | 20 +++++ .../sourceindex/measure_internal_test.go | 14 ++-- ...x_internal_test.go => sourceindex_test.go} | 80 +++++++++---------- .../openapi/internal/spelling/export_test.go | 7 ++ .../internal/spelling/spelling_test.go | 69 ++++++++-------- .../openapi/internal/value/export_test.go | 7 ++ ...scalar_internal_test.go => scalar_test.go} | 61 +++++++------- .../{value_internal_test.go => value_test.go} | 53 ++++++------ 26 files changed, 403 insertions(+), 311 deletions(-) create mode 100644 compilers/openapi/internal/detect/export_test.go create mode 100644 compilers/openapi/internal/nodeview/export_test.go rename compilers/openapi/internal/nodeview/{lookup_internal_test.go => lookup_test.go} (69%) rename compilers/openapi/internal/nodeview/{nodeview_model_internal_test.go => nodeview_model_test.go} (93%) rename compilers/openapi/internal/nodeview/{walk_internal_test.go => walk_test.go} (68%) delete mode 100644 compilers/openapi/internal/oas/code_internal_test.go create mode 100644 compilers/openapi/internal/oas/code_test.go create mode 100644 compilers/openapi/internal/oas/export_test.go delete mode 100644 compilers/openapi/internal/oas/format_internal_test.go create mode 100644 compilers/openapi/internal/oas/format_test.go create mode 100644 compilers/openapi/internal/sourceindex/export_test.go rename compilers/openapi/internal/sourceindex/{sourceindex_internal_test.go => sourceindex_test.go} (82%) create mode 100644 compilers/openapi/internal/spelling/export_test.go create mode 100644 compilers/openapi/internal/value/export_test.go rename compilers/openapi/internal/value/{scalar_internal_test.go => scalar_test.go} (52%) rename compilers/openapi/internal/value/{value_internal_test.go => value_test.go} (88%) diff --git a/compilers/openapi/internal/detect/declares_test.go b/compilers/openapi/internal/detect/declares_test.go index f3045629..066e6e11 100644 --- a/compilers/openapi/internal/detect/declares_test.go +++ b/compilers/openapi/internal/detect/declares_test.go @@ -1,10 +1,12 @@ -package detect +package detect_test import ( "strings" "testing" "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/compilers/openapi/internal/detect" ) // TestDeclaresProbeKey_ScopesTheNameToItsOwnDocument pins the guard that @@ -68,7 +70,7 @@ func TestDeclaresProbeKey_ScopesTheNameToItsOwnDocument(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, declaresProbeKey([]byte(tc.src))) + assert.Equal(t, tc.want, detect.DeclaresProbeKey([]byte(tc.src))) }) } } @@ -83,6 +85,6 @@ func TestDeclaresBlockKey_ReadsABoundedNumberOfLines(t *testing.T) { behind := func(n int) []byte { return []byte(strings.Repeat("openapi: is prose\n", n) + "openapi: 3.1.0\n[\n") } - assert.True(t, declaresBlockKey(behind(maxVersionLines-1), "openapi"), "the last line the bound reads") - assert.False(t, declaresBlockKey(behind(maxVersionLines), "openapi"), "one line past the bound") + assert.True(t, detect.DeclaresBlockKey(behind(detect.MaxVersionLines-1), "openapi"), "the last line the bound reads") + assert.False(t, detect.DeclaresBlockKey(behind(detect.MaxVersionLines), "openapi"), "one line past the bound") } diff --git a/compilers/openapi/internal/detect/export_test.go b/compilers/openapi/internal/detect/export_test.go new file mode 100644 index 00000000..ba01a15e --- /dev/null +++ b/compilers/openapi/internal/detect/export_test.go @@ -0,0 +1,28 @@ +package detect + +// DeclaresBlockKey is declaresBlockKey, for the external tests. +var DeclaresBlockKey = declaresBlockKey + +// DeclaresProbeKey is declaresProbeKey, for the external tests. +var DeclaresProbeKey = declaresProbeKey + +// DecodeYAML is decodeYAML, for the external tests. +var DecodeYAML = decodeYAML + +// DocumentRoot is documentRoot, for the external tests. +var DocumentRoot = documentRoot + +// MajorMinor is majorMinor, for the external tests. +var MajorMinor = majorMinor + +// MaxVersionLines is maxVersionLines, for the external tests. +const MaxVersionLines = maxVersionLines + +// ProbeFromMapping is probeFromMapping, for the external tests. +var ProbeFromMapping = probeFromMapping + +// Sniff is sniff, for the external tests. +var Sniff = sniff + +// SniffProbe is sniffProbe, for the external tests. +type SniffProbe = sniffProbe diff --git a/compilers/openapi/internal/detect/helpers_test.go b/compilers/openapi/internal/detect/helpers_test.go index 1613d76e..c4972545 100644 --- a/compilers/openapi/internal/detect/helpers_test.go +++ b/compilers/openapi/internal/detect/helpers_test.go @@ -1,9 +1,10 @@ -package detect +package detect_test import ( "strings" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/detect" "github.com/dexpace/morphic/ir" ) @@ -11,7 +12,7 @@ import ( // hands it, with no byte budget: no case here comes near the default, and // TestRecognize_HoldsTheParseToItsByteBudget sets one of its own. func recognize(src string) (compilers.Recognition, []ir.Diagnostic, bool) { - return Recognize(0, []byte(src), 0) + return detect.Recognize(0, []byte(src), 0) } // codesOf reduces diagnostics to their codes, which is the whole of what the diff --git a/compilers/openapi/internal/detect/probe_test.go b/compilers/openapi/internal/detect/probe_test.go index f81ad4e3..c6eb8ac7 100644 --- a/compilers/openapi/internal/detect/probe_test.go +++ b/compilers/openapi/internal/detect/probe_test.go @@ -1,4 +1,4 @@ -package detect +package detect_test import ( "fmt" @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/detect" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" @@ -25,33 +26,33 @@ func TestSniff_ReadsALargeDocumentWhole(t *testing.T) { pad := strings.Repeat("p", scanCutover) cases := []struct { name, src string - want sniffProbe + want detect.SniffProbe unreadable bool }{ {"block yaml declaring first", - padTo("openapi: 3.1.0\n", filler), sniffProbe{OpenAPI: "3.1.0"}, false}, + padTo("openapi: 3.1.0\n", filler), detect.SniffProbe{OpenAPI: "3.1.0"}, false}, {"block yaml declaring last", - padTo("", filler) + "openapi: 3.1.0\n", sniffProbe{OpenAPI: "3.1.0"}, false}, + padTo("", filler) + "openapi: 3.1.0\n", detect.SniffProbe{OpenAPI: "3.1.0"}, false}, {"flow json declaring first", - `{"openapi":"3.1.0","x":"` + pad + `"}`, sniffProbe{OpenAPI: "3.1.0"}, false}, + `{"openapi":"3.1.0","x":"` + pad + `"}`, detect.SniffProbe{OpenAPI: "3.1.0"}, false}, {"flow json declaring last", - `{"x":"` + pad + `","openapi":"3.1.0"}`, sniffProbe{OpenAPI: "3.1.0"}, false}, + `{"x":"` + pad + `","openapi":"3.1.0"}`, detect.SniffProbe{OpenAPI: "3.1.0"}, false}, {"flow json swagger first", - `{"swagger":"2.0","x":"` + pad + `"}`, sniffProbe{Swagger: "2.0"}, false}, + `{"swagger":"2.0","x":"` + pad + `"}`, detect.SniffProbe{Swagger: "2.0"}, false}, {"flow json swagger last", - `{"x":"` + pad + `","swagger":"2.0"}`, sniffProbe{Swagger: "2.0"}, false}, + `{"x":"` + pad + `","swagger":"2.0"}`, detect.SniffProbe{Swagger: "2.0"}, false}, // Neither YAML nor JSON: the parse fails and declares nothing. {"protobuf, large", - padTo("syntax = \"proto3\";\n", "message M { string a = 1; }\n"), sniffProbe{}, true}, + padTo("syntax = \"proto3\";\n", "message M { string a = 1; }\n"), detect.SniffProbe{}, true}, // The word is there and is not a key: a value is not a declaration. {"the word as a value, large", - `{"x":"` + pad + `","note":"openapi"}`, sniffProbe{}, false}, + `{"x":"` + pad + `","note":"openapi"}`, detect.SniffProbe{}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() require.Greater(t, len(tc.src), scanCutover, "the case must be large to test it") - probe, parsed, err := sniff([]byte(tc.src)) + probe, parsed, err := detect.Sniff([]byte(tc.src)) assert.Equal(t, tc.want, probe) if tc.unreadable { assert.Error(t, err) @@ -96,7 +97,7 @@ func TestSniff_ReadsAMergeChainAsFarAsTheViewExpandsIt(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() - probe, _, err := sniff(tc.src) + probe, _, err := detect.Sniff(tc.src) require.NoError(t, err, "a read cut short is no parse failure") assert.Equal(t, tc.cut, probe.Cut) if tc.cut { @@ -121,7 +122,7 @@ func TestDecodeYAML_RefusesARootThatIsNoMapping(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - probe, _, err := decodeYAML([]byte(tc.src)) + probe, _, err := detect.DecodeYAML([]byte(tc.src)) assert.Empty(t, probe.OpenAPI) if tc.wantErr == "" { assert.NoError(t, err, "bytes that carry no document decline rather than fail") @@ -147,8 +148,8 @@ func TestSniff_CostIsNotQuadraticInRepeatedKeys(t *testing.T) { small := []byte(head + strings.Repeat("x: y\n", dupRepeats)) large := []byte(head + strings.Repeat("x: y\n", dupRepeats*2)) - smallAllocs := testing.AllocsPerRun(2, func() { _, _, _ = sniff(small) }) - largeAllocs := testing.AllocsPerRun(2, func() { _, _, _ = sniff(large) }) + smallAllocs := testing.AllocsPerRun(2, func() { _, _, _ = detect.Sniff(small) }) + largeAllocs := testing.AllocsPerRun(2, func() { _, _, _ = detect.Sniff(large) }) require.Positive(t, smallAllocs, "a measurement of nothing bounds nothing") assert.Less(t, largeAllocs, smallAllocs*3, @@ -176,7 +177,7 @@ func TestDecodeYAML_RefusesAVersionKeyThatIsNoScalar(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - probe, _, err := decodeYAML([]byte(tc.src)) + probe, _, err := detect.DecodeYAML([]byte(tc.src)) require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr, "the complaint names the shape that was read") assert.Empty(t, probe.OpenAPI) @@ -198,7 +199,7 @@ func TestDecodeYAML_PassesOverWhatNamesNoVersion(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - probe, _, err := decodeYAML([]byte(tc.src)) + probe, _, err := detect.DecodeYAML([]byte(tc.src)) require.NoError(t, err) assert.Equal(t, "3.1.0", probe.OpenAPI) }) @@ -214,7 +215,7 @@ func TestDecodeYAML_PassesOverWhatNamesNoVersion(t *testing.T) { func TestProbeVersion_RefusesAnAliasThatResolvedToNothing(t *testing.T) { t.Parallel() root := ynode.Map(ynode.Scalar("openapi"), &yaml.Node{Kind: yaml.AliasNode}) - _, err := probeFromMapping(root) + _, err := detect.ProbeFromMapping(root) require.Error(t, err) assert.Contains(t, err.Error(), "no value") } @@ -248,12 +249,12 @@ func TestProbeFromMapping_CostIsLinearInMergeBreadth(t *testing.T) { for _, k := range []int{2000, 4000} { var root yaml.Node require.NoError(t, yaml.Unmarshal(breadthMergeDoc(k), &root)) - content := documentRoot(&root) + content := detect.DocumentRoot(&root) - var probe sniffProbe + var probe detect.SniffProbe var err error allocs := testing.AllocsPerRun(1, func() { - probe, err = probeFromMapping(content) + probe, err = detect.ProbeFromMapping(content) }) require.NoError(t, err) require.Equal(t, "3.1.0", probe.OpenAPI, diff --git a/compilers/openapi/internal/detect/recognize_reading_test.go b/compilers/openapi/internal/detect/recognize_reading_test.go index 16e07248..61921306 100644 --- a/compilers/openapi/internal/detect/recognize_reading_test.go +++ b/compilers/openapi/internal/detect/recognize_reading_test.go @@ -1,4 +1,4 @@ -package detect +package detect_test import ( "testing" @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/detect" "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/load" ) @@ -215,8 +216,8 @@ func TestRecognize_ReadsWhatADocumentDeclares(t *testing.T) { for _, tc := range declarations() { t.Run(tc.name, func(t *testing.T) { t.Parallel() - probe, _, err := sniff([]byte(tc.src)) - assert.Equal(t, sniffProbe{OpenAPI: tc.want}, probe) + probe, _, err := detect.Sniff([]byte(tc.src)) + assert.Equal(t, detect.SniffProbe{OpenAPI: tc.want}, probe) if tc.reads == parses { require.NoError(t, err, "the row reads as a document that parses") } else { @@ -225,7 +226,7 @@ func TestRecognize_ReadsWhatADocumentDeclares(t *testing.T) { rec, diags, ok := recognize(tc.src) assert.Equal(t, tc.want != "", ok, "a declared version is what recognizes the source") - assert.Equal(t, majorMinor(tc.want), rec.Format.Version) + assert.Equal(t, detect.MajorMinor(tc.want), rec.Format.Version) var wantCodes []string if tc.reads == reported { wantCodes = []string{diag.UndecodableSource} diff --git a/compilers/openapi/internal/detect/recognize_test.go b/compilers/openapi/internal/detect/recognize_test.go index 56015631..9426da42 100644 --- a/compilers/openapi/internal/detect/recognize_test.go +++ b/compilers/openapi/internal/detect/recognize_test.go @@ -1,4 +1,4 @@ -package detect +package detect_test import ( "fmt" @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/detect" "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/load" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" @@ -27,13 +28,13 @@ func TestRecognize_HoldsTheParseToItsByteBudget(t *testing.T) { refusal, over := load.OverByteBudget(ir.Provenance{Source: 0}, []byte(src), len(src)-1) require.True(t, over, "the refusal the case expects") - rec, diags, ok := Recognize(0, []byte(src), len(src)-1) + rec, diags, ok := detect.Recognize(0, []byte(src), len(src)-1) assert.False(t, ok) assert.Equal(t, compilers.Recognition{}, rec, "a source declined unread carries no parse") assert.Equal(t, []ir.Diagnostic{refusal}, diags, "the refusal Load gives the same source") for _, budget := range []int{len(src), 0} { - rec, diags, ok := Recognize(0, []byte(src), budget) + rec, diags, ok := detect.Recognize(0, []byte(src), budget) require.True(t, ok, "budget %d: %v", budget, diags) assert.Equal(t, compilers.SourceFormat{Name: "openapi", Version: "3.1"}, rec.Format, "budget %d", budget) } @@ -59,7 +60,7 @@ func TestRecognize_ADeclineNamesTheSourceItWasHanded(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() - _, diags, ok := Recognize(srcIndex, tc.src, tc.maxBytes) + _, diags, ok := detect.Recognize(srcIndex, tc.src, tc.maxBytes) assert.False(t, ok) require.Len(t, diags, 1) assert.Equal(t, tc.code, diags[0].Code) @@ -289,11 +290,11 @@ func TestRecognize_ARepeatedVersionKeyAgreesWithTheParser(t *testing.T) { parsed, _, err := soa.Unmarshal(t.Context(), strings.NewReader(src)) require.NoError(t, err) require.NotNil(t, parsed) - assert.Equal(t, tc.want, majorMinor(parsed.OpenAPI), "the spelling the parser keeps") + assert.Equal(t, tc.want, detect.MajorMinor(parsed.OpenAPI), "the spelling the parser keeps") got, _, ok := recognize(src) require.True(t, ok) - assert.Equal(t, compilers.SourceFormat{Name: "openapi", Version: majorMinor(parsed.OpenAPI)}, got.Format, + assert.Equal(t, compilers.SourceFormat{Name: "openapi", Version: detect.MajorMinor(parsed.OpenAPI)}, got.Format, "detection names the version the parser records") }) } @@ -373,7 +374,7 @@ func TestRecognize_AMergeCycleHidesNoKey(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() - probe, _, err := sniff([]byte(tc.src)) + probe, _, err := detect.Sniff([]byte(tc.src)) require.NoError(t, err) assert.False(t, probe.Cut, "a cycle reads every mapping on it") diff --git a/compilers/openapi/internal/detect/version_test.go b/compilers/openapi/internal/detect/version_test.go index 8873597b..64310049 100644 --- a/compilers/openapi/internal/detect/version_test.go +++ b/compilers/openapi/internal/detect/version_test.go @@ -1,14 +1,16 @@ -package detect +package detect_test import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/compilers/openapi/internal/detect" ) func TestMajorMinor(t *testing.T) { t.Parallel() - assert.Equal(t, "3.1", majorMinor("3.1.0")) - assert.Equal(t, "3.1", majorMinor("3.1")) - assert.Equal(t, "4", majorMinor("4")) + assert.Equal(t, "3.1", detect.MajorMinor("3.1.0")) + assert.Equal(t, "3.1", detect.MajorMinor("3.1")) + assert.Equal(t, "4", detect.MajorMinor("4")) } diff --git a/compilers/openapi/internal/nodeview/export_test.go b/compilers/openapi/internal/nodeview/export_test.go new file mode 100644 index 00000000..91cad81d --- /dev/null +++ b/compilers/openapi/internal/nodeview/export_test.go @@ -0,0 +1,37 @@ +package nodeview + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" +) + +// ComponentsDoc builds `{components: {schemas: {S0..Sn-1: {type: object}}}}`, +// the shape every internal $ref in an OpenAPI document points into. +func ComponentsDoc(n int) *yaml.Node { + schemas := &yaml.Node{Kind: yaml.MappingNode} + for i := range n { + schemas.Content = append(schemas.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("S%d", i)}, + &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "type"}, + {Kind: yaml.ScalarNode, Value: "object"}, + }}) + } + components := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "schemas"}, schemas, + }} + return &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "components"}, components, + }} +} + +// YAMLDoc parses src and returns its document root as the view sees it. +func YAMLDoc(t *testing.T, src string) *yaml.Node { + t.Helper() + var doc yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &doc)) + return DocumentRoot(&doc) +} diff --git a/compilers/openapi/internal/nodeview/lookup_internal_test.go b/compilers/openapi/internal/nodeview/lookup_test.go similarity index 69% rename from compilers/openapi/internal/nodeview/lookup_internal_test.go rename to compilers/openapi/internal/nodeview/lookup_test.go index 220eac66..45c1e4d8 100644 --- a/compilers/openapi/internal/nodeview/lookup_internal_test.go +++ b/compilers/openapi/internal/nodeview/lookup_test.go @@ -1,4 +1,4 @@ -package nodeview +package nodeview_test import ( "testing" @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) @@ -18,14 +19,14 @@ func TestChild_ReadsOnlyWhatAMappingHolds(t *testing.T) { value := ynode.Scalar("v") mapping := ynode.Map(ynode.Scalar("k"), ynode.Alias(value)) - assert.Nil(t, Child(nil, "k"), "no node holds nothing") - assert.Nil(t, Child(ynode.Scalar("k"), "k"), "a scalar holds nothing") - assert.Nil(t, Child(ynode.Seq(ynode.Scalar("k")), "0"), "a sequence holds no key") - assert.Same(t, value, Child(&yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{mapping}}, "k"), + assert.Nil(t, nodeview.Child(nil, "k"), "no node holds nothing") + assert.Nil(t, nodeview.Child(ynode.Scalar("k"), "k"), "a scalar holds nothing") + assert.Nil(t, nodeview.Child(ynode.Seq(ynode.Scalar("k")), "0"), "a sequence holds no key") + assert.Same(t, value, nodeview.Child(&yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{mapping}}, "k"), "a document node is unwrapped") - assert.Nil(t, Child(mapping, "absent")) - assert.Same(t, value, Child(mapping, "k"), "the value's alias is followed") - assert.Same(t, value, Child(ynode.Alias(mapping), "k"), "an alias standing for the mapping is followed") + assert.Nil(t, nodeview.Child(mapping, "absent")) + assert.Same(t, value, nodeview.Child(mapping, "k"), "the value's alias is followed") + assert.Same(t, value, nodeview.Child(ynode.Alias(mapping), "k"), "an alias standing for the mapping is followed") } // TestChild_AnswersAsMappingPairs holds Child to the pair MappingPairs gives @@ -52,21 +53,21 @@ func TestChild_AnswersAsMappingPairs(t *testing.T) { "own nil over merge": ynode.Map(s("b"), &yaml.Node{Kind: yaml.AliasNode}, ynode.Merge(), ynode.Alias(source)), "own nil value": ynode.Map(s("b"), nil, ynode.Merge(), ynode.Alias(source)), "a cycle": cycle, - "at the bound": ynode.MergeChain(MergeDepthLimit), - "past the bound": ynode.MergeChain(MergeDepthLimit + 1), + "at the bound": ynode.MergeChain(nodeview.MergeDepthLimit), + "past the bound": ynode.MergeChain(nodeview.MergeDepthLimit + 1), } for name, n := range cases { - pairs := New().MappingPairs(n) + pairs := nodeview.New().MappingPairs(n) asked := map[string]*yaml.Node{"absent": nil, "a": nil, "b": nil, "c": nil, "k": nil, "leaf": nil, "in": nil, "own": nil} for _, p := range pairs { asked[p.Key] = p.Val } for key, want := range asked { - assert.Same(t, want, Child(n, key), "%s: key %q", name, key) + assert.Same(t, want, nodeview.Child(n, key), "%s: key %q", name, key) } } - assert.NotNil(t, Child(cases["at the bound"], "leaf"), "a chain at the bound is read whole") - assert.Nil(t, Child(cases["past the bound"], "leaf"), "one level past it is not") + assert.NotNil(t, nodeview.Child(cases["at the bound"], "leaf"), "a chain at the bound is read whole") + assert.Nil(t, nodeview.Child(cases["past the bound"], "leaf"), "one level past it is not") } // TestPairs_IsAFreshViewsReading pins Pairs to MappingPairs read by a view of @@ -76,11 +77,11 @@ func TestPairs_IsAFreshViewsReading(t *testing.T) { t.Parallel() n := ynode.Map(ynode.Merge(), ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")), ynode.Scalar("b"), ynode.Scalar("2")) - assert.Equal(t, New().MappingPairs(n), Pairs(n)) - assert.Equal(t, []string{"a", "b"}, []string{Pairs(n)[0].Key, Pairs(n)[1].Key}) - assert.Equal(t, Pairs(n), Pairs(&yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{n}}), + assert.Equal(t, nodeview.New().MappingPairs(n), nodeview.Pairs(n)) + assert.Equal(t, []string{"a", "b"}, []string{nodeview.Pairs(n)[0].Key, nodeview.Pairs(n)[1].Key}) + assert.Equal(t, nodeview.Pairs(n), nodeview.Pairs(&yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{n}}), "a document node is unwrapped") - assert.Nil(t, Pairs(ynode.Scalar("x"))) + assert.Nil(t, nodeview.Pairs(ynode.Scalar("x"))) } // TestWrittenChildren_ReadsTheTextNotThroughIt pins the reading that answers @@ -100,22 +101,22 @@ func TestWrittenChildren_ReadsTheTextNotThroughIt(t *testing.T) { ynode.Scalar("k"), last, ) - got := WrittenChildren(n) + got := nodeview.WrittenChildren(n) assert.Equal(t, map[string]*yaml.Node{"a": alias, "k": last}, got) - assert.Nil(t, WrittenChildren(nil)) - assert.Nil(t, WrittenChildren(ynode.Seq(ynode.Scalar("a")))) - assert.Nil(t, WrittenChildren(ynode.Alias(n)), "an alias is not where the mapping is written") + assert.Nil(t, nodeview.WrittenChildren(nil)) + assert.Nil(t, nodeview.WrittenChildren(ynode.Seq(ynode.Scalar("a")))) + assert.Nil(t, nodeview.WrittenChildren(ynode.Alias(n)), "an alias is not where the mapping is written") } // TestIsMergeKey_IsTheLibrarysPredicate pins IsMergeKey to the parsed `<<` // and to nothing else: the same text untagged, an alias to it, or nil. func TestIsMergeKey_IsTheLibrarysPredicate(t *testing.T) { t.Parallel() - assert.True(t, IsMergeKey(ynode.Merge())) - assert.False(t, IsMergeKey(ynode.Scalar("<<")), "untagged, it is an ordinary key") - assert.False(t, IsMergeKey(ynode.Alias(ynode.Merge())), "an alias standing for it is an ordinary key") - assert.False(t, IsMergeKey(nil)) + assert.True(t, nodeview.IsMergeKey(ynode.Merge())) + assert.False(t, nodeview.IsMergeKey(ynode.Scalar("<<")), "untagged, it is an ordinary key") + assert.False(t, nodeview.IsMergeKey(ynode.Alias(ynode.Merge())), "an alias standing for it is an ordinary key") + assert.False(t, nodeview.IsMergeKey(nil)) } // TestRepeat_FindsAKeyWrittenTwiceWhereverTheViewReadsIt pins Repeat: a key @@ -127,19 +128,19 @@ func TestRepeat_FindsAKeyWrittenTwiceWhereverTheViewReadsIt(t *testing.T) { src := "s1: &s1 {p: 1, p: 2}\ns2: &s2 {q: 1, q: 2}\n" + "own: {b: 1, a: 1, b: 2, a: 2}\naliased: {&k k: 1, *k : 2}\n" + "merged: {<<: [*s2, *s1]}\nboth: {<<: *s1, x: 1, x: 2}\nshared: {<<: {a: 1}, a: 2}\n" - root := yamlDoc(t, src) - v := New() + root := nodeview.YAMLDoc(t, src) + v := nodeview.New() for _, tc := range []struct { name, key, in string }{ {"own", "b", "own"}, {"aliased", "k", "aliased"}, {"merged", "q", "s2"}, {"both", "x", "both"}, } { - key, in, ok := v.Repeat(Child(root, tc.name)) + key, in, ok := v.Repeat(nodeview.Child(root, tc.name)) assert.True(t, ok, tc.name) assert.Equal(t, tc.key, key, tc.name) - assert.Same(t, Child(root, tc.in), in, tc.name) + assert.Same(t, nodeview.Child(root, tc.in), in, tc.name) } - _, _, ok := v.Repeat(Child(root, "shared")) + _, _, ok := v.Repeat(nodeview.Child(root, "shared")) assert.False(t, ok, "a key a merge shares with the mapping is no repeat") _, _, ok = v.Repeat(ynode.Scalar("x")) assert.False(t, ok, "a node that is no mapping repeats nothing") diff --git a/compilers/openapi/internal/nodeview/nodeview_documentpath_bench_test.go b/compilers/openapi/internal/nodeview/nodeview_documentpath_bench_test.go index 945ecb21..da75a1d0 100644 --- a/compilers/openapi/internal/nodeview/nodeview_documentpath_bench_test.go +++ b/compilers/openapi/internal/nodeview/nodeview_documentpath_bench_test.go @@ -1,33 +1,13 @@ -package nodeview +package nodeview_test import ( "encoding/json/jsontext" "fmt" "testing" - yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" ) -// componentsDoc builds `{components: {schemas: {S0..Sn-1: {type: object}}}}`, -// the shape every internal $ref in an OpenAPI document points into. -func componentsDoc(n int) *yaml.Node { - schemas := &yaml.Node{Kind: yaml.MappingNode} - for i := range n { - schemas.Content = append(schemas.Content, - &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("S%d", i)}, - &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ - {Kind: yaml.ScalarNode, Value: "type"}, - {Kind: yaml.ScalarNode, Value: "object"}, - }}) - } - components := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ - {Kind: yaml.ScalarNode, Value: "schemas"}, schemas, - }} - return &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ - {Kind: yaml.ScalarNode, Value: "components"}, components, - }} -} - // BenchmarkDocumentPath_IntoAWideMapping resolves one pointer per component of // a components mapping, as the schema bindings do when every schema is // referenced once. @@ -42,7 +22,7 @@ func componentsDoc(n int) *yaml.Node { // scans, and a regression there means the gate has stopped paying for itself. func BenchmarkDocumentPath_IntoAWideMapping(b *testing.B) { for _, n := range []int{2, 8, 64, 256, 1024} { - root := componentsDoc(n) + root := nodeview.ComponentsDoc(n) pointers := make([]jsontext.Pointer, n) for i := range pointers { pointers[i] = jsontext.Pointer(fmt.Sprintf("/components/schemas/S%d", i)) @@ -51,7 +31,7 @@ func BenchmarkDocumentPath_IntoAWideMapping(b *testing.B) { b.Run(fmt.Sprintf("components%d", n), func(b *testing.B) { b.ReportAllocs() for b.Loop() { - v := New() // one view per pass: a view never outlives its compile + v := nodeview.New() // one view per pass: a view never outlives its compile for _, p := range pointers { if _, complete := v.DocumentPath(root, p); !complete { b.Fatalf("pointer %s must resolve", p) diff --git a/compilers/openapi/internal/nodeview/nodeview_internal_test.go b/compilers/openapi/internal/nodeview/nodeview_internal_test.go index f059336c..cbeee893 100644 --- a/compilers/openapi/internal/nodeview/nodeview_internal_test.go +++ b/compilers/openapi/internal/nodeview/nodeview_internal_test.go @@ -421,7 +421,7 @@ func TestMappingPairs_MergeKeySources(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc := yamlDoc(t, tc.src) + doc := YAMLDoc(t, tc.src) use := New().ChildByToken(doc, "use") require.NotNil(t, use, "the fixture declares a `use` mapping") @@ -462,7 +462,7 @@ func TestMappingPairs_MergeKeySpellings(t *testing.T) { t.Parallel() // m anchors a merge key for the alias case to stand in for. src := fmt.Sprintf("b: &b {k: v}\nm: {&m <<: *b}\nuse:\n %s : *b\n", tc.key) - use := New().ChildByToken(yamlDoc(t, src), "use") + use := New().ChildByToken(YAMLDoc(t, src), "use") require.NotNil(t, use, "the fixture declares a `use` mapping") got := pairMap(New().MappingPairs(use)) @@ -519,14 +519,6 @@ func TestExpanded_ReportsAMappingWhoseMergesRunPastTheBound(t *testing.T) { assert.True(t, v.Expanded(nil)) } -// yamlDoc parses src and returns its document root as the view sees it. -func yamlDoc(t *testing.T, src string) *yaml.Node { - t.Helper() - var doc yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(src), &doc)) - return DocumentRoot(&doc) -} - // TestDocumentPath_StopsAtAMissingToken pins a walk a token stops: incomplete, // its path ending at the last node reached. func TestDocumentPath_StopsAtAMissingToken(t *testing.T) { @@ -758,7 +750,7 @@ func TestKeyIndex_DeclinesWhatThePairsMemoDidNotKeep(t *testing.T) { func TestDocumentPath_ReachesTheIndexOnAWideMapping(t *testing.T) { t.Parallel() const width = minIndexedPairs - root := componentsDoc(width) + root := ComponentsDoc(width) v := New() for i := range width { @@ -893,7 +885,7 @@ func pairRead(t *testing.T, pair string) ([]string, *yaml.Node) { require.NotNil(t, parsed.Components.Responses, "the parser reads the section") v := New() - section := v.ChildByToken(v.ChildByToken(yamlDoc(t, src), "components"), "responses") + section := v.ChildByToken(v.ChildByToken(YAMLDoc(t, src), "components"), "responses") require.NotNil(t, section, "the fixture declares the section") require.Len(t, section.Content, 2, "the section holds the one pair") return slices.Collect(parsed.Components.Responses.Keys()), section.Content[0] diff --git a/compilers/openapi/internal/nodeview/nodeview_model_internal_test.go b/compilers/openapi/internal/nodeview/nodeview_model_test.go similarity index 93% rename from compilers/openapi/internal/nodeview/nodeview_model_internal_test.go rename to compilers/openapi/internal/nodeview/nodeview_model_test.go index 2a2dccc0..6baf294d 100644 --- a/compilers/openapi/internal/nodeview/nodeview_model_internal_test.go +++ b/compilers/openapi/internal/nodeview/nodeview_model_test.go @@ -1,4 +1,4 @@ -package nodeview +package nodeview_test import ( "fmt" @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" ) // TestMappingPairs_ReadsWhatTheModelReads holds MappingPairs and Child to the @@ -75,14 +77,14 @@ func assertViewReadsTheModel(t *testing.T, src string) { t.Helper() want := modelPairs(t, src) props := propertiesNode(t, src) - pairs := New().MappingPairs(props) + pairs := nodeview.New().MappingPairs(props) got := make([]string, 0, len(pairs)) for _, p := range pairs { got = append(got, p.Key+"="+titleOf(p.Val)) - assert.Same(t, p.Val, Child(props, p.Key), "Child(%q) answers as the pairs do\n%s", p.Key, src) + assert.Same(t, p.Val, nodeview.Child(props, p.Key), "Child(%q) answers as the pairs do\n%s", p.Key, src) } require.Equal(t, want, got, "the view reads the model's pairs\n%s", src) - assert.Nil(t, Child(props, "absent"), "a key nothing writes holds nothing\n%s", src) + assert.Nil(t, nodeview.Child(props, "absent"), "a key nothing writes holds nothing\n%s", src) } // propertiesSource is a document whose schema S writes props as its @@ -114,14 +116,14 @@ func modelPairs(t *testing.T, src string) []string { // propertiesNode returns the raw properties mapping of S in src. func propertiesNode(t *testing.T, src string) *yaml.Node { t.Helper() - path, complete := New().DocumentPath(yamlDoc(t, src), "/components/schemas/S/properties") + path, complete := nodeview.New().DocumentPath(nodeview.YAMLDoc(t, src), "/components/schemas/S/properties") require.True(t, complete, src) return path[len(path)-1] } // titleOf is the title a property's schema node writes, as the view reads it. func titleOf(schema *yaml.Node) string { - if title := Child(schema, "title"); title != nil { + if title := nodeview.Child(schema, "title"); title != nil { return title.Value } return "" diff --git a/compilers/openapi/internal/nodeview/walk_internal_test.go b/compilers/openapi/internal/nodeview/walk_test.go similarity index 68% rename from compilers/openapi/internal/nodeview/walk_internal_test.go rename to compilers/openapi/internal/nodeview/walk_test.go index 778d6fe9..4a6139fd 100644 --- a/compilers/openapi/internal/nodeview/walk_internal_test.go +++ b/compilers/openapi/internal/nodeview/walk_test.go @@ -1,4 +1,4 @@ -package nodeview +package nodeview_test import ( "strings" @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) @@ -15,8 +16,8 @@ import ( // leave marked with a slash. type trace []string -func (tr *trace) visit(step Step) func(n *yaml.Node, depth int) Step { - return func(n *yaml.Node, depth int) Step { +func (tr *trace) visit(step nodeview.Step) func(n *yaml.Node, depth int) nodeview.Step { + return func(n *yaml.Node, depth int) nodeview.Step { *tr = append(*tr, strings.Repeat(".", depth)+label(n)) return step } @@ -49,10 +50,10 @@ func TestWalk_VisitsInDocumentOrderAndLeavesAfterChildren(t *testing.T) { root := ynode.Map(ynode.Scalar("k"), ynode.Seq(ynode.Scalar("a"), ynode.Scalar("b"))) var tr trace - out := Walk{Nodes: 10, Visit: tr.visit(Descend), Leave: tr.leave}.Run(root) + out := nodeview.Walk{Nodes: 10, Visit: tr.visit(nodeview.Descend), Leave: tr.leave}.Run(root) assert.Equal(t, trace{"map", ".k", "./k", ".seq", "..a", "../a", "..b", "../b", "./seq", "/map"}, tr) - assert.Equal(t, Outcome{Visited: 5}, out) + assert.Equal(t, nodeview.Outcome{Visited: 5}, out) } // TestWalk_PruneAndStop pins the two answers that cut a walk short on purpose: @@ -63,20 +64,20 @@ func TestWalk_PruneAndStop(t *testing.T) { root := ynode.Seq(ynode.Seq(ynode.Scalar("hidden")), ynode.Scalar("after")) var pruned trace - out := Walk{Nodes: 10, Leave: pruned.leave, Visit: func(n *yaml.Node, depth int) Step { - pruned.visit(Descend)(n, depth) + out := nodeview.Walk{Nodes: 10, Leave: pruned.leave, Visit: func(n *yaml.Node, depth int) nodeview.Step { + pruned.visit(nodeview.Descend)(n, depth) if depth == 1 && n.Kind == yaml.SequenceNode { - return Prune + return nodeview.Prune } - return Descend + return nodeview.Descend }}.Run(root) assert.Equal(t, trace{"seq", ".seq", ".after", "./after", "/seq"}, pruned) - assert.Equal(t, Outcome{Visited: 3}, out) + assert.Equal(t, nodeview.Outcome{Visited: 3}, out) var stopped trace - out = Walk{Nodes: 10, Visit: stopped.visit(Stop)}.Run(root) + out = nodeview.Walk{Nodes: 10, Visit: stopped.visit(nodeview.Stop)}.Run(root) assert.Equal(t, trace{"seq"}, stopped) - assert.Equal(t, Outcome{Visited: 1, Stopped: true}, out) + assert.Equal(t, nodeview.Outcome{Visited: 1, Stopped: true}, out) } // TestWalk_BoundsAreReportedNotSilent pins the two bounds: a tree of exactly @@ -87,14 +88,14 @@ func TestWalk_BoundsAreReportedNotSilent(t *testing.T) { t.Parallel() root := ynode.Seq(ynode.Seq(ynode.Scalar("deep")), ynode.Scalar("shallow")) - assert.Equal(t, Outcome{Visited: 4}, Walk{Nodes: 4}.Run(root)) - assert.Equal(t, Outcome{Visited: 3, Cut: true}, Walk{Nodes: 3}.Run(root)) - assert.Equal(t, Outcome{Cut: true}, Walk{Nodes: 0}.Run(root)) + assert.Equal(t, nodeview.Outcome{Visited: 4}, nodeview.Walk{Nodes: 4}.Run(root)) + assert.Equal(t, nodeview.Outcome{Visited: 3, Cut: true}, nodeview.Walk{Nodes: 3}.Run(root)) + assert.Equal(t, nodeview.Outcome{Cut: true}, nodeview.Walk{Nodes: 0}.Run(root)) var tr trace - out := Walk{Nodes: 10, Depth: 1, Visit: tr.visit(Descend)}.Run(root) + out := nodeview.Walk{Nodes: 10, Depth: 1, Visit: tr.visit(nodeview.Descend)}.Run(root) assert.Equal(t, trace{"seq", ".seq", ".shallow"}, tr) - assert.Equal(t, Outcome{Visited: 3, Cut: true}, out) + assert.Equal(t, nodeview.Outcome{Visited: 3, Cut: true}, out) } // TestWalk_AnAliasIsALeafOrItsTarget pins the alias rule: as a leaf, an alias @@ -107,11 +108,11 @@ func TestWalk_AnAliasIsALeafOrItsTarget(t *testing.T) { root := ynode.Seq(target, ynode.Alias(target), ynode.Alias(nil)) var leaf trace - Walk{Nodes: 10, Visit: leaf.visit(Descend)}.Run(root) + nodeview.Walk{Nodes: 10, Visit: leaf.visit(nodeview.Descend)}.Run(root) assert.Equal(t, trace{"seq", ".seq", "..x", ".alias", ".alias"}, leaf) var copies trace - Walk{Nodes: 10, Aliases: AliasTarget, Visit: copies.visit(Descend)}.Run(root) + nodeview.Walk{Nodes: 10, Aliases: nodeview.AliasTarget, Visit: copies.visit(nodeview.Descend)}.Run(root) assert.Equal(t, trace{"seq", ".seq", "..x", ".seq", "..x", ".alias"}, copies) } @@ -120,8 +121,8 @@ func TestWalk_AnAliasIsALeafOrItsTarget(t *testing.T) { // everywhere, which counts the nodes. func TestWalk_SkipsWhatNoParseProduces(t *testing.T) { t.Parallel() - assert.Equal(t, Outcome{}, Walk{Nodes: 10}.Run(nil)) - assert.Equal(t, Outcome{Visited: 2}, Walk{Nodes: 10}.Run(ynode.Map(ynode.Scalar("a"), nil))) + assert.Equal(t, nodeview.Outcome{}, nodeview.Walk{Nodes: 10}.Run(nil)) + assert.Equal(t, nodeview.Outcome{Visited: 2}, nodeview.Walk{Nodes: 10}.Run(ynode.Map(ynode.Scalar("a"), nil))) } // TestWalk_DeepAndWideTreesAreIterative holds the walk to the inputs a reader @@ -136,16 +137,16 @@ func TestWalk_DeepAndWideTreesAreIterative(t *testing.T) { deep = ynode.Seq(deep) } deepest := 0 - out := Walk{Nodes: levels + 1, Visit: func(_ *yaml.Node, depth int) Step { + out := nodeview.Walk{Nodes: levels + 1, Visit: func(_ *yaml.Node, depth int) nodeview.Step { deepest = max(deepest, depth) - return Descend + return nodeview.Descend }}.Run(deep) - require.Equal(t, Outcome{Visited: levels + 1}, out) + require.Equal(t, nodeview.Outcome{Visited: levels + 1}, out) assert.Equal(t, levels, deepest) wide := ynode.Map() for range levels { wide.Content = append(wide.Content, ynode.Scalar("k"), ynode.Scalar("v")) } - assert.Equal(t, Outcome{Visited: 2*levels + 1}, Walk{Nodes: 2*levels + 1}.Run(wide)) + assert.Equal(t, nodeview.Outcome{Visited: 2*levels + 1}, nodeview.Walk{Nodes: 2*levels + 1}.Run(wide)) } diff --git a/compilers/openapi/internal/oas/code_internal_test.go b/compilers/openapi/internal/oas/code_internal_test.go deleted file mode 100644 index fa566fe8..00000000 --- a/compilers/openapi/internal/oas/code_internal_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package oas - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestTemplateFault_NamesWhatIsWrong pins the wording of the template check. -func TestTemplateFault_NamesWhatIsWrong(t *testing.T) { - assert.Empty(t, templateFault("https://{a}/{b}")) - assert.Contains(t, templateFault("{a}/{a}"), `"a"`) - assert.Contains(t, templateFault("{a"), "no }") -} diff --git a/compilers/openapi/internal/oas/code_test.go b/compilers/openapi/internal/oas/code_test.go new file mode 100644 index 00000000..409f40bd --- /dev/null +++ b/compilers/openapi/internal/oas/code_test.go @@ -0,0 +1,16 @@ +package oas_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/compilers/openapi/internal/oas" +) + +// TestTemplateFault_NamesWhatIsWrong pins the wording of the template check. +func TestTemplateFault_NamesWhatIsWrong(t *testing.T) { + assert.Empty(t, oas.TemplateFault("https://{a}/{b}")) + assert.Contains(t, oas.TemplateFault("{a}/{a}"), `"a"`) + assert.Contains(t, oas.TemplateFault("{a"), "no }") +} diff --git a/compilers/openapi/internal/oas/export_test.go b/compilers/openapi/internal/oas/export_test.go new file mode 100644 index 00000000..80230c0f --- /dev/null +++ b/compilers/openapi/internal/oas/export_test.go @@ -0,0 +1,10 @@ +package oas + +// Plural is plural, for the external tests. +var Plural = plural + +// TemplateFault is templateFault, for the external tests. +var TemplateFault = templateFault + +// ValidFormat is validFormat, for the external tests. +var ValidFormat = validFormat diff --git a/compilers/openapi/internal/oas/format_internal_test.go b/compilers/openapi/internal/oas/format_internal_test.go deleted file mode 100644 index 41768a14..00000000 --- a/compilers/openapi/internal/oas/format_internal_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package oas - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed pins the format that -// asks for nothing. -func TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed(t *testing.T) { - assert.True(t, validFormat(FormatNone, "anything at all")) - assert.False(t, validFormat(ComponentName, "a b")) - assert.Equal(t, "1 entry", plural(1)) - assert.Equal(t, "2 entries", plural(2)) -} diff --git a/compilers/openapi/internal/oas/format_test.go b/compilers/openapi/internal/oas/format_test.go new file mode 100644 index 00000000..ccb49fd9 --- /dev/null +++ b/compilers/openapi/internal/oas/format_test.go @@ -0,0 +1,18 @@ +package oas_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/compilers/openapi/internal/oas" +) + +// TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed pins the format that +// asks for nothing. +func TestValidFormat_TakesAnyStringWhereNoSyntaxIsNamed(t *testing.T) { + assert.True(t, oas.ValidFormat(oas.FormatNone, "anything at all")) + assert.False(t, oas.ValidFormat(oas.ComponentName, "a b")) + assert.Equal(t, "1 entry", oas.Plural(1)) + assert.Equal(t, "2 entries", oas.Plural(2)) +} diff --git a/compilers/openapi/internal/sourceindex/export_test.go b/compilers/openapi/internal/sourceindex/export_test.go new file mode 100644 index 00000000..a5b45870 --- /dev/null +++ b/compilers/openapi/internal/sourceindex/export_test.go @@ -0,0 +1,20 @@ +package sourceindex + +import ( + "testing" + + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" +) + +// Decode is the one place these tests parse source text, so a fixture written as +// YAML is indexed over exactly the tree a compile would index. +func Decode(t *testing.T, src string) *yaml.Node { + t.Helper() + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &root)) + return &root +} + +// MaxTrackedDepth is maxTrackedDepth, for the external tests. +const MaxTrackedDepth = maxTrackedDepth diff --git a/compilers/openapi/internal/sourceindex/measure_internal_test.go b/compilers/openapi/internal/sourceindex/measure_internal_test.go index 4d4c435a..6ba5f96f 100644 --- a/compilers/openapi/internal/sourceindex/measure_internal_test.go +++ b/compilers/openapi/internal/sourceindex/measure_internal_test.go @@ -33,7 +33,7 @@ func TestNesting_CountsTheMappingsAndSequencesOnTheDeepestPath(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, Build(decode(t, tc.src), MaxIndexedNodes).Nesting()) + assert.Equal(t, tc.want, Build(Decode(t, tc.src), MaxIndexedNodes).Nesting()) }) } } @@ -58,7 +58,7 @@ func TestNesting_AnAliasNestsAsItsTarget(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - idx := Build(decode(t, tc.src), MaxIndexedNodes) + idx := Build(Decode(t, tc.src), MaxIndexedNodes) require.Empty(t, idx.AnchorCycles()) assert.Equal(t, tc.want, idx.Nesting()) }) @@ -93,7 +93,7 @@ func TestNesting_CountsAMergeValueWhereItIsWritten(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, Build(decode(t, tc.src), MaxIndexedNodes).Nesting()) + assert.Equal(t, tc.want, Build(Decode(t, tc.src), MaxIndexedNodes).Nesting()) }) } } @@ -104,7 +104,7 @@ func TestNesting_CountsAMergeValueWhereItIsWritten(t *testing.T) { // names would fall short of the parser on both. func TestNesting_DoesNotReadAnchorNames(t *testing.T) { t.Parallel() - root := decode(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") + root := Decode(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") named := Build(root, MaxIndexedNodes).Nesting() clearAnchors(root) @@ -174,7 +174,7 @@ func TestMeasure_MeasuresEachNodeOnce(t *testing.T) { for i := 1; i < anchors; i++ { fmt.Fprintf(&b, "a%d: &a%d [*a%d, *a%d]\n", i, i, i-1, i-1) } - root := decode(t, b.String()) + root := Decode(t, b.String()) idx := Build(root, MaxIndexedNodes) m := NewMeasure(MaxIndexedNodes) @@ -218,7 +218,7 @@ func TestMeasure_StopsAtItsLimit(t *testing.T) { assert.False(t, Build(root, MaxIndexedNodes).Truncated()) assert.True(t, Build(root, 3).Truncated(), "a bound the tree as written fits and its alias does not") - whole := decode(t, "a: &x [1, 1, 1, 1, 1, 1, 1, 1]\nb: *x\n") + whole := Decode(t, "a: &x [1, 1, 1, 1, 1, 1, 1, 1]\nb: *x\n") nodes := Build(whole, MaxIndexedNodes).Nodes() assert.False(t, Build(whole, nodes).Truncated(), "a parsed tree at its node bound, its alias measuring half of it, stays within the limit") @@ -243,7 +243,7 @@ func TestMeasure_NilAndTargetlessAliasNestNothing(t *testing.T) { // one that truncates the first measure. func TestNewMeasure_SaturatesALimitPastOverflow(t *testing.T) { t.Parallel() - root := decode(t, "a: &x [1]\nb: *x\n") + root := Decode(t, "a: &x [1]\nb: *x\n") assert.False(t, Build(root, 1<<61).Truncated()) assert.Equal(t, int64(math.MaxInt64), NewMeasure(math.MaxInt64).limit) diff --git a/compilers/openapi/internal/sourceindex/sourceindex_internal_test.go b/compilers/openapi/internal/sourceindex/sourceindex_test.go similarity index 82% rename from compilers/openapi/internal/sourceindex/sourceindex_internal_test.go rename to compilers/openapi/internal/sourceindex/sourceindex_test.go index 184d5f22..7116fa23 100644 --- a/compilers/openapi/internal/sourceindex/sourceindex_internal_test.go +++ b/compilers/openapi/internal/sourceindex/sourceindex_test.go @@ -1,4 +1,4 @@ -package sourceindex +package sourceindex_test import ( "testing" @@ -7,18 +7,10 @@ import ( "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/sourceindex" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) -// decode is the one place these tests parse source text, so a fixture written as -// YAML is indexed over exactly the tree a compile would index. -func decode(t *testing.T, src string) *yaml.Node { - t.Helper() - var root yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(src), &root)) - return &root -} - func TestBuild_EmptyDocumentIndexesNothing(t *testing.T) { t.Parallel() for name, root := range map[string]*yaml.Node{ @@ -27,7 +19,7 @@ func TestBuild_EmptyDocumentIndexesNothing(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - idx := Build(root, MaxIndexedNodes) + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.Nil(t, idx.Root(), "an empty document has no content node") assert.Equal(t, int64(0), idx.Nodes()) assert.False(t, idx.Truncated(), "nothing to index is fully indexed") @@ -45,7 +37,7 @@ func TestBuild_EmptyDocumentIndexesNothing(t *testing.T) { // having failed. func TestBuild_WhitespaceOnlySourceIsOneEmptyNode(t *testing.T) { t.Parallel() - idx := Build(decode(t, "\n\n\n"), MaxIndexedNodes) + idx := sourceindex.Build(sourceindex.Decode(t, "\n\n\n"), sourceindex.MaxIndexedNodes) require.NotNil(t, idx.Root()) assert.Equal(t, yaml.Kind(0), idx.Root().Kind, "yaml.v3 leaves the node untouched") @@ -59,9 +51,9 @@ func TestBuild_RootIsTheDocumentsContent(t *testing.T) { content := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{content}} - assert.Same(t, content, Build(doc, MaxIndexedNodes).Root(), + assert.Same(t, content, sourceindex.Build(doc, sourceindex.MaxIndexedNodes).Root(), "a document node is unwrapped to the node every consumer scans from") - assert.Same(t, content, Build(content, MaxIndexedNodes).Root(), + assert.Same(t, content, sourceindex.Build(content, sourceindex.MaxIndexedNodes).Root(), "a content node passed directly is already the root") } @@ -72,7 +64,7 @@ func TestBuild_CountsEveryNodeOnce(t *testing.T) { ynode.Scalar("a"), ynode.Scalar("1"), ynode.Scalar("b"), ynode.Seq(ynode.Scalar("x"), ynode.Scalar("y")), ) - assert.Equal(t, int64(7), Build(root, MaxIndexedNodes).Nodes()) + assert.Equal(t, int64(7), sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nodes()) } // TestBuild_AnAliasCountsOnceAndIsNotFollowed pins the count to the document's @@ -87,7 +79,7 @@ func TestBuild_AnAliasCountsOnceAndIsNotFollowed(t *testing.T) { ynode.Scalar("reuse"), ynode.Alias(base), ) // root, "base", base's 3, "reuse", the alias = 7. - assert.Equal(t, int64(7), Build(root, MaxIndexedNodes).Nodes(), + assert.Equal(t, int64(7), sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nodes(), "the alias contributes one node, not a copy of its target") } @@ -96,7 +88,7 @@ func TestBuild_NilChildIsSkipped(t *testing.T) { root := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) root.Content = append(root.Content, nil) - idx := Build(root, MaxIndexedNodes) + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.Equal(t, int64(3), idx.Nodes(), "the nil child contributes nothing and costs no dereference") assert.False(t, idx.Truncated()) @@ -104,7 +96,7 @@ func TestBuild_NilChildIsSkipped(t *testing.T) { func TestBuild_NonPositiveBoundIndexesNothing(t *testing.T) { t.Parallel() - idx := Build(ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")), 0) + idx := sourceindex.Build(ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")), 0) assert.True(t, idx.Truncated(), "a bound that admits no node admits no answer either") assert.Equal(t, int64(0), idx.Nodes()) assert.NotNil(t, idx.Root(), "the tree is still named, so a caller can say what it refused") @@ -117,9 +109,9 @@ func TestBuild_StopsAtItsNodeBound(t *testing.T) { t.Parallel() root := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1"), ynode.Scalar("b"), ynode.Scalar("2")) // 5 nodes - assert.False(t, Build(root, 5).Truncated(), "a tree exactly at the bound is fully indexed") + assert.False(t, sourceindex.Build(root, 5).Truncated(), "a tree exactly at the bound is fully indexed") - stopped := Build(root, 4) + stopped := sourceindex.Build(root, 4) assert.True(t, stopped.Truncated(), "one node past the bound stops the walk") assert.Empty(t, stopped.AnchorCycles(), "a truncated walk reports no finding it did not reach") } @@ -132,10 +124,10 @@ func TestBuild_TruncationDoesNotMisreportAnAnchorCycle(t *testing.T) { root := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) root.Content = append(root.Content, ynode.Scalar("b"), ynode.Alias(root)) - full := Build(root, MaxIndexedNodes) + full := sourceindex.Build(root, sourceindex.MaxIndexedNodes) require.NotEmpty(t, full.AnchorCycles(), "the fixture really does carry a recursive anchor") - assert.True(t, Build(root, 2).Truncated(), + assert.True(t, sourceindex.Build(root, 2).Truncated(), "the same tree under a bound it crosses reports truncation rather than cleanliness") } @@ -146,7 +138,7 @@ func TestAnchorCycle_AliasToAnAncestorIsFound(t *testing.T) { alias := ynode.Alias(root) inner.Content = append(inner.Content, ynode.Scalar("loop"), alias) - got := Build(root, MaxIndexedNodes).AnchorCycles() + got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).AnchorCycles() require.Len(t, got, 1, "an alias naming a node it is nested inside expands without bound") assert.Same(t, alias, got[0], "the reported node is the alias, where the author wrote it") } @@ -156,16 +148,16 @@ func TestAnchorCycle_AliasToAnAncestorIsFound(t *testing.T) { // use anchors at all. func TestAnchorCycle_LegalReuseIsNotACycle(t *testing.T) { t.Parallel() - root := decode(t, "a: &x {p: 1}\nb: *x\n") + root := sourceindex.Decode(t, "a: &x {p: 1}\nb: *x\n") - assert.Empty(t, Build(root, MaxIndexedNodes).AnchorCycles(), "an alias to a node that is not an ancestor is legal reuse") + assert.Empty(t, sourceindex.Build(root, sourceindex.MaxIndexedNodes).AnchorCycles(), "an alias to a node that is not an ancestor is legal reuse") } func TestAnchorCycle_AliasWithoutATargetIsNotACycle(t *testing.T) { t.Parallel() root := ynode.Map(ynode.Scalar("k"), &yaml.Node{Kind: yaml.AliasNode}) - idx := Build(root, MaxIndexedNodes) + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.Empty(t, idx.AnchorCycles(), "an alias that names nothing cannot name an ancestor") assert.Equal(t, int64(3), idx.Nodes(), "it is still one of the document's nodes") } @@ -183,7 +175,7 @@ func TestAnchorCycles_EveryOneIsListedInDocumentOrder(t *testing.T) { first.Content = append(first.Content, ynode.Scalar("loop"), firstAlias) second.Content = append(second.Content, ynode.Scalar("loop"), secondAlias) - got := Build(root, MaxIndexedNodes).AnchorCycles() + got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).AnchorCycles() require.Len(t, got, 2) assert.Same(t, firstAlias, got[0]) assert.Same(t, secondAlias, got[1]) @@ -200,7 +192,7 @@ func TestBuild_TracksAncestorsOnlyToItsDepthBound(t *testing.T) { // value two levels below its parent's. root := ynode.Map() deepest := root - const links = maxTrackedDepth + const links = sourceindex.MaxTrackedDepth for range links { next := ynode.Map() deepest.Content = append(deepest.Content, ynode.Scalar("k"), next) @@ -208,7 +200,7 @@ func TestBuild_TracksAncestorsOnlyToItsDepthBound(t *testing.T) { } deepest.Content = append(deepest.Content, ynode.Scalar("loop"), ynode.Alias(root)) - idx := Build(root, MaxIndexedNodes) + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.False(t, idx.Truncated()) // root, then two nodes per link (its key and the mapping it names), then the // deepest mapping's own key and alias. @@ -228,7 +220,7 @@ func TestBuild_TracksAncestorsAtItsDepthBound(t *testing.T) { t.Parallel() root := ynode.Map() deepest := root - const links = maxTrackedDepth - 1 + const links = sourceindex.MaxTrackedDepth - 1 for range links { next := ynode.Map() deepest.Content = append(deepest.Content, ynode.Scalar("k"), next) @@ -237,7 +229,7 @@ func TestBuild_TracksAncestorsAtItsDepthBound(t *testing.T) { // One level shallower than the test above, so these land at the bound itself. deepest.Content = append(deepest.Content, ynode.Scalar("loop"), ynode.Alias(root)) - idx := Build(root, MaxIndexedNodes) + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.False(t, idx.Truncated()) cycles := idx.AnchorCycles() require.Len(t, cycles, 1, "an alias at exactly the tracked depth is still a candidate") @@ -249,9 +241,9 @@ func TestBuild_TracksAncestorsAtItsDepthBound(t *testing.T) { // downstream can vary with when or how often the index was built. func TestBuild_IsAFunctionOfTheTreeAlone(t *testing.T) { t.Parallel() - root := decode(t, "a: &x {p: 1}\nb: [*x, {c: 2}]\n") + root := sourceindex.Decode(t, "a: &x {p: 1}\nb: [*x, {c: 2}]\n") - first, second := Build(root, MaxIndexedNodes), Build(root, MaxIndexedNodes) + first, second := sourceindex.Build(root, sourceindex.MaxIndexedNodes), sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.Same(t, first.Root(), second.Root()) assert.Equal(t, first.Nodes(), second.Nodes()) assert.Equal(t, first.Truncated(), second.Truncated()) @@ -266,11 +258,11 @@ func TestBuild_IsAFunctionOfTheTreeAlone(t *testing.T) { // node reported is the mapping itself, where the author wrote the tag. func TestTaggedMapping_LocalTagIsFound(t *testing.T) { t.Parallel() - root := decode(t, "a:\n b: !content:\n c: 1\n") + root := sourceindex.Decode(t, "a:\n b: !content:\n c: 1\n") want := root.Content[0].Content[1].Content[1] require.Equal(t, "!content:", want.Tag, "the fixture's tag lands where this test reads it") - got := Build(root, MaxIndexedNodes).TaggedMappings() + got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).TaggedMappings() require.Len(t, got, 1) assert.Same(t, want, got[0]) } @@ -285,14 +277,14 @@ func TestTaggedMapping_LocalTagIsFound(t *testing.T) { func TestTaggedMapping_AnyTagButMapIsOne(t *testing.T) { t.Parallel() for name, root := range map[string]*yaml.Node{ - "!!str": decode(t, "a: !!str {b: 1}\n"), - "!!set": decode(t, "a: !!set {b: 1}\n"), - "at the root": decode(t, "--- !x\na: 1\n"), + "!!str": sourceindex.Decode(t, "a: !!str {b: 1}\n"), + "!!set": sourceindex.Decode(t, "a: !!set {b: 1}\n"), + "at the root": sourceindex.Decode(t, "--- !x\na: 1\n"), "assembled": ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")), } { t.Run(name, func(t *testing.T) { t.Parallel() - assert.NotEmpty(t, Build(root, MaxIndexedNodes).TaggedMappings()) + assert.NotEmpty(t, sourceindex.Build(root, sourceindex.MaxIndexedNodes).TaggedMappings()) }) } } @@ -315,7 +307,7 @@ func TestTaggedMapping_OnlyAMappingCounts(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - assert.Empty(t, Build(decode(t, src), MaxIndexedNodes).TaggedMappings()) + assert.Empty(t, sourceindex.Build(sourceindex.Decode(t, src), sourceindex.MaxIndexedNodes).TaggedMappings()) }) } } @@ -325,10 +317,10 @@ func TestTaggedMapping_OnlyAMappingCounts(t *testing.T) { // chooses which a refusal names. func TestTaggedMappings_EveryOneIsListedInDocumentOrder(t *testing.T) { t.Parallel() - root := decode(t, "a: !x {b: 1}\nc: !y {d: 2}\n") + root := sourceindex.Decode(t, "a: !x {b: 1}\nc: !y {d: 2}\n") first, second := root.Content[0].Content[1], root.Content[0].Content[3] - got := Build(root, MaxIndexedNodes).TaggedMappings() + got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).TaggedMappings() require.Len(t, got, 2) assert.Same(t, first, got[0]) assert.Same(t, second, got[1]) @@ -339,10 +331,10 @@ func TestTaggedMappings_EveryOneIsListedInDocumentOrder(t *testing.T) { // where it is declared, and that is the node reported. func TestTaggedMapping_IsFoundThroughAnAnchor(t *testing.T) { t.Parallel() - root := decode(t, "a: &x !t {b: 1}\nc: *x\n") + root := sourceindex.Decode(t, "a: &x !t {b: 1}\nc: *x\n") anchored := root.Content[0].Content[1] - got := Build(root, MaxIndexedNodes).TaggedMappings() + got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).TaggedMappings() require.Len(t, got, 1, "the alias is not a second mapping") assert.Same(t, anchored, got[0]) } diff --git a/compilers/openapi/internal/spelling/export_test.go b/compilers/openapi/internal/spelling/export_test.go new file mode 100644 index 00000000..c6a75eb7 --- /dev/null +++ b/compilers/openapi/internal/spelling/export_test.go @@ -0,0 +1,7 @@ +package spelling + +// FormatTable is formatTable, for the external tests. +var FormatTable = formatTable + +// Narrows is narrows, for the external tests. +var Narrows = narrows diff --git a/compilers/openapi/internal/spelling/spelling_test.go b/compilers/openapi/internal/spelling/spelling_test.go index 5a8950d2..0fd8ad04 100644 --- a/compilers/openapi/internal/spelling/spelling_test.go +++ b/compilers/openapi/internal/spelling/spelling_test.go @@ -1,4 +1,4 @@ -package spelling +package spelling_test import ( "strings" @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/dexpace/morphic/compilers/openapi/internal/spelling" "github.com/dexpace/morphic/internal/decls" "github.com/dexpace/morphic/ir" ) @@ -46,7 +47,7 @@ func declaredKinds(t *testing.T) []ir.PrimKind { } func spelled(k ir.PrimKind) bool { - for _, prim := range formatTable { + for _, prim := range spelling.FormatTable { if prim == k { return true } @@ -63,12 +64,12 @@ func spelled(k ir.PrimKind) bool { // node as byte and an unknown format do, or be read elsewhere, as password is. func TestFormatTable_EveryRowDistinguishesTheBareType(t *testing.T) { t.Parallel() - for key, prim := range formatTable { + for key, prim := range spelling.FormatTable { typ, _, paired := strings.Cut(key, "/") if !paired { continue // the bare type's own row, which no format selects } - bare, ok := formatTable[typ] + bare, ok := spelling.FormatTable[typ] require.True(t, ok, "row %q names a type with no row of its own", key) assert.NotEqual(t, bare, prim, "row %q maps to the same kind (%s) as the bare type %q, so the pairing "+ @@ -84,14 +85,14 @@ func TestFormatTable_EveryRowDistinguishesTheBareType(t *testing.T) { // numeric row to the value domain alone. func TestFormatTable_EveryRowNarrowsItsBareType(t *testing.T) { t.Parallel() - for key, prim := range formatTable { + for key, prim := range spelling.FormatTable { typ, _, paired := strings.Cut(key, "/") if !paired { continue } - bare, ok := formatTable[typ] + bare, ok := spelling.FormatTable[typ] require.True(t, ok, "row %q names a type with no row of its own", key) - assert.True(t, narrows(prim, bare), + assert.True(t, spelling.Narrows(prim, bare), "row %q lowers to %s, which does not narrow the bare %q kind %s", key, prim, typ, bare) if typ != "string" { assert.True(t, prim.StrictlyWithin(bare), @@ -126,7 +127,7 @@ func TestRelation_EveryDeclaredKindIsRelatedOrARoot(t *testing.T) { for _, k := range kinds { related := false for _, other := range kinds { - related = related || (other != ir.PrimAny && narrows(k, other)) + related = related || (other != ir.PrimAny && spelling.Narrows(k, other)) } _, root := relationRoots[k] assert.Equal(t, !root, related, "%s must narrow a kind or be a listed root, not both", k) @@ -180,8 +181,8 @@ func TestNarrows_HoldsEveryContainment(t *testing.T) { {ir.PrimInteger, ir.PrimFloat}, {ir.PrimUUID, ir.PrimAny}, {ir.PrimInteger, ir.PrimAny}, } for _, p := range holds { - assert.True(t, narrows(p[0], p[1]), "%s narrows %s", p[0], p[1]) - assert.False(t, narrows(p[1], p[0]), "%s does not narrow %s", p[1], p[0]) + assert.True(t, spelling.Narrows(p[0], p[1]), "%s narrows %s", p[0], p[1]) + assert.False(t, spelling.Narrows(p[1], p[0]), "%s does not narrow %s", p[1], p[0]) } unrelated := [][2]ir.PrimKind{ @@ -190,12 +191,12 @@ func TestNarrows_HoldsEveryContainment(t *testing.T) { {ir.PrimBytes, ir.PrimInteger}, {ir.PrimDatetime, ir.PrimString}, } for _, p := range unrelated { - assert.False(t, narrows(p[0], p[1]), "%s does not narrow %s", p[0], p[1]) - assert.False(t, narrows(p[1], p[0]), "%s does not narrow %s", p[1], p[0]) + assert.False(t, spelling.Narrows(p[0], p[1]), "%s does not narrow %s", p[0], p[1]) + assert.False(t, spelling.Narrows(p[1], p[0]), "%s does not narrow %s", p[1], p[0]) } for _, k := range declaredKinds(t) { - assert.False(t, narrows(k, k), "%s is not strictly narrower than itself", k) + assert.False(t, spelling.Narrows(k, k), "%s is not strictly narrower than itself", k) } } @@ -214,9 +215,9 @@ func TestNarrows_OmitsRoundingContainments(t *testing.T) { {ir.PrimInteger, ir.PrimFloat32}, {ir.PrimInteger, ir.PrimFloat64}, } for _, p := range rounding { - assert.False(t, narrows(p[0], p[1]), "%s is not narrower than %s", p[0], p[1]) - assert.Equal(t, Overlap, Relate(p[0], p[1]), "%s against %s", p[0], p[1]) - assert.Equal(t, Overlap, Relate(p[1], p[0]), "%s against %s", p[1], p[0]) + assert.False(t, spelling.Narrows(p[0], p[1]), "%s is not narrower than %s", p[0], p[1]) + assert.Equal(t, spelling.Overlap, spelling.Relate(p[0], p[1]), "%s against %s", p[0], p[1]) + assert.Equal(t, spelling.Overlap, spelling.Relate(p[1], p[0]), "%s against %s", p[1], p[0]) } } @@ -226,25 +227,25 @@ func TestRelate_ClassifiesEveryVerdict(t *testing.T) { tests := []struct { name string a, b ir.PrimKind - want Relation - back Relation + want spelling.Relation + back spelling.Relation }{ - {"same", ir.PrimString, ir.PrimString, Same, Same}, - {"domain narrowing", ir.PrimInt32, ir.PrimInteger, ANarrower, BNarrower}, - {"spelling narrowing", ir.PrimUUID, ir.PrimString, ANarrower, BNarrower}, - {"numeric overlap", ir.PrimInt32, ir.PrimUint32, Overlap, Overlap}, - {"number kinds overlap", ir.PrimDecimal128, ir.PrimFloat32, Overlap, Overlap}, - {"bytes against uuid", ir.PrimBytes, ir.PrimUUID, Overlap, Overlap}, - {"two string formats", ir.PrimUUID, ir.PrimDatetimeOffset, Disjoint, Disjoint}, - {"string against number", ir.PrimString, ir.PrimInteger, Disjoint, Disjoint}, - {"bool against string", ir.PrimBool, ir.PrimString, Disjoint, Disjoint}, - {"datetime against a format", ir.PrimDatetime, ir.PrimDate, Disjoint, Disjoint}, - {"any is the top", ir.PrimUUID, ir.PrimAny, ANarrower, BNarrower}, - {"any against itself", ir.PrimAny, ir.PrimAny, Same, Same}, + {"same", ir.PrimString, ir.PrimString, spelling.Same, spelling.Same}, + {"domain narrowing", ir.PrimInt32, ir.PrimInteger, spelling.ANarrower, spelling.BNarrower}, + {"spelling narrowing", ir.PrimUUID, ir.PrimString, spelling.ANarrower, spelling.BNarrower}, + {"numeric overlap", ir.PrimInt32, ir.PrimUint32, spelling.Overlap, spelling.Overlap}, + {"number kinds overlap", ir.PrimDecimal128, ir.PrimFloat32, spelling.Overlap, spelling.Overlap}, + {"bytes against uuid", ir.PrimBytes, ir.PrimUUID, spelling.Overlap, spelling.Overlap}, + {"two string formats", ir.PrimUUID, ir.PrimDatetimeOffset, spelling.Disjoint, spelling.Disjoint}, + {"string against number", ir.PrimString, ir.PrimInteger, spelling.Disjoint, spelling.Disjoint}, + {"bool against string", ir.PrimBool, ir.PrimString, spelling.Disjoint, spelling.Disjoint}, + {"datetime against a format", ir.PrimDatetime, ir.PrimDate, spelling.Disjoint, spelling.Disjoint}, + {"any is the top", ir.PrimUUID, ir.PrimAny, spelling.ANarrower, spelling.BNarrower}, + {"any against itself", ir.PrimAny, ir.PrimAny, spelling.Same, spelling.Same}, } for _, tc := range tests { - assert.Equal(t, tc.want, Relate(tc.a, tc.b), "%s: %s against %s", tc.name, tc.a, tc.b) - assert.Equal(t, tc.back, Relate(tc.b, tc.a), "%s: %s against %s", tc.name, tc.b, tc.a) + assert.Equal(t, tc.want, spelling.Relate(tc.a, tc.b), "%s: %s against %s", tc.name, tc.a, tc.b) + assert.Equal(t, tc.back, spelling.Relate(tc.b, tc.a), "%s: %s against %s", tc.name, tc.b, tc.a) } } @@ -252,11 +253,11 @@ func TestRelate_ClassifiesEveryVerdict(t *testing.T) { // row and an unknown one reports it. func TestLookup_ReadsTheTable(t *testing.T) { t.Parallel() - prim, ok := Lookup("integer/int32") + prim, ok := spelling.Lookup("integer/int32") assert.True(t, ok) assert.Equal(t, ir.PrimInt32, prim) - _, ok = Lookup("string/password") + _, ok = spelling.Lookup("string/password") assert.False(t, ok) } diff --git a/compilers/openapi/internal/value/export_test.go b/compilers/openapi/internal/value/export_test.go new file mode 100644 index 00000000..b1c945f2 --- /dev/null +++ b/compilers/openapi/internal/value/export_test.go @@ -0,0 +1,7 @@ +package value + +// ErrMergedPastBound is errMergedPastBound, for the external tests. +var ErrMergedPastBound = errMergedPastBound + +// MaxValueDepth is maxValueDepth, for the external tests. +const MaxValueDepth = maxValueDepth diff --git a/compilers/openapi/internal/value/scalar_internal_test.go b/compilers/openapi/internal/value/scalar_test.go similarity index 52% rename from compilers/openapi/internal/value/scalar_internal_test.go rename to compilers/openapi/internal/value/scalar_test.go index 47198946..094aa650 100644 --- a/compilers/openapi/internal/value/scalar_internal_test.go +++ b/compilers/openapi/internal/value/scalar_test.go @@ -1,4 +1,4 @@ -package value +package value_test import ( "testing" @@ -8,6 +8,7 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/value" "github.com/dexpace/morphic/ir" ) @@ -18,39 +19,39 @@ import ( // same rows through both renderers. var classifyRows = []struct { name, src string - kind ScalarKind + kind value.ScalarKind num ir.BigVal text string refused bool }{ - {name: "plain null", src: "null", kind: ScalarNull, text: "null"}, - {name: "tilde null", src: "~", kind: ScalarNull, text: "~"}, - {name: "empty null", src: "!!null ''", kind: ScalarNull}, + {name: "plain null", src: "null", kind: value.ScalarNull, text: "null"}, + {name: "tilde null", src: "~", kind: value.ScalarNull, text: "~"}, + {name: "empty null", src: "!!null ''", kind: value.ScalarNull}, {name: "null tag over text", src: "!!null abc", refused: true}, - {name: "plain true", src: "true", kind: ScalarBool, text: "true"}, + {name: "plain true", src: "true", kind: value.ScalarBool, text: "true"}, {name: "bool tag over text", src: "!!bool maybe", refused: true}, - {name: "plain int", src: "42", kind: ScalarNumber, num: "42", text: "42"}, - {name: "octal int", src: "0644", kind: ScalarNumber, num: "420", text: "0644"}, - {name: "hex int", src: "0x1f", kind: ScalarNumber, num: "31", text: "0x1f"}, - {name: "separated int", src: "1_000", kind: ScalarNumber, num: "1000", text: "1_000"}, - {name: "plain float", src: "1.5", kind: ScalarNumber, num: "1.5", text: "1.5"}, - {name: "float past float64", src: "1.8e308", kind: ScalarNumber, num: "1.8e308", text: "1.8e308"}, - {name: "negative past float64", src: "-1.8e308", kind: ScalarNumber, num: "-1.8e308", text: "-1.8e308"}, - {name: "exponent past float64", src: "1e400", kind: ScalarNumber, num: "1e400", text: "1e400"}, + {name: "plain int", src: "42", kind: value.ScalarNumber, num: "42", text: "42"}, + {name: "octal int", src: "0644", kind: value.ScalarNumber, num: "420", text: "0644"}, + {name: "hex int", src: "0x1f", kind: value.ScalarNumber, num: "31", text: "0x1f"}, + {name: "separated int", src: "1_000", kind: value.ScalarNumber, num: "1000", text: "1_000"}, + {name: "plain float", src: "1.5", kind: value.ScalarNumber, num: "1.5", text: "1.5"}, + {name: "float past float64", src: "1.8e308", kind: value.ScalarNumber, num: "1.8e308", text: "1.8e308"}, + {name: "negative past float64", src: "-1.8e308", kind: value.ScalarNumber, num: "-1.8e308", text: "-1.8e308"}, + {name: "exponent past float64", src: "1e400", kind: value.ScalarNumber, num: "1e400", text: "1e400"}, {name: "infinity", src: ".inf", refused: true}, {name: "not a number", src: ".nan", refused: true}, {name: "int tag over text", src: "!!int 1.5", refused: true}, {name: "float tag over text", src: "!!float abc", refused: true}, - {name: "plain string", src: "hello", kind: ScalarString, text: "hello"}, - {name: "quoted number", src: `"1.8e308"`, kind: ScalarString, text: "1.8e308"}, - {name: "single-quoted number", src: "'42'", kind: ScalarString, text: "42"}, - {name: "string tag over a number", src: "!!str 42", kind: ScalarString, text: "42"}, - {name: "merge text as a value", src: "<<", kind: ScalarString, text: "<<"}, - {name: "plain timestamp", src: "2021-1-1", kind: ScalarTimestamp, text: "2021-1-1"}, + {name: "plain string", src: "hello", kind: value.ScalarString, text: "hello"}, + {name: "quoted number", src: `"1.8e308"`, kind: value.ScalarString, text: "1.8e308"}, + {name: "single-quoted number", src: "'42'", kind: value.ScalarString, text: "42"}, + {name: "string tag over a number", src: "!!str 42", kind: value.ScalarString, text: "42"}, + {name: "merge text as a value", src: "<<", kind: value.ScalarString, text: "<<"}, + {name: "plain timestamp", src: "2021-1-1", kind: value.ScalarTimestamp, text: "2021-1-1"}, {name: "timestamp tag over text", src: "!!timestamp notadate", refused: true}, - {name: "binary", src: "!!binary aGVsbG8=", kind: ScalarBinary, text: "aGVsbG8="}, + {name: "binary", src: "!!binary aGVsbG8=", kind: value.ScalarBinary, text: "aGVsbG8="}, {name: "binary tag over text", src: "!!binary '###'", refused: true}, - {name: "a document's own tag", src: "!acme thing", kind: ScalarTagged, text: "thing"}, + {name: "a document's own tag", src: "!acme thing", kind: value.ScalarTagged, text: "thing"}, } // TestClassify_ReadsEachSpellingOnce holds Classify to classifyRows: the kind @@ -61,7 +62,7 @@ func TestClassify_ReadsEachSpellingOnce(t *testing.T) { for _, row := range classifyRows { t.Run(row.name, func(t *testing.T) { t.Parallel() - got, err := Classify(openapitest.YAMLNode(t, row.src)) + got, err := value.Classify(openapitest.YAMLNode(t, row.src)) if row.refused { require.Error(t, err) return @@ -78,20 +79,20 @@ func TestClassify_ReadsEachSpellingOnce(t *testing.T) { // carry, and the refusal of a node that is no scalar. func TestClassify_ReadsTheValueOfItsKind(t *testing.T) { t.Parallel() - b, err := Classify(openapitest.YAMLNode(t, "false")) + b, err := value.Classify(openapitest.YAMLNode(t, "false")) require.NoError(t, err) assert.False(t, b.Bool) - b, err = Classify(openapitest.YAMLNode(t, "true")) + b, err = value.Classify(openapitest.YAMLNode(t, "true")) require.NoError(t, err) assert.True(t, b.Bool) - bin, err := Classify(openapitest.YAMLNode(t, "!!binary aGVsbG8=")) + bin, err := value.Classify(openapitest.YAMLNode(t, "!!binary aGVsbG8=")) require.NoError(t, err) assert.Equal(t, []byte("hello"), bin.Bytes) - _, err = Classify(openapitest.YAMLNode(t, "[a]")) + _, err = value.Classify(openapitest.YAMLNode(t, "[a]")) require.Error(t, err) - _, err = Classify(nil) + _, err = value.Classify(nil) require.Error(t, err) } @@ -99,8 +100,8 @@ func TestClassify_ReadsTheValueOfItsKind(t *testing.T) { // hand carries no tag, and is read as a parse would have tagged its text. func TestClassify_ResolvesAnUntaggedNodeByItsText(t *testing.T) { t.Parallel() - got, err := Classify(&yaml.Node{Kind: yaml.ScalarNode, Value: "7"}) + got, err := value.Classify(&yaml.Node{Kind: yaml.ScalarNode, Value: "7"}) require.NoError(t, err) - assert.Equal(t, ScalarNumber, got.Kind) + assert.Equal(t, value.ScalarNumber, got.Kind) assert.Equal(t, ir.BigVal("7"), got.Num) } diff --git a/compilers/openapi/internal/value/value_internal_test.go b/compilers/openapi/internal/value/value_test.go similarity index 88% rename from compilers/openapi/internal/value/value_internal_test.go rename to compilers/openapi/internal/value/value_test.go index 48941f51..40c96854 100644 --- a/compilers/openapi/internal/value/value_internal_test.go +++ b/compilers/openapi/internal/value/value_test.go @@ -1,4 +1,4 @@ -package value +package value_test import ( "testing" @@ -10,6 +10,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/value" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" "github.com/dexpace/morphic/ir" ) @@ -29,7 +30,7 @@ func TestValueFromNode_Scalars(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := FromNode(openapitest.YAMLNode(t, tc.src)) + got, err := value.FromNode(openapitest.YAMLNode(t, tc.src)) require.NoError(t, err) if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) @@ -67,7 +68,7 @@ func TestNumericLiteral_YAMLBases(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := NumericLiteral(openapitest.YAMLNode(t, tc.src)) + got, err := value.NumericLiteral(openapitest.YAMLNode(t, tc.src)) require.NoError(t, err) assert.Equal(t, tc.want, got) }) @@ -92,7 +93,7 @@ func TestNumericLiteral_ExplicitIntBeyondUint64(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := NumericLiteral(openapitest.YAMLNode(t, tc.src)) + got, err := value.NumericLiteral(openapitest.YAMLNode(t, tc.src)) require.NoError(t, err) assert.Equal(t, tc.want, got) }) @@ -102,7 +103,7 @@ func TestNumericLiteral_ExplicitIntBeyondUint64(t *testing.T) { // TestNumericLiteral_NilNode covers the nil guard. func TestNumericLiteral_NilNode(t *testing.T) { t.Parallel() - _, err := NumericLiteral(nil) + _, err := value.NumericLiteral(nil) require.Error(t, err) } @@ -114,7 +115,7 @@ func TestNumericLiteral_UndecodableInt(t *testing.T) { for _, src := range []string{"12abc", "077777777777777777777777", "0x1FFFFFFFFFFFFFFFFFFFF", ""} { t.Run(src, func(t *testing.T) { t.Parallel() - _, err := NumericLiteral(openapitest.ScalarNode("!!int", src)) + _, err := value.NumericLiteral(openapitest.ScalarNode("!!int", src)) require.Error(t, err) }) } @@ -122,7 +123,7 @@ func TestNumericLiteral_UndecodableInt(t *testing.T) { func TestValueFromNode_ObjectPreservesOrder(t *testing.T) { t.Parallel() - got, err := FromNode(openapitest.YAMLNode(t, "b: 1\na: 2\n")) + got, err := value.FromNode(openapitest.YAMLNode(t, "b: 1\na: 2\n")) require.NoError(t, err) require.Equal(t, ir.ValueObject, got.Kind) require.Len(t, got.Object, 2) @@ -132,7 +133,7 @@ func TestValueFromNode_ObjectPreservesOrder(t *testing.T) { func TestValueFromNode_NilYieldsNull(t *testing.T) { t.Parallel() - got, err := FromNode(nil) + got, err := value.FromNode(nil) require.NoError(t, err) assert.Equal(t, ir.ValueNull, got.Kind) } @@ -141,7 +142,7 @@ func TestValueFromNode_AliasFollowed(t *testing.T) { t.Parallel() target := openapitest.ScalarNode("!!str", "hi") alias := &yaml.Node{Kind: yaml.AliasNode, Alias: target} - got, err := FromNode(alias) + got, err := value.FromNode(alias) require.NoError(t, err) assert.Equal(t, ir.ValueString, got.Kind) assert.Equal(t, "hi", got.Str) @@ -152,7 +153,7 @@ func TestValueFromNode_Sequence(t *testing.T) { seq := &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{ openapitest.ScalarNode("!!int", "1"), openapitest.ScalarNode("!!str", "x"), }} - got, err := FromNode(seq) + got, err := value.FromNode(seq) require.NoError(t, err) require.Equal(t, ir.ValueList, got.Kind) require.Len(t, got.List, 2) @@ -162,7 +163,7 @@ func TestValueFromNode_Sequence(t *testing.T) { func TestValueFromNode_Binary(t *testing.T) { t.Parallel() - got, err := FromNode(openapitest.YAMLNode(t, "!!binary aGVsbG8=")) + got, err := value.FromNode(openapitest.YAMLNode(t, "!!binary aGVsbG8=")) require.NoError(t, err) require.Equal(t, ir.ValueBytes, got.Kind) assert.Equal(t, []byte("hello"), got.Bytes) @@ -179,7 +180,7 @@ func TestValueFromNode_Timestamp(t *testing.T) { t.Parallel() node := openapitest.YAMLNode(t, src) require.Equal(t, "!!timestamp", node.Tag, "precondition: this spelling must resolve to !!timestamp") - got, err := FromNode(node) + got, err := value.FromNode(node) require.NoError(t, err) assert.Equal(t, ir.ValueString, got.Kind) assert.Equal(t, src, got.Str, "verbatim spelling survives, not a canonicalized or parsed form") @@ -202,7 +203,7 @@ func TestValueFromNode_ScalarErrors(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, err := FromNode(tc.node) + _, err := value.FromNode(tc.node) require.Error(t, err) }) } @@ -212,7 +213,7 @@ func TestValueFromNode_OverflowNumberIsNumber(t *testing.T) { t.Parallel() // A float64-overflow literal resolves to a plain !!str node; it must be // captured as the number it is, canonicalized, not as a string. - got, err := FromNode(openapitest.ScalarNode("!!str", "1.8e308")) + got, err := value.FromNode(openapitest.ScalarNode("!!str", "1.8e308")) require.NoError(t, err) assert.Equal(t, ir.ValueNumber, got.Kind) assert.Equal(t, ir.BigVal("1.8e308"), got.Num) @@ -223,7 +224,7 @@ func TestValueFromNode_QuotedNumericStaysString(t *testing.T) { // A quoted numeric string is not plain, so it stays a string. node := openapitest.ScalarNode("!!str", "123") node.Style = yaml.DoubleQuotedStyle - got, err := FromNode(node) + got, err := value.FromNode(node) require.NoError(t, err) assert.Equal(t, ir.ValueString, got.Kind) assert.Equal(t, "123", got.Str) @@ -231,7 +232,7 @@ func TestValueFromNode_QuotedNumericStaysString(t *testing.T) { func TestValueFromNode_UnsupportedNodeKind(t *testing.T) { t.Parallel() - _, err := FromNode(&yaml.Node{Kind: yaml.Kind(99)}) + _, err := value.FromNode(&yaml.Node{Kind: yaml.Kind(99)}) require.Error(t, err) } @@ -240,7 +241,7 @@ func TestValueFromNode_SequenceChildError(t *testing.T) { seq := &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{ openapitest.ScalarNode("!custom", "x"), }} - _, err := FromNode(seq) + _, err := value.FromNode(seq) require.Error(t, err) } @@ -249,17 +250,17 @@ func TestValueFromNode_MappingValueError(t *testing.T) { m := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ openapitest.ScalarNode("!!str", "k"), openapitest.ScalarNode("!custom", "x"), }} - _, err := FromNode(m) + _, err := value.FromNode(m) require.Error(t, err) } func TestValueFromNode_DepthCapExceeded(t *testing.T) { t.Parallel() n := openapitest.ScalarNode("!!int", "1") - for range maxValueDepth + 2 { + for range value.MaxValueDepth + 2 { n = &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{n}} } - _, err := FromNode(n) + _, err := value.FromNode(n) require.Error(t, err) } @@ -287,7 +288,7 @@ func TestNumericLiteral_NonNumericTagReadsAsDecimal(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := NumericLiteral(openapitest.ScalarNode(tc.tag, tc.val)) + got, err := value.NumericLiteral(openapitest.ScalarNode(tc.tag, tc.val)) require.NoError(t, err) assert.Equal(t, tc.want, got) }) @@ -312,7 +313,7 @@ func TestValueFromNode_ReadsAMappingAsTheParserDoes(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := FromNode(nodeview.Child(openapitest.YAMLNode(t, tc.src), "v")) + got, err := value.FromNode(nodeview.Child(openapitest.YAMLNode(t, tc.src), "v")) require.NoError(t, err) require.Equal(t, ir.ValueObject, got.Kind) var names []string @@ -335,11 +336,11 @@ func TestValueFromNode_RefusesWhatNoObjectHolds(t *testing.T) { "a key repeated as an alias": "{&k rate: 1, *k : 2}", "a key a merge writes twice": "{<<: {a: 1, a: 2}}", } { - _, err := FromNode(openapitest.YAMLNode(t, src)) + _, err := value.FromNode(openapitest.YAMLNode(t, src)) assert.ErrorContains(t, err, "written more than once", name) } - _, err := FromNode(ynode.MergeChain(nodeview.MergeDepthLimit + 1)) - assert.ErrorIs(t, err, errMergedPastBound) - _, err = FromNode(ynode.MergeChain(nodeview.MergeDepthLimit)) + _, err := value.FromNode(ynode.MergeChain(nodeview.MergeDepthLimit + 1)) + assert.ErrorIs(t, err, value.ErrMergedPastBound) + _, err = value.FromNode(ynode.MergeChain(nodeview.MergeDepthLimit)) assert.NoError(t, err, "a chain at the bound is read whole") } From cd5f002215ff25a19cb53993c410667585b8f43f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 05:32:36 +0300 Subject: [PATCH 10/18] test: file the remaining tests beside the code their names test Thirty-seven tests sat in the test file of one source file while their names test a declaration of another: value's TestNumericLiteral tests, for one, sat in value_test.go though NumericLiteral is declared in scalar.go. Each moves to the test file of the file that declares its subject: - load: the Open test to external, the Beside and Writes tests to siblings; - value: the NumericLiteral tests to scalar; - nodeview: the IsMergeKey and Repeat tests to a new nodeview_test.go; - oas: the Kinds test to types_table; - lowering: the New test to lowering; - irtest: the Fill test to fill; - census: the Report tests to a new report_test.go; - sourceindex: the Nesting tests, with the two helpers only they use, to sourceindex_test.go, outside the package as that file is; - merge: the PropIndex, WireNameIndex and MergeProperty tests to merge, and the Merger tests to fold; - ir: the TypeCommon, TypeDef and template tests to typedef, and the marker test to a new typedef_internal_test.go, since it calls the unexported marker. e2e's assertOnlyErrorDiags, which helpers_test.go also calls, moves there, and irtest's TestMain comment names golden_test.go, which declares runCompare. Every moved declaration is byte-identical, comment included, except the Nesting tests' qualified references and one comment that no longer calls the round-trip tests "above". Each package's tests and subtests are unchanged, compared with go test -json. --- compilers/openapi/e2e/conformance_test.go | 17 -- compilers/openapi/e2e/helpers_test.go | 18 +++ .../internal/load/beside_internal_test.go | 92 ----------- .../internal/load/external_internal_test.go | 29 ++++ .../internal/load/siblings_internal_test.go | 91 +++++++++++ .../internal/load/source_internal_test.go | 31 ---- .../openapi/internal/lowering/limits_test.go | 14 -- .../internal/lowering/lowering_test.go | 11 ++ .../merge/fold_cases_internal_test.go | 38 ----- .../internal/merge/fold_internal_test.go | 95 +++++------ .../internal/merge/merge_internal_test.go | 113 ++++++++++--- .../openapi/internal/nodeview/lookup_test.go | 37 ----- .../internal/nodeview/nodeview_test.go | 47 ++++++ compilers/openapi/internal/oas/minor_test.go | 12 -- .../openapi/internal/oas/types_table_test.go | 11 ++ .../sourceindex/measure_internal_test.go | 149 ----------------- .../internal/sourceindex/sourceindex_test.go | 150 ++++++++++++++++++ .../openapi/internal/value/scalar_test.go | 113 +++++++++++++ .../openapi/internal/value/value_test.go | 113 ------------- internal/census/census_test.go | 60 ------- internal/census/report_test.go | 72 +++++++++ ir/irtest/fill_test.go | 22 +++ ir/irtest/golden_test.go | 22 --- ir/irtest/main_test.go | 6 +- ir/json_internal_test.go | 29 ---- ir/typedef_internal_test.go | 32 ++++ ir/typedef_test.go | 134 ++++++++++++++++ ir/types_test.go | 134 ---------------- 28 files changed, 857 insertions(+), 835 deletions(-) create mode 100644 compilers/openapi/internal/nodeview/nodeview_test.go create mode 100644 internal/census/report_test.go create mode 100644 ir/typedef_internal_test.go diff --git a/compilers/openapi/e2e/conformance_test.go b/compilers/openapi/e2e/conformance_test.go index e07a0bc6..ae10a2a3 100644 --- a/compilers/openapi/e2e/conformance_test.go +++ b/compilers/openapi/e2e/conformance_test.go @@ -306,20 +306,3 @@ func invalidCases() map[string][]string { "codeclared-schema-content": {"openapi/validation/validation-mutually-exclusive-fields"}, } } - -// assertOnlyErrorDiags fails when a diagnostic has error severity and a code -// outside want, or when a code of want draws no error. -func assertOnlyErrorDiags(t *testing.T, diags []ir.Diagnostic, want []string) { - t.Helper() - drawn := map[string]bool{} - for _, d := range diags { - if d.Severity != ir.SeverityError { - continue - } - drawn[d.Code] = true - assert.Contains(t, want, d.Code, "unexpected error diagnostic: %+v", d) - } - for _, code := range want { - assert.True(t, drawn[code], "the spec is listed as invalid but draws no %s", code) - } -} diff --git a/compilers/openapi/e2e/helpers_test.go b/compilers/openapi/e2e/helpers_test.go index 90deb2d7..804d5359 100644 --- a/compilers/openapi/e2e/helpers_test.go +++ b/compilers/openapi/e2e/helpers_test.go @@ -11,6 +11,7 @@ import ( "sync" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" @@ -423,3 +424,20 @@ func compileClean(t *testing.T, src string, opts openapi.Options) *ir.Document { assertNoErrorDiags(t, diags) return doc } + +// assertOnlyErrorDiags fails when a diagnostic has error severity and a code +// outside want, or when a code of want draws no error. +func assertOnlyErrorDiags(t *testing.T, diags []ir.Diagnostic, want []string) { + t.Helper() + drawn := map[string]bool{} + for _, d := range diags { + if d.Severity != ir.SeverityError { + continue + } + drawn[d.Code] = true + assert.Contains(t, want, d.Code, "unexpected error diagnostic: %+v", d) + } + for _, code := range want { + assert.True(t, drawn[code], "the spec is listed as invalid but draws no %s", code) + } +} diff --git a/compilers/openapi/internal/load/beside_internal_test.go b/compilers/openapi/internal/load/beside_internal_test.go index 3c67e2d5..e0e6446d 100644 --- a/compilers/openapi/internal/load/beside_internal_test.go +++ b/compilers/openapi/internal/load/beside_internal_test.go @@ -2,7 +2,6 @@ package load import ( "encoding/json/jsontext" - "strings" "testing" soa "github.com/speakeasy-api/openapi/openapi" @@ -17,47 +16,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// TestSiblings_BesideNamesTheFieldAPointerPasses pins which pointers pass -// through the fields beside a path item's $ref: one naming a key written there, -// at any position the walk met the use site at, an alias's included, and at a -// key a `<<` merge key supplies there. The first use site on the pointer is the -// one named. A key nothing writes beside the $ref, the use site itself, and a -// $ref only a merge key supplies, which the library reads as an inline item, -// are not, and an empty registry passes none. -func TestSiblings_BesideNamesTheFieldAPointerPasses(t *testing.T) { - t.Parallel() - doc := modelOf(t, siblingSpec+` /e: - $ref: '#/paths/~1b' - <<: {patch: {responses: {"200": {description: ok}}}} -`) - sibs, faults := modelSiblings(t.Context(), doc, scan.InSource(0)) - require.Empty(t, faults) - - cases := []struct { - pointer jsontext.Pointer - use, key string - }{ - {"/paths/~1a/put/responses", "/paths/~1a", "put"}, - {"/paths/~1a/summary", "/paths/~1a", "summary"}, - {"/paths/~1d/put", "/paths/~1d", "put"}, - {"/paths/~1e/patch", "/paths/~1e", "patch"}, - {"/paths/~1a/put/callbacks/cb/{$request.body#~1url}/delete", "/paths/~1a", "put"}, - {"/paths/~1a/get", "", ""}, - {"/paths/~1a", "", ""}, - {"/paths/~1c/put", "", ""}, - {"/paths/~1m/post", "", ""}, - {"", "", ""}, - } - for _, tc := range cases { - use, key, ok := sibs.Beside(tc.pointer) - assert.Equal(t, tc.use != "", ok, "%s", tc.pointer) - assert.Equal(t, jsontext.Pointer(tc.use), use, "%s", tc.pointer) - assert.Equal(t, tc.key, key, "%s", tc.pointer) - } - _, _, ok := Siblings{}.Beside("/paths/~1a/put") - assert.False(t, ok, "an empty registry holds no use site") -} - // besideChainSpec reaches /a's get request body, written beside its $ref, // through a component, and its schema through a schema component that another // names. @@ -144,38 +102,6 @@ func TestLoad_TakesAHopThatFindsTheFieldWrittenThere(t *testing.T) { "the document's own objects are never asked about, so they are not recorded") } -// TestSiblings_WritesReadsTheNodeAnAnswerIsWrittenAs pins Writes on the answers -// a copy gives: one the resolver builds from the node the fields write, as for -// a pointer through an alias's position, is that field, though it is not the -// object their model holds. An answer with no node of its own is none. -func TestSiblings_WritesReadsTheNodeAnAnswerIsWrittenAs(t *testing.T) { - t.Parallel() - spec := strings.Replace(besideChainSpec, " /a:\n", " /a: &a\n", 1) - spec = strings.Replace(spec, "components:\n", ` /z: *a - /d: - post: - requestBody: {$ref: 'spec.yaml#/paths/~1z/get/requestBody'} - responses: {"200": {description: ok}} -components: -`, 1) - got, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(spec)}, - Options{Reader: reader.Disk{}}) - require.NoError(t, err) - for _, d := range diags { - assert.NotEqual(t, jsontext.Pointer("/paths/~1d/post/requestBody"), d.Provenance.Pointer, "%s", d.Message) - } - - d := pathEntry(t, got.Doc, "/d").GetObject().Post().GetRequestBody() - copied := d.GetReferenceResolutionInfo().Object - a, ok := got.Siblings.Of(pathEntry(t, got.Doc, "/a")) - require.True(t, ok) - require.NotSame(t, a.Item.Get().GetRequestBody(), copied, "the resolver built a copy") - assert.True(t, got.Siblings.Writes("/paths/~1z/get/requestBody", copied)) - - got.Siblings.write("/paths/~1a/get/requestBody", bareReference{}) - assert.False(t, got.Siblings.Writes("/paths/~1a/get/requestBody", bareReference{})) -} - // TestLoad_HoldsADefsPointerBesideARefUnresolved pins a "#/$defs/..." pointer // written in the fields beside a $ref: its definition is in those fields too, // at a pointer the resolver reads through the $ref, so it is held unresolved @@ -239,24 +165,6 @@ func TestBesideRefs_AWalkThatPanicsIsReported(t *testing.T) { assert.Contains(t, diags[0].Message, "path item $ref check panicked: walk fault") } -// TestSiblings_BesideReadsAPointerInBoundedWork pins Beside's cost, which every -// hop of every reference pays in this phase and in the lowering: it reads a -// pointer only as deep as some use site's position goes, so a long pointer costs -// what a short one does. Building each prefix of the pointer instead made it -// quadratic in the pointer's length. Allocations count the work, since they do -// not depend on the machine. -func TestSiblings_BesideReadsAPointerInBoundedWork(t *testing.T) { - sibs, _ := modelSiblings(t.Context(), modelOf(t, siblingSpec), scan.InSource(0)) - long := func(head string, tokens int) jsontext.Pointer { - return jsontext.Pointer(head + strings.Repeat("/x", tokens)) - } - for _, head := range []string{"/paths/~1a/put", "/paths/~1a/get", "/paths/~1c", "/components"} { - short := testing.AllocsPerRun(20, func() { sibs.Beside(long(head, 1_000)) }) - longer := testing.AllocsPerRun(20, func() { sibs.Beside(long(head, 100_000)) }) - assert.Equal(t, short, longer, "%s: a hundred times the tokens, the same work", head) - } -} - // bareReference is a $ref model with no core to read, and coredReference one // whose core holds no mapping: shapes no library model takes, which ownRoot // must still answer for rather than fault on. diff --git a/compilers/openapi/internal/load/external_internal_test.go b/compilers/openapi/internal/load/external_internal_test.go index 982b44bc..269edc02 100644 --- a/compilers/openapi/internal/load/external_internal_test.go +++ b/compilers/openapi/internal/load/external_internal_test.go @@ -532,3 +532,32 @@ func TestExternal_OpenReadsTheDocumentThePathNames(t *testing.T) { }) } } + +// TestExternal_OpenHoldsTheSourceItIsAskedFor pins what Open does with the +// source spelled as no key holds it: it holds the source under that spelling, +// so the next $ref spelling it so finds the source's own objects, and serves +// the bytes held for it, which the resolver reads none of, its tree being held. +func TestExternal_OpenHoldsTheSourceItIsAskedFor(t *testing.T) { + t.Parallel() + dir := t.TempDir() + doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) + spelled := dir + "/./root.yaml" + + f, err := pass.reader.Open(spelled) + + require.NoError(t, err, "no root.yaml is on disk, and none is read") + data, err := io.ReadAll(f) + require.NoError(t, err) + assert.Empty(t, data) + _, err = f.Stat() + require.ErrorIs(t, err, fs.ErrInvalid) + require.NoError(t, f.Close()) + tree, ok := doc.GetCachedExternalDocument(spelled) + require.True(t, ok) + assert.Same(t, pass.reader.read.self.root, tree) + held, ok := doc.GetCachedReferencedObject(spelled + "#/components/responses/R") + require.True(t, ok) + r, ok := doc.Components.Responses.Get("R") + require.True(t, ok) + assert.Same(t, r, held, "the source's own object, a response being no reference") +} diff --git a/compilers/openapi/internal/load/siblings_internal_test.go b/compilers/openapi/internal/load/siblings_internal_test.go index 1d9dc723..f0d99e30 100644 --- a/compilers/openapi/internal/load/siblings_internal_test.go +++ b/compilers/openapi/internal/load/siblings_internal_test.go @@ -466,3 +466,94 @@ func TestModelSiblings_DoesNotWalkADocumentWithNothingBesideARef(t *testing.T) { allocs := testing.AllocsPerRun(10, func() { modelSiblings(t.Context(), doc, scan.InSource(0)) }) assert.Less(t, allocs, 50.0, "a scan, not a walk of 200 path items") } + +// TestSiblings_BesideNamesTheFieldAPointerPasses pins which pointers pass +// through the fields beside a path item's $ref: one naming a key written there, +// at any position the walk met the use site at, an alias's included, and at a +// key a `<<` merge key supplies there. The first use site on the pointer is the +// one named. A key nothing writes beside the $ref, the use site itself, and a +// $ref only a merge key supplies, which the library reads as an inline item, +// are not, and an empty registry passes none. +func TestSiblings_BesideNamesTheFieldAPointerPasses(t *testing.T) { + t.Parallel() + doc := modelOf(t, siblingSpec+` /e: + $ref: '#/paths/~1b' + <<: {patch: {responses: {"200": {description: ok}}}} +`) + sibs, faults := modelSiblings(t.Context(), doc, scan.InSource(0)) + require.Empty(t, faults) + + cases := []struct { + pointer jsontext.Pointer + use, key string + }{ + {"/paths/~1a/put/responses", "/paths/~1a", "put"}, + {"/paths/~1a/summary", "/paths/~1a", "summary"}, + {"/paths/~1d/put", "/paths/~1d", "put"}, + {"/paths/~1e/patch", "/paths/~1e", "patch"}, + {"/paths/~1a/put/callbacks/cb/{$request.body#~1url}/delete", "/paths/~1a", "put"}, + {"/paths/~1a/get", "", ""}, + {"/paths/~1a", "", ""}, + {"/paths/~1c/put", "", ""}, + {"/paths/~1m/post", "", ""}, + {"", "", ""}, + } + for _, tc := range cases { + use, key, ok := sibs.Beside(tc.pointer) + assert.Equal(t, tc.use != "", ok, "%s", tc.pointer) + assert.Equal(t, jsontext.Pointer(tc.use), use, "%s", tc.pointer) + assert.Equal(t, tc.key, key, "%s", tc.pointer) + } + _, _, ok := Siblings{}.Beside("/paths/~1a/put") + assert.False(t, ok, "an empty registry holds no use site") +} + +// TestSiblings_WritesReadsTheNodeAnAnswerIsWrittenAs pins Writes on the answers +// a copy gives: one the resolver builds from the node the fields write, as for +// a pointer through an alias's position, is that field, though it is not the +// object their model holds. An answer with no node of its own is none. +func TestSiblings_WritesReadsTheNodeAnAnswerIsWrittenAs(t *testing.T) { + t.Parallel() + spec := strings.Replace(besideChainSpec, " /a:\n", " /a: &a\n", 1) + spec = strings.Replace(spec, "components:\n", ` /z: *a + /d: + post: + requestBody: {$ref: 'spec.yaml#/paths/~1z/get/requestBody'} + responses: {"200": {description: ok}} +components: +`, 1) + got, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(spec)}, + Options{Reader: reader.Disk{}}) + require.NoError(t, err) + for _, d := range diags { + assert.NotEqual(t, jsontext.Pointer("/paths/~1d/post/requestBody"), d.Provenance.Pointer, "%s", d.Message) + } + + d := pathEntry(t, got.Doc, "/d").GetObject().Post().GetRequestBody() + copied := d.GetReferenceResolutionInfo().Object + a, ok := got.Siblings.Of(pathEntry(t, got.Doc, "/a")) + require.True(t, ok) + require.NotSame(t, a.Item.Get().GetRequestBody(), copied, "the resolver built a copy") + assert.True(t, got.Siblings.Writes("/paths/~1z/get/requestBody", copied)) + + got.Siblings.write("/paths/~1a/get/requestBody", bareReference{}) + assert.False(t, got.Siblings.Writes("/paths/~1a/get/requestBody", bareReference{})) +} + +// TestSiblings_BesideReadsAPointerInBoundedWork pins Beside's cost, which every +// hop of every reference pays in this phase and in the lowering: it reads a +// pointer only as deep as some use site's position goes, so a long pointer costs +// what a short one does. Building each prefix of the pointer instead made it +// quadratic in the pointer's length. Allocations count the work, since they do +// not depend on the machine. +func TestSiblings_BesideReadsAPointerInBoundedWork(t *testing.T) { + sibs, _ := modelSiblings(t.Context(), modelOf(t, siblingSpec), scan.InSource(0)) + long := func(head string, tokens int) jsontext.Pointer { + return jsontext.Pointer(head + strings.Repeat("/x", tokens)) + } + for _, head := range []string{"/paths/~1a/put", "/paths/~1a/get", "/paths/~1c", "/components"} { + short := testing.AllocsPerRun(20, func() { sibs.Beside(long(head, 1_000)) }) + longer := testing.AllocsPerRun(20, func() { sibs.Beside(long(head, 100_000)) }) + assert.Equal(t, short, longer, "%s: a hundred times the tokens, the same work", head) + } +} diff --git a/compilers/openapi/internal/load/source_internal_test.go b/compilers/openapi/internal/load/source_internal_test.go index 7f1e41ef..e9db0387 100644 --- a/compilers/openapi/internal/load/source_internal_test.go +++ b/compilers/openapi/internal/load/source_internal_test.go @@ -2,8 +2,6 @@ package load import ( "encoding/json/jsontext" - "io" - "io/fs" "path/filepath" "testing" @@ -103,35 +101,6 @@ func heldResolution(t *testing.T, spec, path string) (*soa.OpenAPI, *resolution) return doc, newResolution(t.Context(), doc, Siblings{}, self, opts, &ext) } -// TestExternal_OpenHoldsTheSourceItIsAskedFor pins what Open does with the -// source spelled as no key holds it: it holds the source under that spelling, -// so the next $ref spelling it so finds the source's own objects, and serves -// the bytes held for it, which the resolver reads none of, its tree being held. -func TestExternal_OpenHoldsTheSourceItIsAskedFor(t *testing.T) { - t.Parallel() - dir := t.TempDir() - doc, pass := heldResolution(t, backRoot(backPath("/a", "#/components/responses/R")), filepath.Join(dir, "root.yaml")) - spelled := dir + "/./root.yaml" - - f, err := pass.reader.Open(spelled) - - require.NoError(t, err, "no root.yaml is on disk, and none is read") - data, err := io.ReadAll(f) - require.NoError(t, err) - assert.Empty(t, data) - _, err = f.Stat() - require.ErrorIs(t, err, fs.ErrInvalid) - require.NoError(t, f.Close()) - tree, ok := doc.GetCachedExternalDocument(spelled) - require.True(t, ok) - assert.Same(t, pass.reader.read.self.root, tree) - held, ok := doc.GetCachedReferencedObject(spelled + "#/components/responses/R") - require.True(t, ok) - r, ok := doc.Components.Responses.Get("R") - require.True(t, ok) - assert.Same(t, r, held, "the source's own object, a response being no reference") -} - // TestSourceTree_LocatorsAnswerAtTheLastNodeTheyReach pins that a pointer // leading nowhere is placed at the last node it reached, and that a tree // holding no node places every finding at the reference that reached it. diff --git a/compilers/openapi/internal/lowering/limits_test.go b/compilers/openapi/internal/lowering/limits_test.go index 41c6c757..3fa70f85 100644 --- a/compilers/openapi/internal/lowering/limits_test.go +++ b/compilers/openapi/internal/lowering/limits_test.go @@ -6,9 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/compilers/openapi/internal/lowering" - "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/compilers/openapi/internal/overlay" - "github.com/dexpace/morphic/ir" ) func TestLimitsEnumMembersExceeded_TreatsAnUnsetBudgetAsUnbounded(t *testing.T) { @@ -31,14 +28,3 @@ func TestLimitsEnumMembersExceeded_TreatsAnUnsetBudgetAsUnbounded(t *testing.T) }) } } - -// TestNew_CarriesTheLimits pins that the budgets reach the walk through the -// context, beside the other policy the caller chose. -func TestNew_CarriesTheLimits(t *testing.T) { - t.Parallel() - limits := lowering.Limits{MaxEnumMembers: 12} - - c := lowering.New(0, openapitest.DocDeclaring(), ir.SourceInfo{}, lowering.GroupByTags, limits, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}) - - assert.Equal(t, limits, c.Limits) -} diff --git a/compilers/openapi/internal/lowering/lowering_test.go b/compilers/openapi/internal/lowering/lowering_test.go index fc8c33bb..f1039266 100644 --- a/compilers/openapi/internal/lowering/lowering_test.go +++ b/compilers/openapi/internal/lowering/lowering_test.go @@ -636,3 +636,14 @@ func TestDynamicAnchorSites_ReadsACompleteIndexQuietly(t *testing.T) { assert.Empty(t, sites) assert.Empty(t, got) } + +// TestNew_CarriesTheLimits pins that the budgets reach the walk through the +// context, beside the other policy the caller chose. +func TestNew_CarriesTheLimits(t *testing.T) { + t.Parallel() + limits := lowering.Limits{MaxEnumMembers: 12} + + c := lowering.New(0, openapitest.DocDeclaring(), ir.SourceInfo{}, lowering.GroupByTags, limits, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, lowering.NullableWidening{}, overlay.Origin{}) + + assert.Equal(t, limits, c.Limits) +} diff --git a/compilers/openapi/internal/merge/fold_cases_internal_test.go b/compilers/openapi/internal/merge/fold_cases_internal_test.go index 049c7114..75346ef4 100644 --- a/compilers/openapi/internal/merge/fold_cases_internal_test.go +++ b/compilers/openapi/internal/merge/fold_cases_internal_test.go @@ -20,44 +20,6 @@ func bigVal(v string) *ir.BigVal { return &b } -// TestWireNameIndex_KeysEveryPropertyByItsWireName pins the index a -// redeclaration is found through. A property missing from it would be appended a -// second time rather than reconciled, leaving one wire name declared twice in -// one model. -func TestWireNameIndex_KeysEveryPropertyByItsWireName(t *testing.T) { - t.Parallel() - got := WireNameIndex([]ir.Property{{WireName: "id"}, {WireName: "name"}, {WireName: "age"}}) - - assert.Equal(t, map[string]int{"id": 0, "name": 1, "age": 2}, got) - assert.Empty(t, WireNameIndex(nil), "no properties index to nothing") -} - -// TestMergeProperty_AppendsThenFolds pins the two outcomes the entry point -// chooses between: a wire name not yet present is appended and indexed, and one -// already present is folded into the property that holds it rather than -// appended beside it. -func TestMergeProperty_AppendsThenFolds(t *testing.T) { - t.Parallel() - g, recorded := stubMerger(nil) - m := &ir.Model{} - idx, settle := indexOver(m, nil, g) - - idx.MergeProperty(ir.Property{WireName: "id", Required: true, Provenance: ptrAt("/p")}, unread(t)) - idx.MergeProperty(ir.Property{WireName: "name", Provenance: ptrAt("/p")}, unread(t)) - - require.Len(t, m.Properties, 2, "two distinct wire names are two properties") - assert.Equal(t, 0, idx.byWire["id"].pos) - assert.Equal(t, 1, idx.byWire["name"].pos) - - idx.MergeProperty(ir.Property{WireName: "id", Secret: true, Provenance: ptrAt("/other")}, unread(t)) - settle() - - require.Len(t, m.Properties, 2, "a redeclaration folds rather than appending") - assert.True(t, m.Properties[0].Required, "required is kept from the first declaration") - assert.True(t, m.Properties[0].Secret, "and OR-ed with the redeclaration's") - assert.Empty(t, *recorded, "agreeing declarations are not a conflict") -} - // TestFold_AConstraintDisagreementIsReported pins the second of the two // conflict routes. Two declarations that agree on type but pin one keyword to // different values fold to the first one's bound, so the other, which may be diff --git a/compilers/openapi/internal/merge/fold_internal_test.go b/compilers/openapi/internal/merge/fold_internal_test.go index 1317e37c..22353e77 100644 --- a/compilers/openapi/internal/merge/fold_internal_test.go +++ b/compilers/openapi/internal/merge/fold_internal_test.go @@ -19,65 +19,6 @@ func redeclared(target ir.TypeID, at string) ir.Property { return ir.Property{WireName: "v", Type: ir.TypeRef{Target: target}, Provenance: ptrAt(jsontext.Pointer("/allOf/" + at + "/properties/v"))} } -// TestPropIndex_ARedeclarationFoldsOnceTheRegistryIsWhole pins what frees the -// fold from declaration order (GitHub #898): the walk lands a wire name's first -// declaration and records every later one, the model hands its folds over -// once, and the fold reads the registry it settles against, which holds nodes -// the walk had not built when it met the declarations. -func TestPropIndex_ARedeclarationFoldsOnceTheRegistryIsWhole(t *testing.T) { - t.Parallel() - reg := ir.TypeRegistry{"t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}} - var queued []Settle - m := &ir.Model{ID: "t/m"} - idx := NewPropIndex(m, func(s Settle) { queued = append(queued, s) }) - - w := func(at string) ir.Property { - return ir.Property{WireName: "w", Type: ir.TypeRef{Target: "t/str"}, Provenance: ptrAt(jsontext.Pointer("/allOf/" + at + "/properties/w"))} - } - idx.MergeProperty(redeclared("t/str", "0"), written(`{"type":"string"}`)) - idx.MergeProperty(w("0"), unread(t)) - idx.MergeProperty(redeclared("t/uri", "1"), unread(t)) - idx.MergeProperty(w("1"), unread(t)) - idx.MergeProperty(redeclared("t/bool", "2"), written(`{"type":"boolean"}`)) - - require.Len(t, queued, 1, "one settlement per model, however many fields are redeclared") - require.Len(t, m.Properties, 2, "a redeclaration adds no property") - assert.Equal(t, ir.TypeID("t/str"), m.Properties[0].Type.Target, "nothing is folded before the settlement runs") - assert.Empty(t, m.Properties[0].Unmodeled) - - reg["t/uri"] = &ir.Primitive{ID: "t/uri", Prim: ir.PrimURL} - reg["t/bool"] = &ir.Primitive{ID: "t/bool", Prim: ir.PrimBool} - var settled []ir.Diagnostic - queued[0](reg, func(sev ir.Severity, code string, pointer jsontext.Pointer, format string, args ...any) { - settled = append(settled, diag.Newf(sev, code, ir.Provenance{Pointer: pointer}, format, args...)) - }) - - assert.Equal(t, ir.TypeID("t/uri"), m.Properties[0].Type.Target, - "the narrower type wins, judged against a node the walk had not built") - require.Len(t, settled, 1) - assert.Equal(t, diag.ConflictingRedecl, settled[0].Code) - assert.Equal(t, ptrAt("/allOf/2/properties/v").Pointer, settled[0].Provenance.Pointer) - assert.Equal(t, []string{losingDeclarationKey + "/allOf/2/properties/v"}, slices.Sorted(maps.Keys(m.Properties[0].Unmodeled)), - "the losing declaration is kept, and only it") - assert.Equal(t, ir.TypeID("t/str"), m.Properties[1].Type.Target, "a field declared once is left as declared") -} - -// TestPropIndex_AModelDeclaringEachNameOnceQueuesNothing pins the common path: -// a model whose properties are each declared once has nothing to fold, so it -// hands no settlement over. -func TestPropIndex_AModelDeclaringEachNameOnceQueuesNothing(t *testing.T) { - t.Parallel() - m := &ir.Model{ID: "t/m"} - idx := NewPropIndex(m, func(Settle) { t.Fatal("a model with no redeclaration queues no fold") }) - - idx.MergeProperty(ir.Property{WireName: "a", Provenance: ptrAt("/properties/a")}, unread(t)) - idx.MergeProperty(ir.Property{WireName: "b", Provenance: ptrAt("/properties/b")}, unread(t)) - - require.Len(t, m.Properties, 2) - assert.Equal(t, "a", m.Properties[0].WireName) - assert.Equal(t, "b", m.Properties[1].WireName) -} - // kindDecl declares the field "p" as kind at a pointer named for the kind, so // what a fold reports and keeps reads the same whatever order the // declarations come in. @@ -449,3 +390,39 @@ func TestFold_ExamplesAreJudgedAsOneKeyword(t *testing.T) { assert.Contains(t, got.Unmodeled, losingDeclarationKey+"/b", "%s: the writer is kept", tc.name) } } + +// TestMerger_ReconcileReportsDisagreementAndKeepsAWinner pins the documented +// policy: an unsatisfiable redeclaration is surfaced rather than silently +// resolved, and the merge still yields one property. +func TestMerger_ReconcileReportsDisagreementAndKeepsAWinner(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, + }) + dst := ir.Property{Name: ir.Naming{Source: "id"}, WireName: "id", Type: ir.TypeRef{Target: "t/str"}} + src := ir.Property{Name: ir.Naming{Source: "id"}, WireName: "id", Type: ir.TypeRef{Target: "t/int"}, + Provenance: ptrAt("/components/schemas/S/properties/id")} + + dst = foldTwo(g, dst, src, written(`{"type":"integer"}`)) + + require.Len(t, *recorded, 1, "one disagreement, one diagnostic") + d := (*recorded)[0] + assert.Equal(t, diag.ConflictingRedecl, d.Code) + assert.Contains(t, d.Message, `"id"`, "the diagnostic names the field that disagrees") + assert.Equal(t, ir.TypeID("t/str"), dst.Type.Target, "the property already merged keeps its type") +} + +func TestMerger_ResolvePrimKindReportsUnresolvableTargets(t *testing.T) { + t.Parallel() + g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, + }) + + kind, ok := judge(g).primKind(ir.TypeRef{Target: "t/str"}) + require.True(t, ok) + assert.Equal(t, ir.PrimString, kind) + + _, ok = judge(g).primKind(ir.TypeRef{Target: "t/absent"}) + assert.False(t, ok, "a target the registry does not hold resolves to no kind") +} diff --git a/compilers/openapi/internal/merge/merge_internal_test.go b/compilers/openapi/internal/merge/merge_internal_test.go index ee70e92c..f22fd3c3 100644 --- a/compilers/openapi/internal/merge/merge_internal_test.go +++ b/compilers/openapi/internal/merge/merge_internal_test.go @@ -2,6 +2,8 @@ package merge import ( "encoding/json/jsontext" + "maps" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -69,38 +71,99 @@ func indexOver(m *ir.Model, reg map[ir.TypeID]ir.TypeDef, g *Merger) (idx *PropI } } -// TestMerger_ReconcileReportsDisagreementAndKeepsAWinner pins the documented -// policy: an unsatisfiable redeclaration is surfaced rather than silently -// resolved, and the merge still yields one property. -func TestMerger_ReconcileReportsDisagreementAndKeepsAWinner(t *testing.T) { +// TestPropIndex_ARedeclarationFoldsOnceTheRegistryIsWhole pins what frees the +// fold from declaration order (GitHub #898): the walk lands a wire name's first +// declaration and records every later one, the model hands its folds over +// once, and the fold reads the registry it settles against, which holds nodes +// the walk had not built when it met the declarations. +func TestPropIndex_ARedeclarationFoldsOnceTheRegistryIsWhole(t *testing.T) { t.Parallel() - g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - "t/int": &ir.Primitive{ID: "t/int", Prim: ir.PrimInt32}, + reg := ir.TypeRegistry{"t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}} + var queued []Settle + m := &ir.Model{ID: "t/m"} + idx := NewPropIndex(m, func(s Settle) { queued = append(queued, s) }) + + w := func(at string) ir.Property { + return ir.Property{WireName: "w", Type: ir.TypeRef{Target: "t/str"}, Provenance: ptrAt(jsontext.Pointer("/allOf/" + at + "/properties/w"))} + } + idx.MergeProperty(redeclared("t/str", "0"), written(`{"type":"string"}`)) + idx.MergeProperty(w("0"), unread(t)) + idx.MergeProperty(redeclared("t/uri", "1"), unread(t)) + idx.MergeProperty(w("1"), unread(t)) + idx.MergeProperty(redeclared("t/bool", "2"), written(`{"type":"boolean"}`)) + + require.Len(t, queued, 1, "one settlement per model, however many fields are redeclared") + require.Len(t, m.Properties, 2, "a redeclaration adds no property") + assert.Equal(t, ir.TypeID("t/str"), m.Properties[0].Type.Target, "nothing is folded before the settlement runs") + assert.Empty(t, m.Properties[0].Unmodeled) + + reg["t/uri"] = &ir.Primitive{ID: "t/uri", Prim: ir.PrimURL} + reg["t/bool"] = &ir.Primitive{ID: "t/bool", Prim: ir.PrimBool} + var settled []ir.Diagnostic + queued[0](reg, func(sev ir.Severity, code string, pointer jsontext.Pointer, format string, args ...any) { + settled = append(settled, diag.Newf(sev, code, ir.Provenance{Pointer: pointer}, format, args...)) }) - dst := ir.Property{Name: ir.Naming{Source: "id"}, WireName: "id", Type: ir.TypeRef{Target: "t/str"}} - src := ir.Property{Name: ir.Naming{Source: "id"}, WireName: "id", Type: ir.TypeRef{Target: "t/int"}, - Provenance: ptrAt("/components/schemas/S/properties/id")} - dst = foldTwo(g, dst, src, written(`{"type":"integer"}`)) + assert.Equal(t, ir.TypeID("t/uri"), m.Properties[0].Type.Target, + "the narrower type wins, judged against a node the walk had not built") + require.Len(t, settled, 1) + assert.Equal(t, diag.ConflictingRedecl, settled[0].Code) + assert.Equal(t, ptrAt("/allOf/2/properties/v").Pointer, settled[0].Provenance.Pointer) + assert.Equal(t, []string{losingDeclarationKey + "/allOf/2/properties/v"}, slices.Sorted(maps.Keys(m.Properties[0].Unmodeled)), + "the losing declaration is kept, and only it") + assert.Equal(t, ir.TypeID("t/str"), m.Properties[1].Type.Target, "a field declared once is left as declared") +} + +// TestPropIndex_AModelDeclaringEachNameOnceQueuesNothing pins the common path: +// a model whose properties are each declared once has nothing to fold, so it +// hands no settlement over. +func TestPropIndex_AModelDeclaringEachNameOnceQueuesNothing(t *testing.T) { + t.Parallel() + m := &ir.Model{ID: "t/m"} + idx := NewPropIndex(m, func(Settle) { t.Fatal("a model with no redeclaration queues no fold") }) + + idx.MergeProperty(ir.Property{WireName: "a", Provenance: ptrAt("/properties/a")}, unread(t)) + idx.MergeProperty(ir.Property{WireName: "b", Provenance: ptrAt("/properties/b")}, unread(t)) - require.Len(t, *recorded, 1, "one disagreement, one diagnostic") - d := (*recorded)[0] - assert.Equal(t, diag.ConflictingRedecl, d.Code) - assert.Contains(t, d.Message, `"id"`, "the diagnostic names the field that disagrees") - assert.Equal(t, ir.TypeID("t/str"), dst.Type.Target, "the property already merged keeps its type") + require.Len(t, m.Properties, 2) + assert.Equal(t, "a", m.Properties[0].WireName) + assert.Equal(t, "b", m.Properties[1].WireName) } -func TestMerger_ResolvePrimKindReportsUnresolvableTargets(t *testing.T) { +// TestWireNameIndex_KeysEveryPropertyByItsWireName pins the index a +// redeclaration is found through. A property missing from it would be appended a +// second time rather than reconciled, leaving one wire name declared twice in +// one model. +func TestWireNameIndex_KeysEveryPropertyByItsWireName(t *testing.T) { t.Parallel() - g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ - "t/str": &ir.Primitive{ID: "t/str", Prim: ir.PrimString}, - }) + got := WireNameIndex([]ir.Property{{WireName: "id"}, {WireName: "name"}, {WireName: "age"}}) + + assert.Equal(t, map[string]int{"id": 0, "name": 1, "age": 2}, got) + assert.Empty(t, WireNameIndex(nil), "no properties index to nothing") +} + +// TestMergeProperty_AppendsThenFolds pins the two outcomes the entry point +// chooses between: a wire name not yet present is appended and indexed, and one +// already present is folded into the property that holds it rather than +// appended beside it. +func TestMergeProperty_AppendsThenFolds(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(nil) + m := &ir.Model{} + idx, settle := indexOver(m, nil, g) + + idx.MergeProperty(ir.Property{WireName: "id", Required: true, Provenance: ptrAt("/p")}, unread(t)) + idx.MergeProperty(ir.Property{WireName: "name", Provenance: ptrAt("/p")}, unread(t)) + + require.Len(t, m.Properties, 2, "two distinct wire names are two properties") + assert.Equal(t, 0, idx.byWire["id"].pos) + assert.Equal(t, 1, idx.byWire["name"].pos) - kind, ok := judge(g).primKind(ir.TypeRef{Target: "t/str"}) - require.True(t, ok) - assert.Equal(t, ir.PrimString, kind) + idx.MergeProperty(ir.Property{WireName: "id", Secret: true, Provenance: ptrAt("/other")}, unread(t)) + settle() - _, ok = judge(g).primKind(ir.TypeRef{Target: "t/absent"}) - assert.False(t, ok, "a target the registry does not hold resolves to no kind") + require.Len(t, m.Properties, 2, "a redeclaration folds rather than appending") + assert.True(t, m.Properties[0].Required, "required is kept from the first declaration") + assert.True(t, m.Properties[0].Secret, "and OR-ed with the redeclaration's") + assert.Empty(t, *recorded, "agreeing declarations are not a conflict") } diff --git a/compilers/openapi/internal/nodeview/lookup_test.go b/compilers/openapi/internal/nodeview/lookup_test.go index 45c1e4d8..57e8434c 100644 --- a/compilers/openapi/internal/nodeview/lookup_test.go +++ b/compilers/openapi/internal/nodeview/lookup_test.go @@ -108,40 +108,3 @@ func TestWrittenChildren_ReadsTheTextNotThroughIt(t *testing.T) { assert.Nil(t, nodeview.WrittenChildren(ynode.Seq(ynode.Scalar("a")))) assert.Nil(t, nodeview.WrittenChildren(ynode.Alias(n)), "an alias is not where the mapping is written") } - -// TestIsMergeKey_IsTheLibrarysPredicate pins IsMergeKey to the parsed `<<` -// and to nothing else: the same text untagged, an alias to it, or nil. -func TestIsMergeKey_IsTheLibrarysPredicate(t *testing.T) { - t.Parallel() - assert.True(t, nodeview.IsMergeKey(ynode.Merge())) - assert.False(t, nodeview.IsMergeKey(ynode.Scalar("<<")), "untagged, it is an ordinary key") - assert.False(t, nodeview.IsMergeKey(ynode.Alias(ynode.Merge())), "an alias standing for it is an ordinary key") - assert.False(t, nodeview.IsMergeKey(nil)) -} - -// TestRepeat_FindsAKeyWrittenTwiceWhereverTheViewReadsIt pins Repeat: a key -// the mapping writes twice, plainly or through an alias key, then one a merge -// source writes twice, in merge order, each named with the mapping writing -// it; a key a merge shares with the mapping is precedence, not a repeat. -func TestRepeat_FindsAKeyWrittenTwiceWhereverTheViewReadsIt(t *testing.T) { - t.Parallel() - src := "s1: &s1 {p: 1, p: 2}\ns2: &s2 {q: 1, q: 2}\n" + - "own: {b: 1, a: 1, b: 2, a: 2}\naliased: {&k k: 1, *k : 2}\n" + - "merged: {<<: [*s2, *s1]}\nboth: {<<: *s1, x: 1, x: 2}\nshared: {<<: {a: 1}, a: 2}\n" - root := nodeview.YAMLDoc(t, src) - v := nodeview.New() - for _, tc := range []struct { - name, key, in string - }{ - {"own", "b", "own"}, {"aliased", "k", "aliased"}, {"merged", "q", "s2"}, {"both", "x", "both"}, - } { - key, in, ok := v.Repeat(nodeview.Child(root, tc.name)) - assert.True(t, ok, tc.name) - assert.Equal(t, tc.key, key, tc.name) - assert.Same(t, nodeview.Child(root, tc.in), in, tc.name) - } - _, _, ok := v.Repeat(nodeview.Child(root, "shared")) - assert.False(t, ok, "a key a merge shares with the mapping is no repeat") - _, _, ok = v.Repeat(ynode.Scalar("x")) - assert.False(t, ok, "a node that is no mapping repeats nothing") -} diff --git a/compilers/openapi/internal/nodeview/nodeview_test.go b/compilers/openapi/internal/nodeview/nodeview_test.go new file mode 100644 index 00000000..a20a2148 --- /dev/null +++ b/compilers/openapi/internal/nodeview/nodeview_test.go @@ -0,0 +1,47 @@ +package nodeview_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" +) + +// TestIsMergeKey_IsTheLibrarysPredicate pins IsMergeKey to the parsed `<<` +// and to nothing else: the same text untagged, an alias to it, or nil. +func TestIsMergeKey_IsTheLibrarysPredicate(t *testing.T) { + t.Parallel() + assert.True(t, nodeview.IsMergeKey(ynode.Merge())) + assert.False(t, nodeview.IsMergeKey(ynode.Scalar("<<")), "untagged, it is an ordinary key") + assert.False(t, nodeview.IsMergeKey(ynode.Alias(ynode.Merge())), "an alias standing for it is an ordinary key") + assert.False(t, nodeview.IsMergeKey(nil)) +} + +// TestRepeat_FindsAKeyWrittenTwiceWhereverTheViewReadsIt pins Repeat: a key +// the mapping writes twice, plainly or through an alias key, then one a merge +// source writes twice, in merge order, each named with the mapping writing +// it; a key a merge shares with the mapping is precedence, not a repeat. +func TestRepeat_FindsAKeyWrittenTwiceWhereverTheViewReadsIt(t *testing.T) { + t.Parallel() + src := "s1: &s1 {p: 1, p: 2}\ns2: &s2 {q: 1, q: 2}\n" + + "own: {b: 1, a: 1, b: 2, a: 2}\naliased: {&k k: 1, *k : 2}\n" + + "merged: {<<: [*s2, *s1]}\nboth: {<<: *s1, x: 1, x: 2}\nshared: {<<: {a: 1}, a: 2}\n" + root := nodeview.YAMLDoc(t, src) + v := nodeview.New() + for _, tc := range []struct { + name, key, in string + }{ + {"own", "b", "own"}, {"aliased", "k", "aliased"}, {"merged", "q", "s2"}, {"both", "x", "both"}, + } { + key, in, ok := v.Repeat(nodeview.Child(root, tc.name)) + assert.True(t, ok, tc.name) + assert.Equal(t, tc.key, key, tc.name) + assert.Same(t, nodeview.Child(root, tc.in), in, tc.name) + } + _, _, ok := v.Repeat(nodeview.Child(root, "shared")) + assert.False(t, ok, "a key a merge shares with the mapping is no repeat") + _, _, ok = v.Repeat(ynode.Scalar("x")) + assert.False(t, ok, "a node that is no mapping repeats nothing") +} diff --git a/compilers/openapi/internal/oas/minor_test.go b/compilers/openapi/internal/oas/minor_test.go index 289a05ae..172830c0 100644 --- a/compilers/openapi/internal/oas/minor_test.go +++ b/compilers/openapi/internal/oas/minor_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers/openapi/internal/oas" ) @@ -95,14 +94,3 @@ func TestSpan_OverlapsOnSharedMinors(t *testing.T) { assert.False(t, oas.Only(oas.V30).Overlaps(oas.From(oas.V31))) assert.False(t, oas.Never().Overlaps(oas.Always())) } - -// TestKinds_ListsEveryKindOnce pins the enumeration the table tests lean on. -func TestKinds_ListsEveryKindOnce(t *testing.T) { - kinds := oas.Kinds() - require.NotEmpty(t, kinds) - assert.Equal(t, oas.Document, kinds[0]) - assert.Equal(t, oas.SecurityRequirement, kinds[len(kinds)-1]) - for i := 1; i < len(kinds); i++ { - assert.Equal(t, kinds[i-1]+1, kinds[i], "the kinds run in sequence") - } -} diff --git a/compilers/openapi/internal/oas/types_table_test.go b/compilers/openapi/internal/oas/types_table_test.go index 66b56b68..f6e02f14 100644 --- a/compilers/openapi/internal/oas/types_table_test.go +++ b/compilers/openapi/internal/oas/types_table_test.go @@ -294,3 +294,14 @@ func TestTable_AgreesWithTheConstructsLaterMinorsAdded(t *testing.T) { slices.SortFunc(got, order) assert.Empty(t, cmp.Diff(want, got)) } + +// TestKinds_ListsEveryKindOnce pins the enumeration the table tests lean on. +func TestKinds_ListsEveryKindOnce(t *testing.T) { + kinds := oas.Kinds() + require.NotEmpty(t, kinds) + assert.Equal(t, oas.Document, kinds[0]) + assert.Equal(t, oas.SecurityRequirement, kinds[len(kinds)-1]) + for i := 1; i < len(kinds); i++ { + assert.Equal(t, kinds[i-1]+1, kinds[i], "the kinds run in sequence") + } +} diff --git a/compilers/openapi/internal/sourceindex/measure_internal_test.go b/compilers/openapi/internal/sourceindex/measure_internal_test.go index 6ba5f96f..d99bee83 100644 --- a/compilers/openapi/internal/sourceindex/measure_internal_test.go +++ b/compilers/openapi/internal/sourceindex/measure_internal_test.go @@ -7,160 +7,11 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) -// TestNesting_CountsTheMappingsAndSequencesOnTheDeepestPath pins what one -// level is: a mapping or a sequence, empty or not, and never a scalar. -func TestNesting_CountsTheMappingsAndSequencesOnTheDeepestPath(t *testing.T) { - t.Parallel() - tests := []struct { - name string - src string - want int64 - }{ - {"a scalar document", "x\n", 0}, - {"an empty mapping", "{}\n", 1}, - {"an empty sequence", "[]\n", 1}, - {"a flat mapping", "a: 1\nb: 2\n", 1}, - {"an empty mapping nested", "a: {}\n", 2}, - {"mappings and sequences alternating", "a: [1, {b: [2]}]\n", 4}, - {"the deepest of two branches", "a: {b: {c: 1}}\nd: [1]\n", 3}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, Build(Decode(t, tc.src), MaxIndexedNodes).Nesting()) - }) - } -} - -// TestNesting_AnAliasNestsAsItsTarget pins that the nesting is the parser's, -// which builds an alias's target where the alias stands. Each anchor below -// nests the one before it through an alias, so the tree as written is two -// levels deep while the one the parser builds is one level deeper per anchor: -// what let a document nested a few hundred levels as written reach thousands. -func TestNesting_AnAliasNestsAsItsTarget(t *testing.T) { - t.Parallel() - tests := []struct { - name string - src string - want int64 - }{ - {"one alias", "a: &x {b: {c: 1}}\nd: {e: *x}\n", 4}, - {"an alias at the top", "a: &x {b: 1}\nc: *x\n", 2}, - {"an alias to a scalar", "a: &x 1\nb: *x\n", 1}, - {"a chain of anchors", chainedAnchors(8), 9}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - idx := Build(Decode(t, tc.src), MaxIndexedNodes) - require.Empty(t, idx.AnchorCycles()) - assert.Equal(t, tc.want, idx.Nesting()) - }) - } -} - -// chainedAnchors is n anchors a0 … a(n-1), each a mapping holding an alias to -// the one before it. -func chainedAnchors(n int) string { - var b strings.Builder - b.WriteString("a0: &a0 {k: 1}\n") - for i := 1; i < n; i++ { - fmt.Fprintf(&b, "a%d: &a%d {k: *a%d}\n", i, i, i-1) - } - return b.String() -} - -// TestNesting_CountsAMergeValueWhereItIsWritten pins the one place the measure -// counts more than the parser builds: the parser folds a `<<` value's pairs -// into the mapping holding it, and the measure counts the value as the level -// it is written at. The excess is a level per merge, never a shortfall. -func TestNesting_CountsAMergeValueWhereItIsWritten(t *testing.T) { - t.Parallel() - tests := []struct { - name string - src string - want int64 - }{ - {"an inline merge", "a: {<<: {k: {v: 1}}}\n", 4}, - {"a merge through an alias", "b: &b {k: {v: 1}}\na: {<<: *b}\n", 4}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, Build(Decode(t, tc.src), MaxIndexedNodes).Nesting()) - }) - } -} - -// TestNesting_DoesNotReadAnchorNames pins that an alias's target is measured -// by identity. The loader clears every anchor name before the model is built -// (releaseAnchors), and a hand-built tree carries none, so a measure keyed on -// names would fall short of the parser on both. -func TestNesting_DoesNotReadAnchorNames(t *testing.T) { - t.Parallel() - root := Decode(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") - named := Build(root, MaxIndexedNodes).Nesting() - clearAnchors(root) - - assert.Equal(t, named, Build(root, MaxIndexedNodes).Nesting(), "the same tree without its anchor names") - - target := ynode.Map(ynode.Scalar("k"), ynode.Map(ynode.Scalar("v"), ynode.Scalar("1"))) - built := ynode.Map(ynode.Scalar("a"), target, ynode.Scalar("b"), ynode.Map(ynode.Scalar("c"), ynode.Alias(target))) - assert.Equal(t, int64(4), Build(built, MaxIndexedNodes).Nesting(), "a hand-built alias to an unnamed node") -} - -// clearAnchors empties the anchor name of every node under n. -func clearAnchors(n *yaml.Node) { - stack := []*yaml.Node{n} - for len(stack) > 0 { - at := stack[len(stack)-1] - stack = stack[:len(stack)-1] - at.Anchor = "" - stack = append(stack, at.Content...) - } -} - -// TestNesting_AnAnchorCycleCountsOneTurn pins that a recursive anchor, which -// the caller refuses on AnchorCycles, leaves a finite answer behind rather -// than a walk that never ends. -func TestNesting_AnAnchorCycleCountsOneTurn(t *testing.T) { - t.Parallel() - inner := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) - root := ynode.Map(ynode.Scalar("outer"), inner) - inner.Content = append(inner.Content, ynode.Scalar("loop"), ynode.Alias(root)) - - idx := Build(root, MaxIndexedNodes) - require.Len(t, idx.AnchorCycles(), 1) - assert.Equal(t, int64(4), idx.Nesting(), "root, inner, and one turn back through root and inner") -} - -// TestNesting_DeepTreeCostsNoStack pins that neither the walk nor the measure -// recurses: a tree nested ten times deeper than yaml.v3 parses, reached both -// as written and through an alias, is measured whole. -func TestNesting_DeepTreeCostsNoStack(t *testing.T) { - t.Parallel() - const depth = 100000 - deep := ynode.Seq() - at := deep - for range depth - 1 { - next := ynode.Seq() - at.Content = append(at.Content, next) - at = next - } - root := ynode.Map(ynode.Scalar("deep"), deep, ynode.Scalar("again"), ynode.Alias(deep)) - - idx := Build(root, MaxIndexedNodes) - require.False(t, idx.Truncated()) - assert.Equal(t, int64(depth+1), idx.Nesting()) - assert.Equal(t, int64(depth), NewMeasure(MaxIndexedNodes).Of(deep)) -} - // TestMeasure_MeasuresEachNodeOnce counts the measure's steps rather than // timing it. Every anchor below is aliased twice by the next, so the tree the // parser builds doubles at each one, while the measure, which remembers each diff --git a/compilers/openapi/internal/sourceindex/sourceindex_test.go b/compilers/openapi/internal/sourceindex/sourceindex_test.go index 7116fa23..dca4f47b 100644 --- a/compilers/openapi/internal/sourceindex/sourceindex_test.go +++ b/compilers/openapi/internal/sourceindex/sourceindex_test.go @@ -1,6 +1,8 @@ package sourceindex_test import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -338,3 +340,151 @@ func TestTaggedMapping_IsFoundThroughAnAnchor(t *testing.T) { require.Len(t, got, 1, "the alias is not a second mapping") assert.Same(t, anchored, got[0]) } + +// TestNesting_CountsTheMappingsAndSequencesOnTheDeepestPath pins what one +// level is: a mapping or a sequence, empty or not, and never a scalar. +func TestNesting_CountsTheMappingsAndSequencesOnTheDeepestPath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + src string + want int64 + }{ + {"a scalar document", "x\n", 0}, + {"an empty mapping", "{}\n", 1}, + {"an empty sequence", "[]\n", 1}, + {"a flat mapping", "a: 1\nb: 2\n", 1}, + {"an empty mapping nested", "a: {}\n", 2}, + {"mappings and sequences alternating", "a: [1, {b: [2]}]\n", 4}, + {"the deepest of two branches", "a: {b: {c: 1}}\nd: [1]\n", 3}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, sourceindex.Build(sourceindex.Decode(t, tc.src), sourceindex.MaxIndexedNodes).Nesting()) + }) + } +} + +// TestNesting_AnAliasNestsAsItsTarget pins that the nesting is the parser's, +// which builds an alias's target where the alias stands. Each anchor below +// nests the one before it through an alias, so the tree as written is two +// levels deep while the one the parser builds is one level deeper per anchor: +// what let a document nested a few hundred levels as written reach thousands. +func TestNesting_AnAliasNestsAsItsTarget(t *testing.T) { + t.Parallel() + tests := []struct { + name string + src string + want int64 + }{ + {"one alias", "a: &x {b: {c: 1}}\nd: {e: *x}\n", 4}, + {"an alias at the top", "a: &x {b: 1}\nc: *x\n", 2}, + {"an alias to a scalar", "a: &x 1\nb: *x\n", 1}, + {"a chain of anchors", chainedAnchors(8), 9}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + idx := sourceindex.Build(sourceindex.Decode(t, tc.src), sourceindex.MaxIndexedNodes) + require.Empty(t, idx.AnchorCycles()) + assert.Equal(t, tc.want, idx.Nesting()) + }) + } +} + +// chainedAnchors is n anchors a0 … a(n-1), each a mapping holding an alias to +// the one before it. +func chainedAnchors(n int) string { + var b strings.Builder + b.WriteString("a0: &a0 {k: 1}\n") + for i := 1; i < n; i++ { + fmt.Fprintf(&b, "a%d: &a%d {k: *a%d}\n", i, i, i-1) + } + return b.String() +} + +// TestNesting_CountsAMergeValueWhereItIsWritten pins the one place the measure +// counts more than the parser builds: the parser folds a `<<` value's pairs +// into the mapping holding it, and the measure counts the value as the level +// it is written at. The excess is a level per merge, never a shortfall. +func TestNesting_CountsAMergeValueWhereItIsWritten(t *testing.T) { + t.Parallel() + tests := []struct { + name string + src string + want int64 + }{ + {"an inline merge", "a: {<<: {k: {v: 1}}}\n", 4}, + {"a merge through an alias", "b: &b {k: {v: 1}}\na: {<<: *b}\n", 4}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, sourceindex.Build(sourceindex.Decode(t, tc.src), sourceindex.MaxIndexedNodes).Nesting()) + }) + } +} + +// TestNesting_DoesNotReadAnchorNames pins that an alias's target is measured +// by identity. The loader clears every anchor name before the model is built +// (releaseAnchors), and a hand-built tree carries none, so a measure keyed on +// names would fall short of the parser on both. +func TestNesting_DoesNotReadAnchorNames(t *testing.T) { + t.Parallel() + root := sourceindex.Decode(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") + named := sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nesting() + clearAnchors(root) + + assert.Equal(t, named, sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nesting(), "the same tree without its anchor names") + + target := ynode.Map(ynode.Scalar("k"), ynode.Map(ynode.Scalar("v"), ynode.Scalar("1"))) + built := ynode.Map(ynode.Scalar("a"), target, ynode.Scalar("b"), ynode.Map(ynode.Scalar("c"), ynode.Alias(target))) + assert.Equal(t, int64(4), sourceindex.Build(built, sourceindex.MaxIndexedNodes).Nesting(), "a hand-built alias to an unnamed node") +} + +// clearAnchors empties the anchor name of every node under n. +func clearAnchors(n *yaml.Node) { + stack := []*yaml.Node{n} + for len(stack) > 0 { + at := stack[len(stack)-1] + stack = stack[:len(stack)-1] + at.Anchor = "" + stack = append(stack, at.Content...) + } +} + +// TestNesting_AnAnchorCycleCountsOneTurn pins that a recursive anchor, which +// the caller refuses on AnchorCycles, leaves a finite answer behind rather +// than a walk that never ends. +func TestNesting_AnAnchorCycleCountsOneTurn(t *testing.T) { + t.Parallel() + inner := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) + root := ynode.Map(ynode.Scalar("outer"), inner) + inner.Content = append(inner.Content, ynode.Scalar("loop"), ynode.Alias(root)) + + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) + require.Len(t, idx.AnchorCycles(), 1) + assert.Equal(t, int64(4), idx.Nesting(), "root, inner, and one turn back through root and inner") +} + +// TestNesting_DeepTreeCostsNoStack pins that neither the walk nor the measure +// recurses: a tree nested ten times deeper than yaml.v3 parses, reached both +// as written and through an alias, is measured whole. +func TestNesting_DeepTreeCostsNoStack(t *testing.T) { + t.Parallel() + const depth = 100000 + deep := ynode.Seq() + at := deep + for range depth - 1 { + next := ynode.Seq() + at.Content = append(at.Content, next) + at = next + } + root := ynode.Map(ynode.Scalar("deep"), deep, ynode.Scalar("again"), ynode.Alias(deep)) + + idx := sourceindex.Build(root, sourceindex.MaxIndexedNodes) + require.False(t, idx.Truncated()) + assert.Equal(t, int64(depth+1), idx.Nesting()) + assert.Equal(t, int64(depth), sourceindex.NewMeasure(sourceindex.MaxIndexedNodes).Of(deep)) +} diff --git a/compilers/openapi/internal/value/scalar_test.go b/compilers/openapi/internal/value/scalar_test.go index 094aa650..ffdcb0d8 100644 --- a/compilers/openapi/internal/value/scalar_test.go +++ b/compilers/openapi/internal/value/scalar_test.go @@ -105,3 +105,116 @@ func TestClassify_ResolvesAnUntaggedNodeByItsText(t *testing.T) { assert.Equal(t, value.ScalarNumber, got.Kind) assert.Equal(t, ir.BigVal("7"), got.Num) } + +// TestNumericLiteral_YAMLBases pins the rule that a numeric scalar is captured +// as the value YAML resolved, not as the base-10 reading of its source text. The +// two differ for every prefixed spelling, and reading the text as decimal stores +// a different number with nothing to report it. +func TestNumericLiteral_YAMLBases(t *testing.T) { + t.Parallel() + cases := []struct { + name, src string + want ir.BigVal + }{ + {"octal 1.1 spelling", "0644", "420"}, + {"octal negative", "-017", "-15"}, + {"octal 1.2 spelling", "0o17", "15"}, + {"hex", "0x1f", "31"}, + {"hex negative", "-0x1f", "-31"}, + {"binary", "0b1010", "10"}, + {"separators", "1_000", "1000"}, + {"plain decimal", "42", "42"}, + {"leading plus", "+42", "42"}, + {"above int64", "18446744073709551615", "18446744073709551615"}, + // A float keeps its text: decoding it would round it through float64. + {"float leading zero", "09", "9"}, + {"float separators", "1_000.5", "1000.5"}, + {"float precision", "1.23456789012345678901234567890", "1.23456789012345678901234567890"}, + {"float beyond int64", "123456789012345678901234567890", "123456789012345678901234567890"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := value.NumericLiteral(openapitest.YAMLNode(t, tc.src)) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestNumericLiteral_ExplicitIntBeyondUint64 covers a magnitude past uint64 +// under an explicit !!int tag: yaml.v3 resolves one that big to !!float, so it +// only reaches an !!int node when the source says so and nothing can decode it. +// Its plain-decimal text means what it says, so it survives to the last digit +// rather than being dropped for being undecodable. +func TestNumericLiteral_ExplicitIntBeyondUint64(t *testing.T) { + t.Parallel() + cases := []struct { + name, src string + want ir.BigVal + }{ + {"positive", "!!int 99999999999999999999999", "99999999999999999999999"}, + {"negative", "!!int -99999999999999999999999", "-99999999999999999999999"}, + {"separators", "!!int 99_999_999_999_999_999_999_999", "99999999999999999999999"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := value.NumericLiteral(openapitest.YAMLNode(t, tc.src)) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestNumericLiteral_NilNode covers the nil guard. +func TestNumericLiteral_NilNode(t *testing.T) { + t.Parallel() + _, err := value.NumericLiteral(nil) + require.Error(t, err) +} + +// TestNumericLiteral_UndecodableInt covers !!int spellings no base-10 reading +// can recover: a non-numeric run, and a prefixed magnitude past uint64 whose +// base would have to be guessed. Neither may fall back to its source text. +func TestNumericLiteral_UndecodableInt(t *testing.T) { + t.Parallel() + for _, src := range []string{"12abc", "077777777777777777777777", "0x1FFFFFFFFFFFFFFFFFFFF", ""} { + t.Run(src, func(t *testing.T) { + t.Parallel() + _, err := value.NumericLiteral(openapitest.ScalarNode("!!int", src)) + require.Error(t, err) + }) + } +} + +// TestNumericLiteral_NonNumericTagReadsAsDecimal covers the tag this package +// does not recognise as numeric. A quoted bound reaches it — YAML resolves +// `minimum: "10"` to !!str — and carries no YAML-assigned base, so its text is +// the decimal it looks like rather than something to reinterpret. +func TestNumericLiteral_NonNumericTagReadsAsDecimal(t *testing.T) { + t.Parallel() + tests := []struct { + name string + tag string + val string + want ir.BigVal + }{ + {name: "a quoted integer", tag: "!!str", val: "10", want: "10"}, + {name: "a quoted decimal", tag: "!!str", val: "1.5", want: "1.5"}, + // The leading zero is not an octal prefix at this tag, and BigVal is a + // canonical decimal, so it is dropped rather than reinterpreted — 0644 is + // 644 here and 420 under !!int, which is the whole reason the two paths + // are separate. + {name: "a leading zero is dropped, not read as octal", tag: "!!str", val: "0644", want: "644"}, + {name: "a signed value", tag: "!!str", val: "-7", want: "-7"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := value.NumericLiteral(openapitest.ScalarNode(tc.tag, tc.val)) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/compilers/openapi/internal/value/value_test.go b/compilers/openapi/internal/value/value_test.go index 40c96854..8705ddf5 100644 --- a/compilers/openapi/internal/value/value_test.go +++ b/compilers/openapi/internal/value/value_test.go @@ -39,88 +39,6 @@ func TestValueFromNode_Scalars(t *testing.T) { } } -// TestNumericLiteral_YAMLBases pins the rule that a numeric scalar is captured -// as the value YAML resolved, not as the base-10 reading of its source text. The -// two differ for every prefixed spelling, and reading the text as decimal stores -// a different number with nothing to report it. -func TestNumericLiteral_YAMLBases(t *testing.T) { - t.Parallel() - cases := []struct { - name, src string - want ir.BigVal - }{ - {"octal 1.1 spelling", "0644", "420"}, - {"octal negative", "-017", "-15"}, - {"octal 1.2 spelling", "0o17", "15"}, - {"hex", "0x1f", "31"}, - {"hex negative", "-0x1f", "-31"}, - {"binary", "0b1010", "10"}, - {"separators", "1_000", "1000"}, - {"plain decimal", "42", "42"}, - {"leading plus", "+42", "42"}, - {"above int64", "18446744073709551615", "18446744073709551615"}, - // A float keeps its text: decoding it would round it through float64. - {"float leading zero", "09", "9"}, - {"float separators", "1_000.5", "1000.5"}, - {"float precision", "1.23456789012345678901234567890", "1.23456789012345678901234567890"}, - {"float beyond int64", "123456789012345678901234567890", "123456789012345678901234567890"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := value.NumericLiteral(openapitest.YAMLNode(t, tc.src)) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - -// TestNumericLiteral_ExplicitIntBeyondUint64 covers a magnitude past uint64 -// under an explicit !!int tag: yaml.v3 resolves one that big to !!float, so it -// only reaches an !!int node when the source says so and nothing can decode it. -// Its plain-decimal text means what it says, so it survives to the last digit -// rather than being dropped for being undecodable. -func TestNumericLiteral_ExplicitIntBeyondUint64(t *testing.T) { - t.Parallel() - cases := []struct { - name, src string - want ir.BigVal - }{ - {"positive", "!!int 99999999999999999999999", "99999999999999999999999"}, - {"negative", "!!int -99999999999999999999999", "-99999999999999999999999"}, - {"separators", "!!int 99_999_999_999_999_999_999_999", "99999999999999999999999"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := value.NumericLiteral(openapitest.YAMLNode(t, tc.src)) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - -// TestNumericLiteral_NilNode covers the nil guard. -func TestNumericLiteral_NilNode(t *testing.T) { - t.Parallel() - _, err := value.NumericLiteral(nil) - require.Error(t, err) -} - -// TestNumericLiteral_UndecodableInt covers !!int spellings no base-10 reading -// can recover: a non-numeric run, and a prefixed magnitude past uint64 whose -// base would have to be guessed. Neither may fall back to its source text. -func TestNumericLiteral_UndecodableInt(t *testing.T) { - t.Parallel() - for _, src := range []string{"12abc", "077777777777777777777777", "0x1FFFFFFFFFFFFFFFFFFFF", ""} { - t.Run(src, func(t *testing.T) { - t.Parallel() - _, err := value.NumericLiteral(openapitest.ScalarNode("!!int", src)) - require.Error(t, err) - }) - } -} - func TestValueFromNode_ObjectPreservesOrder(t *testing.T) { t.Parallel() got, err := value.FromNode(openapitest.YAMLNode(t, "b: 1\na: 2\n")) @@ -264,37 +182,6 @@ func TestValueFromNode_DepthCapExceeded(t *testing.T) { require.Error(t, err) } -// TestNumericLiteral_NonNumericTagReadsAsDecimal covers the tag this package -// does not recognise as numeric. A quoted bound reaches it — YAML resolves -// `minimum: "10"` to !!str — and carries no YAML-assigned base, so its text is -// the decimal it looks like rather than something to reinterpret. -func TestNumericLiteral_NonNumericTagReadsAsDecimal(t *testing.T) { - t.Parallel() - tests := []struct { - name string - tag string - val string - want ir.BigVal - }{ - {name: "a quoted integer", tag: "!!str", val: "10", want: "10"}, - {name: "a quoted decimal", tag: "!!str", val: "1.5", want: "1.5"}, - // The leading zero is not an octal prefix at this tag, and BigVal is a - // canonical decimal, so it is dropped rather than reinterpreted — 0644 is - // 644 here and 420 under !!int, which is the whole reason the two paths - // are separate. - {name: "a leading zero is dropped, not read as octal", tag: "!!str", val: "0644", want: "644"}, - {name: "a signed value", tag: "!!str", val: "-7", want: "-7"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := value.NumericLiteral(openapitest.ScalarNode(tc.tag, tc.val)) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - // TestValueFromNode_ReadsAMappingAsTheParserDoes pins the members of a mapping // value to the pairs the parser reads (nodeview.View.MappingPairs): every `<<` // merged in, first, a key written through an alias read as the key it names, diff --git a/internal/census/census_test.go b/internal/census/census_test.go index d771e34e..5018aafd 100644 --- a/internal/census/census_test.go +++ b/internal/census/census_test.go @@ -3,11 +3,9 @@ package census_test import ( "fmt" "reflect" - "slices" "strings" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -256,53 +254,6 @@ func TestCensus_OfTheZeroUniverseCountsNoFact(t *testing.T) { assert.Equal(t, "documents 2\nno-document 0\ntruncated 0\n", c.Report()) } -func TestReport_ListsEveryFactOfTheUniverseWhateverIsCounted(t *testing.T) { - t.Parallel() - empty := facts(t, census.New(fixtureUniverse(t)).Report()) - got := counted(t, &fixture.Root{Name: "n", Color: "green"}) - - for fact, n := range empty { - switch fact { - case "documents", "no-document", "truncated": - continue - } - assert.Equal(t, "0 0", n, fact) - assert.Contains(t, got, fact, "counting a document removes no fact from the report") - } - assert.Contains(t, got, `value Color "green" undeclared`) -} - -func TestReport_IsDeterministicAndOrdersValuesByValue(t *testing.T) { - t.Parallel() - build := func() string { - c := census.New(fixtureUniverse(t)) - require.NoError(t, c.AddAny(&fixture.Root{ - Color: "green", - Labels: map[string]fixture.Level{"a": 5, "b": -5, "c": 1000}, - })) - return c.Report() - } - first, second := build(), build() - if diff := cmp.Diff(first, second); diff != "" { - t.Errorf("two runs differ (-first +second):\n%s", diff) - } - - var values []string - for line := range strings.Lines(first) { - if fields := strings.Fields(line); fields[0] == "value" { - values = append(values, strings.Join(fields[3:5], " ")) - } - } - want := []string{ - `Color "blue"`, `Color "green"`, `Color "red"`, - `Kind "circle"`, `Kind "hexagon"`, `Kind "square"`, - `Level -20`, `Level -5`, `Level -1`, `Level 0`, `Level 5`, `Level 10`, `Level 100`, `Level 1000`, - } - if diff := cmp.Diff(want, values); diff != "" { - t.Errorf("values (-want +got):\n%s", diff) - } -} - // docOf returns a document holding the given type nodes, each filed under an // ID made from its kind and position. func docOf(nodes ...ir.TypeDef) *ir.Document { @@ -390,14 +341,3 @@ func TestCensus_CountsATruncatedWalkAndKeepsWhatItReached(t *testing.T) { assert.Greater(t, instances, ir.MaxWalkDepth/4, "the walk got deep into the one, and all the way into the other") assert.Less(t, instances, ir.MaxWalkDepth+3, "but not to the bottom of the one it cut") } - -func TestReport_ListsMembersByName(t *testing.T) { - t.Parallel() - var names []string - for line := range strings.Lines(irCensus(t).Report()) { - if fields := strings.Fields(line); fields[0] == "member" { - names = append(names, fields[3]) - } - } - assert.True(t, slices.IsSorted(names)) -} diff --git a/internal/census/report_test.go b/internal/census/report_test.go new file mode 100644 index 00000000..7b1f7251 --- /dev/null +++ b/internal/census/report_test.go @@ -0,0 +1,72 @@ +package census_test + +import ( + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/internal/census" + "github.com/dexpace/morphic/internal/census/testdata/fixture" +) + +func TestReport_ListsEveryFactOfTheUniverseWhateverIsCounted(t *testing.T) { + t.Parallel() + empty := facts(t, census.New(fixtureUniverse(t)).Report()) + got := counted(t, &fixture.Root{Name: "n", Color: "green"}) + + for fact, n := range empty { + switch fact { + case "documents", "no-document", "truncated": + continue + } + assert.Equal(t, "0 0", n, fact) + assert.Contains(t, got, fact, "counting a document removes no fact from the report") + } + assert.Contains(t, got, `value Color "green" undeclared`) +} + +func TestReport_IsDeterministicAndOrdersValuesByValue(t *testing.T) { + t.Parallel() + build := func() string { + c := census.New(fixtureUniverse(t)) + require.NoError(t, c.AddAny(&fixture.Root{ + Color: "green", + Labels: map[string]fixture.Level{"a": 5, "b": -5, "c": 1000}, + })) + return c.Report() + } + first, second := build(), build() + if diff := cmp.Diff(first, second); diff != "" { + t.Errorf("two runs differ (-first +second):\n%s", diff) + } + + var values []string + for line := range strings.Lines(first) { + if fields := strings.Fields(line); fields[0] == "value" { + values = append(values, strings.Join(fields[3:5], " ")) + } + } + want := []string{ + `Color "blue"`, `Color "green"`, `Color "red"`, + `Kind "circle"`, `Kind "hexagon"`, `Kind "square"`, + `Level -20`, `Level -5`, `Level -1`, `Level 0`, `Level 5`, `Level 10`, `Level 100`, `Level 1000`, + } + if diff := cmp.Diff(want, values); diff != "" { + t.Errorf("values (-want +got):\n%s", diff) + } +} + +func TestReport_ListsMembersByName(t *testing.T) { + t.Parallel() + var names []string + for line := range strings.Lines(irCensus(t).Report()) { + if fields := strings.Fields(line); fields[0] == "member" { + names = append(names, fields[3]) + } + } + assert.True(t, slices.IsSorted(names)) +} diff --git a/ir/irtest/fill_test.go b/ir/irtest/fill_test.go index 4bca39b7..a91d700c 100644 --- a/ir/irtest/fill_test.go +++ b/ir/irtest/fill_test.go @@ -45,3 +45,25 @@ func TestFill_StopsNestingAStructTypeAtItsBound(t *testing.T) { } assert.Equal(t, 3, depth, "a Value nests three deep along one path, then stays zero") } + +func TestFill_RefusesAnythingButANonNilPointer(t *testing.T) { + t.Parallel() + for name, v := range map[string]any{ + "a value": ir.Document{}, + "a nil pointer": (*ir.Document)(nil), + "nothing": nil, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + rec := &recordingT{} + done := make(chan struct{}) + go func() { + defer close(done) + irtest.FillOn(rec, v) + }() + <-done + assert.True(t, rec.aborted, "fill must stop rather than fill what it cannot reach") + assert.Contains(t, rec.fatalf, "want a non-nil pointer") + }) + } +} diff --git a/ir/irtest/golden_test.go b/ir/irtest/golden_test.go index e3b0ceed..9702c051 100644 --- a/ir/irtest/golden_test.go +++ b/ir/irtest/golden_test.go @@ -356,25 +356,3 @@ func TestCompareGolden_HelperIsCalled(t *testing.T) { assert.GreaterOrEqual(t, rec.helperCalls, 1, "compareGolden marks itself a helper") }) } - -func TestFill_RefusesAnythingButANonNilPointer(t *testing.T) { - t.Parallel() - for name, v := range map[string]any{ - "a value": ir.Document{}, - "a nil pointer": (*ir.Document)(nil), - "nothing": nil, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - rec := &recordingT{} - done := make(chan struct{}) - go func() { - defer close(done) - irtest.FillOn(rec, v) - }() - <-done - assert.True(t, rec.aborted, "fill must stop rather than fill what it cannot reach") - assert.Contains(t, rec.fatalf, "want a non-nil pointer") - }) - } -} diff --git a/ir/irtest/main_test.go b/ir/irtest/main_test.go index 4adb06f0..3e5d77d2 100644 --- a/ir/irtest/main_test.go +++ b/ir/irtest/main_test.go @@ -7,9 +7,9 @@ import ( ) // TestMain runs this package's tests through leakcheck.Main. runCompare, in -// golden_internal_test.go, drives compareGolden on its own goroutine so a -// stubbed testingT's simulated Fatalf can call runtime.Goexit without -// unwinding the real test goroutine. +// golden_test.go, drives compareGolden on its own goroutine so a stubbed +// testingT's simulated Fatalf can call runtime.Goexit without unwinding the +// real test goroutine. func TestMain(m *testing.M) { leakcheck.Main(m) } diff --git a/ir/json_internal_test.go b/ir/json_internal_test.go index 7f5bcba0..d9bb4068 100644 --- a/ir/json_internal_test.go +++ b/ir/json_internal_test.go @@ -11,35 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestTypeDef_MarkerMethods exercises the unexported sealed-interface marker on -// every concrete TypeDef kind. The markers carry no behavior; calling them keeps -// the sealed-sum contract honest and observable to coverage. -func TestTypeDef_MarkerMethods(t *testing.T) { - t.Parallel() - defs := []TypeDef{ - &Primitive{}, - &Scalar{}, - &Model{}, - &Union{}, - &Enum{}, - &List{}, - &MapT{}, - &Tuple{}, - &Literal{}, - &External{}, - &Any{}, - } - for _, td := range defs { - t.Run(string(td.Kind()), func(t *testing.T) { - t.Parallel() - // typeDef is the unexported sealing marker on the TypeDef interface; - // invoking it keeps the sealed-sum contract observable. The bodies are - // empty (zero statements), so this documents rather than covers them. - td.typeDef() - }) - } -} - // TestStringMember_Branches pins every return path of stringMember directly: // the document and registry decoders reach it only with input a decoder has // already read, so its refusals of malformed input are otherwise unexercised. diff --git a/ir/typedef_internal_test.go b/ir/typedef_internal_test.go new file mode 100644 index 00000000..6edb02a7 --- /dev/null +++ b/ir/typedef_internal_test.go @@ -0,0 +1,32 @@ +package ir + +import "testing" + +// TestTypeDef_MarkerMethods exercises the unexported sealed-interface marker on +// every concrete TypeDef kind. The markers carry no behavior; calling them keeps +// the sealed-sum contract honest and observable to coverage. +func TestTypeDef_MarkerMethods(t *testing.T) { + t.Parallel() + defs := []TypeDef{ + &Primitive{}, + &Scalar{}, + &Model{}, + &Union{}, + &Enum{}, + &List{}, + &MapT{}, + &Tuple{}, + &Literal{}, + &External{}, + &Any{}, + } + for _, td := range defs { + t.Run(string(td.Kind()), func(t *testing.T) { + t.Parallel() + // typeDef is the unexported sealing marker on the TypeDef interface; + // invoking it keeps the sealed-sum contract observable. The bodies are + // empty (zero statements), so this documents rather than covers them. + td.typeDef() + }) + } +} diff --git a/ir/typedef_test.go b/ir/typedef_test.go index 64580263..b33489e8 100644 --- a/ir/typedef_test.go +++ b/ir/typedef_test.go @@ -1,6 +1,7 @@ package ir_test import ( + "encoding/json/v2" "testing" "github.com/stretchr/testify/assert" @@ -64,3 +65,136 @@ func TestIsNilTypeDef_ScreensBothSpellingsOfNil(t *testing.T) { assert.False(t, ir.IsNilTypeDef(td), "%T is a live definition", td) } } + +// TestTypeCommon_JSONContract pins TypeCommon's omitempty contract. ID, Name, +// Anonymous, Docs, Sensitive, and Provenance carry no omitempty because every +// type-graph node has an identity, a naming, an anonymity flag, a docs +// object, a sensitivity flag, and provenance; every other field is optional. +// TypeCommon is embedded and inlined into all 11 concrete kinds, so this test +// pins the shared portion of every one of their zero-value encodings. It also +// pins that a fully populated TypeCommon round-trips. +func TestTypeCommon_JSONContract(t *testing.T) { + t.Parallel() + assertJSONContract(t, ir.TypeCommon{}, + `{"id":"","name":{},"anonymous":false,"docs":{},"sensitive":false,"provenance":{"source":0}}`, + ir.TypeCommon{ + ID: "t/openapi/components/schemas/User", + Name: populatedNaming(), + Namespace: []string{"com", "example"}, + Anonymous: true, + Docs: populatedDocs(), + Tags: []string{"users", "public"}, + Sensitive: true, + Access: "internal", + Deprecation: populatedDeprecation(), + Availability: populatedAvailability(), + WireNameByFormat: map[string]string{"xml": "User", "json": "user"}, + MediaTypeHint: "application/json", + XML: populatedXMLHints(), + Examples: []ir.Example{ + {Name: "ex1", Value: &ir.Value{Kind: ir.ValueString, Str: "v1"}}, + }, + Instantiation: &ir.TemplateInstantiation{ + Template: "Page", + Args: []ir.TemplateArg{ + {Type: &ir.TypeRef{Target: "t/item"}}, + {Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("10")}}, + }, + }, + Unmodeled: populatedUnmodeled(), + Provenance: populatedProvenance(), + }) +} + +// TestTypeCommon_WireNameByFormatDeterministic pins Class C for TypeCommon's +// map field. +func TestTypeCommon_WireNameByFormatDeterministic(t *testing.T) { + t.Parallel() + tc := ir.TypeCommon{ + WireNameByFormat: map[string]string{ + "z-format": "Z", "m-format": "M", "a-format": "A", "q-format": "Q", "b-format": "B", + }, + } + got := assertDeterministicMarshal(t, tc) + assert.Contains(t, got, + `"wireNameByFormat":{"a-format":"A","b-format":"B","m-format":"M","q-format":"Q","z-format":"Z"}`) +} + +// TestTypeDef_ZeroValueShapeWithKindTag pins the zero-value marshal shape of +// every concrete TypeDef kind, including the adjacent "kind" discriminator +// (invariant #7 / the sealed-sum JSON encoding). This is the Class A test +// json_internal_test.go does not cover: that file drives the codec's helpers +// directly, but never asserts the exact zero-value payload for each real kind. +func TestTypeDef_ZeroValueShapeWithKindTag(t *testing.T) { + t.Parallel() + for _, tt := range typeDefZeroShapeTests() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + raw, err := json.Marshal(tt.td) + require.NoError(t, err) + assert.Equal(t, tt.want, string(raw)) + }) + } +} + +// TestTypeDef_CommonIsAnAliasNotACopy checks that Common() returns a pointer +// into the concrete struct's own TypeCommon, not a copy or a value shared +// across kinds. A copying or singleton implementation would pass every other +// test in this package (including types_test.go's JSON round-trip tests, which +// never call Common()) while silently breaking any pass that mutates shared +// metadata (Availability, Deprecation, …) via the TypeDef interface. +// +// The read-back goes through json.Marshal of the whole td rather than +// td.Common().ID again, since re-reading via Common() would trivially pass a +// shared-singleton mutant. +func TestTypeDef_CommonIsAnAliasNotACopy(t *testing.T) { + t.Parallel() + for _, k := range allKinds { + t.Run(string(k), func(t *testing.T) { + t.Parallel() + td, ok := ir.NewTypeDef(k) + require.True(t, ok, "no concrete type registered for kind %q", k) + td.Common().ID = "t/y" + raw, err := json.Marshal(td) + require.NoError(t, err) + assert.Contains(t, string(raw), `"id":"t/y"`) + }) + } +} + +// TestTemplateInstantiation_JSONContract pins TemplateInstantiation's +// omitempty contract (both fields are optional) and that a fully populated +// TemplateInstantiation round-trips. +func TestTemplateInstantiation_JSONContract(t *testing.T) { + t.Parallel() + assertJSONContract(t, ir.TemplateInstantiation{}, `{}`, ir.TemplateInstantiation{ + Template: "Page", + Args: []ir.TemplateArg{ + {Type: &ir.TypeRef{Target: "t/item"}}, + {Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("10")}}, + }, + }) +} + +// TestTemplateArg_ZeroValueShape pins TemplateArg's omitempty contract: both +// fields are optional, since exactly one is legally set at a time. +func TestTemplateArg_ZeroValueShape(t *testing.T) { + t.Parallel() + assertZeroValueShape(t, ir.TemplateArg{}, `{}`) +} + +// TestTemplateArg_PopulatedRoundTrip pins that both arms of TemplateArg — the +// type-parameter arm and the valueof arm — round-trip. +func TestTemplateArg_PopulatedRoundTrip(t *testing.T) { + t.Parallel() + tests := map[string]ir.TemplateArg{ + "type": {Type: &ir.TypeRef{Target: "t/item"}}, + "value": {Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("10")}}, + } + for name, want := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + assertRoundTrip(t, want) + }) + } +} diff --git a/ir/types_test.go b/ir/types_test.go index 5ff77ff9..0cac4988 100644 --- a/ir/types_test.go +++ b/ir/types_test.go @@ -1,7 +1,6 @@ package ir_test import ( - "encoding/json/v2" "maps" "slices" "testing" @@ -12,60 +11,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// TestTypeCommon_JSONContract pins TypeCommon's omitempty contract. ID, Name, -// Anonymous, Docs, Sensitive, and Provenance carry no omitempty because every -// type-graph node has an identity, a naming, an anonymity flag, a docs -// object, a sensitivity flag, and provenance; every other field is optional. -// TypeCommon is embedded and inlined into all 11 concrete kinds, so this test -// pins the shared portion of every one of their zero-value encodings. It also -// pins that a fully populated TypeCommon round-trips. -func TestTypeCommon_JSONContract(t *testing.T) { - t.Parallel() - assertJSONContract(t, ir.TypeCommon{}, - `{"id":"","name":{},"anonymous":false,"docs":{},"sensitive":false,"provenance":{"source":0}}`, - ir.TypeCommon{ - ID: "t/openapi/components/schemas/User", - Name: populatedNaming(), - Namespace: []string{"com", "example"}, - Anonymous: true, - Docs: populatedDocs(), - Tags: []string{"users", "public"}, - Sensitive: true, - Access: "internal", - Deprecation: populatedDeprecation(), - Availability: populatedAvailability(), - WireNameByFormat: map[string]string{"xml": "User", "json": "user"}, - MediaTypeHint: "application/json", - XML: populatedXMLHints(), - Examples: []ir.Example{ - {Name: "ex1", Value: &ir.Value{Kind: ir.ValueString, Str: "v1"}}, - }, - Instantiation: &ir.TemplateInstantiation{ - Template: "Page", - Args: []ir.TemplateArg{ - {Type: &ir.TypeRef{Target: "t/item"}}, - {Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("10")}}, - }, - }, - Unmodeled: populatedUnmodeled(), - Provenance: populatedProvenance(), - }) -} - -// TestTypeCommon_WireNameByFormatDeterministic pins Class C for TypeCommon's -// map field. -func TestTypeCommon_WireNameByFormatDeterministic(t *testing.T) { - t.Parallel() - tc := ir.TypeCommon{ - WireNameByFormat: map[string]string{ - "z-format": "Z", "m-format": "M", "a-format": "A", "q-format": "Q", "b-format": "B", - }, - } - got := assertDeterministicMarshal(t, tc) - assert.Contains(t, got, - `"wireNameByFormat":{"a-format":"A","b-format":"B","m-format":"M","q-format":"Q","z-format":"Z"}`) -} - // typeDefZeroShapeTests is the Class A table for every concrete TypeDef kind: // each must marshal its zero value with the "kind" tag adjacent and correct, // followed by TypeCommon's shared fields and then its own fields in @@ -138,23 +83,6 @@ func typeDefZeroShapeTests() []struct { } } -// TestTypeDef_ZeroValueShapeWithKindTag pins the zero-value marshal shape of -// every concrete TypeDef kind, including the adjacent "kind" discriminator -// (invariant #7 / the sealed-sum JSON encoding). This is the Class A test -// json_internal_test.go does not cover: that file drives the codec's helpers -// directly, but never asserts the exact zero-value payload for each real kind. -func TestTypeDef_ZeroValueShapeWithKindTag(t *testing.T) { - t.Parallel() - for _, tt := range typeDefZeroShapeTests() { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - raw, err := json.Marshal(tt.td) - require.NoError(t, err) - assert.Equal(t, tt.want, string(raw)) - }) - } -} - // TestPrimitive_PopulatedRoundTrip pins that a fully populated Primitive // (TypeCommon plus Prim) round-trips. func TestPrimitive_PopulatedRoundTrip(t *testing.T) { @@ -319,31 +247,6 @@ func TestAny_PopulatedRoundTrip(t *testing.T) { assertTypeDefRoundTrip(t, want) } -// TestTypeDef_CommonIsAnAliasNotACopy checks that Common() returns a pointer -// into the concrete struct's own TypeCommon, not a copy or a value shared -// across kinds. A copying or singleton implementation would pass every other -// test in this package (including the JSON round-trip tests above, which -// never call Common()) while silently breaking any pass that mutates shared -// metadata (Availability, Deprecation, …) via the TypeDef interface. -// -// The read-back goes through json.Marshal of the whole td rather than -// td.Common().ID again, since re-reading via Common() would trivially pass a -// shared-singleton mutant. -func TestTypeDef_CommonIsAnAliasNotACopy(t *testing.T) { - t.Parallel() - for _, k := range allKinds { - t.Run(string(k), func(t *testing.T) { - t.Parallel() - td, ok := ir.NewTypeDef(k) - require.True(t, ok, "no concrete type registered for kind %q", k) - td.Common().ID = "t/y" - raw, err := json.Marshal(td) - require.NoError(t, err) - assert.Contains(t, string(raw), `"id":"t/y"`) - }) - } -} - // primKindSpellings is the whole primitive vocabulary and its on-disk spelling. // It is a package-level fixture rather than a literal inside the test below // because the ID tests walk the same vocabulary, and a second list of the kinds @@ -674,40 +577,3 @@ func TestEnumMember_JSONContract(t *testing.T) { Unmodeled: populatedUnmodeled(), }) } - -// TestTemplateInstantiation_JSONContract pins TemplateInstantiation's -// omitempty contract (both fields are optional) and that a fully populated -// TemplateInstantiation round-trips. -func TestTemplateInstantiation_JSONContract(t *testing.T) { - t.Parallel() - assertJSONContract(t, ir.TemplateInstantiation{}, `{}`, ir.TemplateInstantiation{ - Template: "Page", - Args: []ir.TemplateArg{ - {Type: &ir.TypeRef{Target: "t/item"}}, - {Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("10")}}, - }, - }) -} - -// TestTemplateArg_ZeroValueShape pins TemplateArg's omitempty contract: both -// fields are optional, since exactly one is legally set at a time. -func TestTemplateArg_ZeroValueShape(t *testing.T) { - t.Parallel() - assertZeroValueShape(t, ir.TemplateArg{}, `{}`) -} - -// TestTemplateArg_PopulatedRoundTrip pins that both arms of TemplateArg — the -// type-parameter arm and the valueof arm — round-trip. -func TestTemplateArg_PopulatedRoundTrip(t *testing.T) { - t.Parallel() - tests := map[string]ir.TemplateArg{ - "type": {Type: &ir.TypeRef{Target: "t/item"}}, - "value": {Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("10")}}, - } - for name, want := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - assertRoundTrip(t, want) - }) - } -} From c451c5f32be80cee8e66ab3ce29d7fb660b09e31 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 05:50:31 +0300 Subject: [PATCH 11/18] test: write each duplicated test helper once Comparing every test helper's body, whatever its name, finds helpers written more than once. Each one with a home now has one copy: - within a package: irverify's violationCodes and violationPaths, each also written under another name, and reader's resolved-directory fixture, written on both sides of the package boundary and now declared once in export_test.go; - in openapitest, for the compiler's packages: ParamBySource, FirstDiag, PointersOf, HolderSpec and YAMLDocument, which also replaces sourceindex's Decode fixture; - in irtest, for the IR's: DocOf, ModelOf, ScalarOf and RefsTo, the builders both ir's and irfacts' tests wrote, NestedListValue, Encoded, RefusedAsCyclic and DiagCodes. The two wrappers that asserted an error-severity code become calls to openapitest.AssertHasCode, as the rest of the suite makes, and the three helpers that took a BigVal's address become new(ir.BigVal(v)). e2e's namedID, a second copy of its componentID, becomes componentID, the name openapitest's package comment gives that per-package copy. What is still written more than once has no common home. parseFull and compileNullable compile through openapi.New, which openapitest cannot import. componentID, and typeByName over it, spell an ID the idderivations rule allows only in a test file. The remaining file, tree and AST helpers sit in packages that share no test-helper package. Each package's tests and subtests are unchanged except for the fourteen tests of the new shared helpers, and every package keeps full statement coverage. --- compilers/openapi/budgets_internal_test.go | 12 +- .../openapi/e2e/allof_visibility_test.go | 2 +- compilers/openapi/e2e/amplification_test.go | 3 +- .../openapi/e2e/annotations_carrier_test.go | 5 +- compilers/openapi/e2e/annotations_test.go | 78 ++++----- .../e2e/conformance_composition_test.go | 148 +++++++++--------- .../openapi/e2e/conformance_content_test.go | 2 +- .../openapi/e2e/conformance_docs_test.go | 20 +-- .../e2e/conformance_extensions_test.go | 4 +- .../e2e/conformance_nullability_test.go | 44 +++--- .../openapi/e2e/conformance_params_test.go | 20 +-- .../openapi/e2e/conformance_responses_test.go | 4 +- .../openapi/e2e/conformance_scalars_test.go | 72 ++++----- .../openapi/e2e/conformance_types_test.go | 48 +++--- .../openapi/e2e/conformance_unmodeled_test.go | 126 +++++++-------- .../openapi/e2e/conformance_values_test.go | 18 +-- compilers/openapi/e2e/cycles_test.go | 6 +- compilers/openapi/e2e/danglingcheck_test.go | 4 +- compilers/openapi/e2e/helpers_test.go | 31 +--- compilers/openapi/e2e/load_test.go | 2 +- .../openapi/e2e/nullable_widening_test.go | 64 +++----- compilers/openapi/e2e/promotion_test.go | 10 +- compilers/openapi/e2e/unpreservable_test.go | 16 +- compilers/openapi/helpers_internal_test.go | 14 +- .../annotation/constraints_internal_test.go | 52 +++--- .../openapi/internal/auth/helpers_test.go | 11 -- .../internal/auth/requirements_test.go | 6 +- .../openapi/internal/auth/schemes_test.go | 6 +- .../load/remotelocal_internal_test.go | 14 +- .../merge/constraints_internal_test.go | 12 +- .../merge/fold_cases_internal_test.go | 12 +- .../merge/fold_narrowing_internal_test.go | 4 +- .../openapi/internal/openapitest/result.go | 34 ++++ .../internal/openapitest/result_test.go | 46 ++++++ .../openapi/internal/openapitest/spec.go | 25 +++ .../openapi/internal/openapitest/spec_test.go | 35 +++++ .../internal/operation/helpers_test.go | 11 -- .../operation/params_identity_test.go | 2 +- .../internal/operation/params_schema_test.go | 4 +- .../internal/schema/allof_nesting_test.go | 12 +- .../internal/schema/constraints_test.go | 14 +- .../openapi/internal/schema/nulls_test.go | 37 +---- .../internal/sourceindex/export_test.go | 16 -- .../sourceindex/measure_internal_test.go | 7 +- .../internal/sourceindex/sourceindex_test.go | 29 ++-- engine/engine_concurrency_test.go | 15 +- engine/reader/contract_test.go | 2 +- engine/reader/disk_resolve_internal_test.go | 2 +- engine/reader/disk_special_unix_test.go | 2 +- engine/reader/disk_test.go | 8 +- engine/reader/export_test.go | 17 ++ engine/reader/helpers_test.go | 10 -- engine/reader/location_internal_test.go | 11 +- ir/composition_test.go | 128 ++++++--------- ir/composition_within_test.go | 35 +++-- ir/edit_test.go | 29 ++-- ir/irfacts/components_test.go | 42 ++--- ir/irfacts/exposure_test.go | 33 ++-- ir/irfacts/helpers_test.go | 41 +---- ir/irfacts/index_test.go | 89 +++++------ ir/irfacts/index_walk_test.go | 5 +- ir/irfacts/usage_test.go | 60 +++---- ir/irtest/diagnostics.go | 12 ++ ir/irtest/diagnostics_test.go | 19 +++ ir/irtest/encode.go | 37 +++++ ir/irtest/encode_test.go | 56 +++++++ ir/irtest/export_test.go | 3 + ir/irtest/golden.go | 6 +- ir/irtest/nesting.go | 10 ++ ir/irtest/nesting_test.go | 14 ++ ir/irtest/typedefs.go | 39 +++++ ir/irtest/typedefs_test.go | 50 ++++++ ir/irverify/cycles_test.go | 20 +-- ir/irverify/document_test.go | 8 +- ir/irverify/helpers_test.go | 27 ++++ ir/irverify/ids_test.go | 23 --- ir/irverify/irverify_walkdepth_test.go | 13 +- ir/irverify/kinds_test.go | 4 +- ir/irverify/maps_test.go | 15 +- ir/irverify/typerefs_test.go | 2 +- ir/irverify/unions_test.go | 12 +- ir/walk_cycle_test.go | 15 +- ir/walk_test.go | 15 +- pass/validate_discriminators_test.go | 5 +- pass/validate_graphql_test.go | 13 +- pass/validate_groups_test.go | 7 +- pass/validate_messages_test.go | 5 +- pass/validate_niltypes_test.go | 5 +- pass/validate_params_test.go | 9 +- pass/validate_payloads_test.go | 3 +- pass/validate_propids_test.go | 17 +- pass/validate_refs_test.go | 8 +- pass/validate_responses_test.go | 3 +- pass/validate_test.go | 11 +- pass/validate_wirenames_test.go | 5 +- 95 files changed, 1122 insertions(+), 1040 deletions(-) create mode 100644 ir/irtest/diagnostics.go create mode 100644 ir/irtest/diagnostics_test.go create mode 100644 ir/irtest/encode.go create mode 100644 ir/irtest/encode_test.go create mode 100644 ir/irtest/typedefs.go create mode 100644 ir/irtest/typedefs_test.go create mode 100644 ir/irverify/helpers_test.go diff --git a/compilers/openapi/budgets_internal_test.go b/compilers/openapi/budgets_internal_test.go index 26444fee..ff57a8a3 100644 --- a/compilers/openapi/budgets_internal_test.go +++ b/compilers/openapi/budgets_internal_test.go @@ -93,7 +93,7 @@ func TestCompile_EnumBudgetBindsOnlyPastTheLimit(t *testing.T) { assert.Len(t, enum.Members, tc.members) return } - assertHasErrorCode(t, diags, diag.BudgetExceeded) + openapitest.AssertHasCode(t, diags, diag.BudgetExceeded, ir.SeverityError) assert.Equal(t, "enum declares 5 members, past the 4-member budget; lowered as any", messageOf(t, diags, diag.BudgetExceeded), "the refusal names what was observed and what the budget was") @@ -124,7 +124,7 @@ func TestCompile_SourceByteBudgetBindsOnlyPastTheLimit(t *testing.T) { return } assert.Nil(t, doc, "an over-budget source is refused before it is parsed") - assertHasErrorCode(t, diags, diag.BudgetExceeded) + openapitest.AssertHasCode(t, diags, diag.BudgetExceeded, ir.SeverityError) assert.Equal(t, fmt.Sprintf("source document is %d bytes, past the %d-byte budget", len(src), tc.limit), messageOf(t, diags, diag.BudgetExceeded)) @@ -137,7 +137,7 @@ func TestCompile_SourceNodeBudgetRefusesADocumentPastIt(t *testing.T) { doc, diags := compileBounded(t, enumSpec(3), Limits{MaxSourceNodes: 1}) assert.Nil(t, doc, "an over-budget node count is refused before the model is built") - assertHasErrorCode(t, diags, diag.BudgetExceeded) + openapitest.AssertHasCode(t, diags, diag.BudgetExceeded, ir.SeverityError) assert.Contains(t, messageOf(t, diags, diag.BudgetExceeded), "past the 1-node budget") } @@ -204,7 +204,7 @@ func TestCompile_AliasBudgetBindsAtItsDefault(t *testing.T) { doc, diags := compileBounded(t, aliasFanOutSpec(20_000), Limits{}) assert.Nil(t, doc, "an over-budget expansion is refused before the model is built") - assertHasErrorCode(t, diags, diag.BudgetExceeded) + openapitest.AssertHasCode(t, diags, diag.BudgetExceeded, ir.SeverityError) assert.Contains(t, messageOf(t, diags, diag.BudgetExceeded), fmt.Sprintf("past the %d-node alias budget", DefaultMaxAliasSurplus)) } @@ -215,7 +215,7 @@ func TestCompile_AliasBudgetIsTheCallersToSet(t *testing.T) { doc, diags := compileBounded(t, src, Limits{MaxAliasSurplus: 1}) assert.Nil(t, doc) - assertHasErrorCode(t, diags, diag.BudgetExceeded) + openapitest.AssertHasCode(t, diags, diag.BudgetExceeded, ir.SeverityError) assert.Contains(t, messageOf(t, diags, diag.BudgetExceeded), "past the 1-node alias budget") doc, diags = compileBounded(t, src, Limits{MaxAliasSurplus: -1}) @@ -233,7 +233,7 @@ func TestCompile_AnUnboundedAliasBudgetStillRefusesABomb(t *testing.T) { doc, diags := compileBounded(t, string(data), Limits{MaxAliasSurplus: -1}) assert.Nil(t, doc) - assertHasErrorCode(t, diags, diag.AliasAmplification) + openapitest.AssertHasCode(t, diags, diag.AliasAmplification, ir.SeverityError) assert.NotContains(t, messageOf(t, diags, diag.AliasAmplification), "budget", "the refusal must not name a budget, since raising one would not admit the document") } diff --git a/compilers/openapi/e2e/allof_visibility_test.go b/compilers/openapi/e2e/allof_visibility_test.go index 9232fb4a..b635eebd 100644 --- a/compilers/openapi/e2e/allof_visibility_test.go +++ b/compilers/openapi/e2e/allof_visibility_test.go @@ -93,7 +93,7 @@ var wantReadOnlyVisibility = ir.Visibility{ func thingIDVisibility(t *testing.T, name, spec string) (ir.Visibility, []ir.Diagnostic) { t.Helper() doc, diags := compileAnnotationSpec(t, name, spec) - m, ok := doc.Types[namedID("Thing")].(*ir.Model) + m, ok := doc.Types[componentID("Thing")].(*ir.Model) require.True(t, ok, "Thing must own a Model node") p, ok := propByWire(m, "id") require.True(t, ok, "Thing must carry a merged \"id\" property") diff --git a/compilers/openapi/e2e/amplification_test.go b/compilers/openapi/e2e/amplification_test.go index f4b19b48..09bf2230 100644 --- a/compilers/openapi/e2e/amplification_test.go +++ b/compilers/openapi/e2e/amplification_test.go @@ -13,6 +13,7 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi" "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" ) @@ -38,7 +39,7 @@ func TestCompile_AliasBombDoesNotExhaustMemory(t *testing.T) { case r := <-done: require.NoError(t, r.err, "an alias bomb is a spec problem, not a Go error") assert.Nil(t, r.doc, "the compiler refuses to lower an amplifying document") - assertHasErrorCode(t, r.diags, diag.AliasAmplification) + openapitest.AssertHasCode(t, r.diags, diag.AliasAmplification, ir.SeverityError) case <-time.After(bound): t.Fatalf("Compile did not return within %v — the bomb likely reached soa.Unmarshal", bound) } diff --git a/compilers/openapi/e2e/annotations_carrier_test.go b/compilers/openapi/e2e/annotations_carrier_test.go index 731e6b61..183b15d8 100644 --- a/compilers/openapi/e2e/annotations_carrier_test.go +++ b/compilers/openapi/e2e/annotations_carrier_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/internal/harness" "github.com/dexpace/morphic/ir" ) @@ -52,7 +53,7 @@ paths: // carriedProperty returns the single property carrierPropertySpec declares. func carriedProperty(t *testing.T, doc *ir.Document) ir.Property { t.Helper() - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok, "component S must lower to a model") p, ok := propByWire(m, "f") require.True(t, ok, "the model must carry property f") @@ -64,7 +65,7 @@ func carriedParameter(t *testing.T, doc *ir.Document) ir.Parameter { t.Helper() op, ok := opByName(doc, "op") require.True(t, ok, "the operation must lower") - p, ok := paramByName(op, "q") + p, ok := openapitest.ParamBySource(op, "q") require.True(t, ok, "the operation must carry parameter q") return p } diff --git a/compilers/openapi/e2e/annotations_test.go b/compilers/openapi/e2e/annotations_test.go index 2e752518..e7644953 100644 --- a/compilers/openapi/e2e/annotations_test.go +++ b/compilers/openapi/e2e/annotations_test.go @@ -137,7 +137,7 @@ components: example: {f: at-declaration} `, assert: func(t *testing.T, doc *ir.Document) { - td, ok := doc.Types[namedID("S")] + td, ok := doc.Types[componentID("S")] require.True(t, ok, "component S must own a node") require.Len(t, td.Common().Examples, 1) ex := td.Common().Examples[0] @@ -161,7 +161,7 @@ components: S: {type: string, example: at-declaration} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") require.Len(t, sc.Examples, 1) require.NotNil(t, sc.Examples[0].Value) @@ -190,7 +190,7 @@ components: example: at-reference `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) @@ -201,7 +201,7 @@ components: assert.Equal(t, "at-reference", p.Examples[0].Value.Str) // ...and must not have leaked onto the referent. - target, ok := doc.Types[namedID("Target")] + target, ok := doc.Types[componentID("Target")] require.True(t, ok) assert.Empty(t, target.Common().Examples, "a reference-site example must not attach to the referent") @@ -230,7 +230,7 @@ components: minProperties: 1 `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok, "S owns a Model node") require.NotNil(t, m.Constraints, "an object component's own minProperties lands in Model.Constraints") require.NotNil(t, m.Constraints.MinProps) @@ -250,7 +250,7 @@ components: S: {type: integer, minimum: 5} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a constrained scalar component owns a Scalar node") require.NotNil(t, sc.Constraints) require.NotNil(t, sc.Constraints.Min) @@ -280,7 +280,7 @@ components: minimum: 5 `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) @@ -288,7 +288,7 @@ components: require.NotNil(t, p.Constraints.Min) assert.Equal(t, "5", p.Constraints.Min.String()) - target, ok := doc.Types[namedID("Target")].(*ir.Scalar) + target, ok := doc.Types[componentID("Target")].(*ir.Scalar) require.True(t, ok) assert.Nil(t, target.Constraints, "a reference-site bound must not attach to the referent") }, @@ -316,7 +316,7 @@ components: description: at-declaration `, assert: func(t *testing.T, doc *ir.Document) { - td, ok := doc.Types[namedID("S")] + td, ok := doc.Types[componentID("S")] require.True(t, ok) assert.Equal(t, "at-declaration", td.Common().Docs.Description) }, @@ -334,7 +334,7 @@ components: S: {type: string, description: at-declaration} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") assert.Equal(t, "at-declaration", sc.Docs.Description) @@ -361,14 +361,14 @@ components: description: at-reference `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) assert.Equal(t, "at-reference", p.Docs.Description, "a description beside a $ref binds the reference site") - target, ok := doc.Types[namedID("Target")] + target, ok := doc.Types[componentID("Target")] require.True(t, ok) assert.Empty(t, target.Common().Docs.Description, "a reference-site description must not attach to the referent") @@ -397,7 +397,7 @@ components: deprecated: true `, assert: func(t *testing.T, doc *ir.Document) { - td, ok := doc.Types[namedID("S")] + td, ok := doc.Types[componentID("S")] require.True(t, ok) assert.NotNil(t, td.Common().Deprecation) }, @@ -415,7 +415,7 @@ components: S: {type: string, deprecated: true} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") assert.NotNil(t, sc.Deprecation) @@ -442,13 +442,13 @@ components: deprecated: true `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) assert.NotNil(t, p.Deprecation, "deprecated beside a $ref binds the reference site") - target, ok := doc.Types[namedID("Target")] + target, ok := doc.Types[componentID("Target")] require.True(t, ok) assert.Nil(t, target.Common().Deprecation, "a reference-site deprecation must not attach to the referent") @@ -477,7 +477,7 @@ components: default: {f: x} `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok, "S owns a Model node") assert.NotContains(t, marshalToMap(t, m), "default", "a default binds a use of the type, so it gets no field on the type node") @@ -500,7 +500,7 @@ components: S: {type: integer, default: 7} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") assert.NotContains(t, marshalToMap(t, sc), "default", "a default binds a use of the type, so it gets no field on the type node") @@ -532,14 +532,14 @@ components: default: 7 `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) require.NotNil(t, p.Default, "a default beside a $ref binds the reference site") assert.Equal(t, "7", p.Default.Num.String()) - target, ok := doc.Types[namedID("Target")].(*ir.Scalar) + target, ok := doc.Types[componentID("Target")].(*ir.Scalar) require.True(t, ok) assert.NotContains(t, marshalToMap(t, target), "default", "a reference-site default must not attach to the referent") @@ -568,7 +568,7 @@ components: readOnly: true `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok, "S owns a Model node") assert.NotContains(t, marshalToMap(t, m), "visibility", "readOnly binds a use of the type, so it gets no field on the type node") @@ -591,7 +591,7 @@ components: S: {type: string, readOnly: true} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") assert.NotContains(t, marshalToMap(t, sc), "visibility", "readOnly binds a use of the type, so it gets no field on the type node") @@ -623,7 +623,7 @@ components: readOnly: true `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) @@ -631,7 +631,7 @@ components: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleDelete, ir.LifecycleQuery}, p.Visibility.Only) - target, ok := doc.Types[namedID("Target")].(*ir.Scalar) + target, ok := doc.Types[componentID("Target")].(*ir.Scalar) require.True(t, ok) assert.NotContains(t, marshalToMap(t, target), "visibility", "a reference-site readOnly must not attach to the referent") @@ -660,7 +660,7 @@ components: x-vendor: at-declaration `, assert: func(t *testing.T, doc *ir.Document) { - td, ok := doc.Types[namedID("S")] + td, ok := doc.Types[componentID("S")] require.True(t, ok) raw, ok := td.Common().Unmodeled["openapi:x-vendor"] require.True(t, ok, "x-vendor must be preserved under the openapi: namespace") @@ -681,7 +681,7 @@ components: S: {type: string, x-vendor: at-declaration} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") raw, ok := sc.Unmodeled["openapi:x-vendor"] require.True(t, ok, "x-vendor must be preserved under the openapi: namespace") @@ -711,7 +711,7 @@ components: x-vendor: at-reference `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) @@ -720,7 +720,7 @@ components: assert.JSONEq(t, `"at-reference"`, string(raw.Value)) assert.Equal(t, ir.ReasonVendorExtension, raw.Reason) - target, ok := doc.Types[namedID("Target")] + target, ok := doc.Types[componentID("Target")] require.True(t, ok) assert.Empty(t, target.Common().Unmodeled, "a reference-site extension must not attach to the referent") @@ -749,7 +749,7 @@ components: xml: {name: Renamed} `, assert: func(t *testing.T, doc *ir.Document) { - td, ok := doc.Types[namedID("S")] + td, ok := doc.Types[componentID("S")] require.True(t, ok) require.NotNil(t, td.Common().XML) assert.Equal(t, "Renamed", td.Common().XML.Name) @@ -768,7 +768,7 @@ components: S: {type: string, xml: {name: Renamed}} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") require.NotNil(t, sc.XML) assert.Equal(t, "Renamed", sc.XML.Name) @@ -796,14 +796,14 @@ components: xml: {name: Renamed} `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) require.NotNil(t, p.XML, "an xml hint beside a $ref binds the reference site") assert.Equal(t, "Renamed", p.XML.Name) - target, ok := doc.Types[namedID("Target")] + target, ok := doc.Types[componentID("Target")] require.True(t, ok) assert.Nil(t, target.Common().XML, "a reference-site xml hint must not attach to the referent") }, @@ -841,7 +841,7 @@ components: then: {minLength: 1} `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) raw, ok := m.Unmodeled["openapi:if-then-else"] require.True(t, ok, "if/then must be kept verbatim under Unmodeled") @@ -869,7 +869,7 @@ components: then: {minLength: 1} `, assert: func(t *testing.T, doc *ir.Document) { - sc, ok := doc.Types[namedID("S")].(*ir.Scalar) + sc, ok := doc.Types[componentID("S")].(*ir.Scalar) require.True(t, ok, "a bare scalar component owns a Scalar node") raw, ok := sc.Unmodeled["openapi:if-then-else"] require.True(t, ok, "if/then must be kept verbatim under Unmodeled") @@ -904,7 +904,7 @@ components: then: {minLength: 1} `, assert: func(t *testing.T, doc *ir.Document) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) p, ok := propByWire(m, "f") require.True(t, ok) @@ -913,7 +913,7 @@ components: assert.JSONEq(t, `{"if":{"type":"string"},"then":{"minLength":1}}`, string(raw.Value)) assert.Equal(t, ir.ReasonValidationOnly, raw.Reason) - target, ok := doc.Types[namedID("Target")] + target, ok := doc.Types[componentID("Target")] require.True(t, ok) assert.Empty(t, target.Common().Unmodeled, "a reference-site keyword must not attach to the referent") @@ -994,7 +994,7 @@ func TestValidationOnly_BesideARefAtABodyPosition(t *testing.T) { assert.Equal(t, ir.ReasonValidationOnly, raw.Reason, tc.name) } - target, found := doc.Types[namedID("Target")] + target, found := doc.Types[componentID("Target")] require.True(t, found) assert.Empty(t, target.Common().Unmodeled, "three reference sites, and none of them wrote onto the referent") @@ -1018,7 +1018,7 @@ func refSiteAlias(t *testing.T, doc *ir.Document, c ir.Content) ir.TypeDef { sc, ok := td.(*ir.Scalar) require.True(t, ok, "a $ref with siblings hoists an alias; got %T", td) require.NotNil(t, sc.Base, "and that alias points at what it referenced") - assert.Equal(t, namedID("Target"), sc.Base.Target) + assert.Equal(t, componentID("Target"), sc.Base.Target) return td } @@ -1122,7 +1122,7 @@ func TestAnnotationRetention_OtherDeclarationShapes(t *testing.T) { t.Parallel() doc, diags := compileAnnotationSpec(t, shape.name, shape.spec()) assertNoErrorDiags(t, diags) - td, ok := doc.Types[namedID("S")] + td, ok := doc.Types[componentID("S")] require.True(t, ok, "component S must own a node") require.Equal(t, shape.kind, td.Kind(), "this shape must reach its own lower() destination") assertDeclaredAnnotationsKept(t, td) diff --git a/compilers/openapi/e2e/conformance_composition_test.go b/compilers/openapi/e2e/conformance_composition_test.go index a319a18d..25637d9f 100644 --- a/compilers/openapi/e2e/conformance_composition_test.go +++ b/compilers/openapi/e2e/conformance_composition_test.go @@ -14,10 +14,10 @@ import ( ) func assertAllOfInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - d, ok := doc.Types[namedID("Derived")].(*ir.Model) + d, ok := doc.Types[componentID("Derived")].(*ir.Model) require.True(t, ok) require.NotNil(t, d.Base, "sole allOf $ref becomes Base") - assert.Equal(t, namedID("Base"), d.Base.Target) + assert.Equal(t, componentID("Base"), d.Base.Target) assert.Empty(t, d.Mixins) _, ok = propByWire(d, "extra") assert.True(t, ok, "property declared alongside allOf survives") @@ -31,19 +31,19 @@ func assertAllOfInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { } func assertAllOfMixins(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - c, ok := doc.Types[namedID("C")].(*ir.Model) + c, ok := doc.Types[componentID("C")].(*ir.Model) require.True(t, ok) assert.Nil(t, c.Base, "multiple non-hierarchy refs stay Mixins, none is Base") require.Len(t, c.Mixins, 2) - assert.Equal(t, namedID("A"), c.Mixins[0].Target) - assert.Equal(t, namedID("B"), c.Mixins[1].Target) + assert.Equal(t, componentID("A"), c.Mixins[0].Target) + assert.Equal(t, componentID("B"), c.Mixins[1].Target) } func assertAllOfInlineMerge(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("Merged")].(*ir.Model) + m, ok := doc.Types[componentID("Merged")].(*ir.Model) require.True(t, ok) require.NotNil(t, m.Base) - assert.Equal(t, namedID("Base"), m.Base.Target) + assert.Equal(t, componentID("Base"), m.Base.Target) _, ok = propByWire(m, "name") assert.True(t, ok, "inline allOf branch contributes its properties") } @@ -54,24 +54,24 @@ func assertAllOfInlineMerge(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // nothing else is kept nowhere; one that also writes a keyword is kept whole, // with one diagnostic. func assertAllOfNestedInline(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("M")].(*ir.Model) + m, ok := doc.Types[componentID("M")].(*ir.Model) require.True(t, ok) assert.Nil(t, m.Base, "two refs and no hierarchy: none is Base") require.Len(t, m.Mixins, 2) - assert.Equal(t, namedID("A"), m.Mixins[0].Target) - assert.Equal(t, namedID("B"), m.Mixins[1].Target) + assert.Equal(t, componentID("A"), m.Mixins[0].Target) + assert.Equal(t, componentID("B"), m.Mixins[1].Target) assert.Empty(t, m.Unmodeled, "the wrapper is flattened, not kept verbatim") - n, ok := doc.Types[namedID("Nested2")].(*ir.Model) + n, ok := doc.Types[componentID("Nested2")].(*ir.Model) require.True(t, ok) require.NotNil(t, n.Base, "the sole ref two wrappers down is the Base") - assert.Equal(t, namedID("A"), n.Base.Target) + assert.Equal(t, componentID("A"), n.Base.Target) c, ok := propByWire(n, "c") require.True(t, ok) assert.Equal(t, jsontext.Pointer("/components/schemas/Nested2/allOf/0/allOf/1/properties/c"), c.Provenance.Pointer) - mixed, ok := doc.Types[namedID("Mixed")].(*ir.Model) + mixed, ok := doc.Types[componentID("Mixed")].(*ir.Model) require.True(t, ok) mc, ok := propByWire(mixed, "c") require.True(t, ok) @@ -82,18 +82,18 @@ func assertAllOfNestedInline(t *testing.T, doc *ir.Document, diags []ir.Diagnost } assert.Equal(t, []string{"c", "w"}, wires, "the wrapper's own property merges after its branches'") - closed, ok := doc.Types[namedID("Closed")].(*ir.Model) + closed, ok := doc.Types[componentID("Closed")].(*ir.Model) require.True(t, ok) assert.Equal(t, ir.AdditionalClosed, closed.Additional, "a false inside a wrapper still closes") assert.Contains(t, closed.Unmodeled, "openapi:allOf/0/allOf/0") assert.NotContains(t, closed.Unmodeled, "openapi:allOf/0") - described, ok := doc.Types[namedID("Described")].(*ir.Model) + described, ok := doc.Types[componentID("Described")].(*ir.Model) require.True(t, ok) assert.Nil(t, described.Base) require.Len(t, described.Mixins, 2, "the wrapper's refs compose despite its description") - assert.Equal(t, namedID("A"), described.Mixins[0].Target) - assert.Equal(t, namedID("B"), described.Mixins[1].Target) + assert.Equal(t, componentID("A"), described.Mixins[0].Target) + assert.Equal(t, componentID("B"), described.Mixins[1].Target) assert.Contains(t, described.Unmodeled, "openapi:allOf/0", "the wrapper is kept whole") for _, id := range []ir.TypeID{"t/composed/components/schemas/U/oneOf/0", "t/composed/components/schemas/U/oneOf/1"} { @@ -115,7 +115,7 @@ func assertAllOfNestedInline(t *testing.T, doc *ir.Document, diags []ir.Diagnost } func assertAllOfRequiredOnly(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - d, ok := doc.Types[namedID("Derived")].(*ir.Model) + d, ok := doc.Types[componentID("Derived")].(*ir.Model) require.True(t, ok) require.NotNil(t, d.Base, "the $ref branch still becomes Base") name, ok := propByWire(d, "name") @@ -132,27 +132,27 @@ func assertAllOfRequiredOnly(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) // stays a Model rather than becoming a map since a map requires no key, and the // object variant of a multi-typed schema. func assertRequiredMembers(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - plain, ok := doc.Types[namedID("Plain")].(*ir.Model) + plain, ok := doc.Types[componentID("Plain")].(*ir.Model) require.True(t, ok) id, ok := propByWire(plain, "id") require.True(t, ok) assert.True(t, id.Required, "a declared name is Required on its property") assert.Equal(t, []string{"ghost"}, plain.Requires, "an undeclared one is listed, and the declared one is not") - created, ok := doc.Types[namedID("CreatedUser")].(*ir.Model) + created, ok := doc.Types[componentID("CreatedUser")].(*ir.Model) require.True(t, ok) require.NotNil(t, created.Base) - assert.Equal(t, namedID("User"), created.Base.Target) + assert.Equal(t, componentID("User"), created.Base.Target) assert.Empty(t, created.Properties, "the base declares id, so the composed model has no property for it") assert.Equal(t, []string{"id"}, created.Requires, "the inherited property it makes required") - user, ok := doc.Types[namedID("User")].(*ir.Model) + user, ok := doc.Types[componentID("User")].(*ir.Model) require.True(t, ok) assert.Empty(t, user.Requires, "the base is not made to require what only its subtype does") userID, ok := propByWire(user, "id") require.True(t, ok) assert.False(t, userID.Required, "and its own property stays optional") - tagged, ok := doc.Types[namedID("Tagged")].(*ir.Model) + tagged, ok := doc.Types[componentID("Tagged")].(*ir.Model) require.True(t, ok, "a required name beside a catch-all keeps the object a Model, not a map") assert.NotNil(t, tagged.AdditionalProps) assert.Empty(t, tagged.Properties) @@ -160,7 +160,7 @@ func assertRequiredMembers(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { _, isMap := ir.AsMap(tagged) assert.False(t, isMap, "AsMap keeps a model that requires a member a Model") - either, ok := doc.Types[namedID("Either")].(*ir.Union) + either, ok := doc.Types[componentID("Either")].(*ir.Union) require.True(t, ok) var object *ir.Model for _, v := range either.Variants { @@ -185,28 +185,28 @@ func assertRequiredMembers(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // the third thing: a composed variant is Morphic's own node, so it cannot be // taken by, or take from, a $ref. func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - combo, ok := doc.Types[namedID("Combo")].(*ir.Union) + combo, ok := doc.Types[componentID("Combo")].(*ir.Union) require.True(t, ok, "the union is the value, not a preserved sibling") require.Len(t, combo.Variants, 2) for i, branch := range []string{"A", "B"} { v, ok := doc.Types[combo.Variants[i].Type.Target].(*ir.Model) require.True(t, ok, "variant %d composes as a model", i) require.NotNil(t, v.Base, "the allOf composition rides on every variant") - assert.Equal(t, namedID("Base"), v.Base.Target) + assert.Equal(t, componentID("Base"), v.Base.Target) require.Len(t, v.Mixins, 1) - assert.Equal(t, namedID(branch), v.Mixins[0].Target) + assert.Equal(t, componentID(branch), v.Mixins[0].Target) require.NotNil(t, v.Constraints, "the enclosing schema's own minProperties rides on every variant too (GitHub #407)") require.NotNil(t, v.Constraints.MinProps) assert.Equal(t, int64(2), *v.Constraints.MinProps) } - mixed, ok := doc.Types[namedID("MixedKinds")].(*ir.Model) + mixed, ok := doc.Types[componentID("MixedKinds")].(*ir.Model) require.True(t, ok, "the structural body survives") entry, ok := mixed.Unmodeled["openapi:oneOf"] require.True(t, ok, "and the union it could not absorb survives beside it") assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - bounded, ok := doc.Types[namedID("BoundedKinds")].(*ir.Scalar) + bounded, ok := doc.Types[componentID("BoundedKinds")].(*ir.Scalar) require.True(t, ok, "a body that is not a model reduces to a shared primitive and hoists an alias") entry, ok = bounded.Unmodeled["openapi:oneOf"] require.True(t, ok, "which is the node the kept union sits on") @@ -215,13 +215,13 @@ func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnos require.NotNil(t, bounded.Constraints.MinLength) assert.Equal(t, int64(3), *bounded.Constraints.MinLength) - outsider, ok := doc.Types[namedID("Outsider")].(*ir.Model) + outsider, ok := doc.Types[componentID("Outsider")].(*ir.Model) require.True(t, ok) require.Len(t, outsider.Properties, 1) branchNode, ok := doc.Types[outsider.Properties[0].Type.Target].(*ir.Scalar) require.True(t, ok, "the branch pointer hoists an alias of what the branch references") require.NotNil(t, branchNode.Base) - assert.Equal(t, namedID("A"), branchNode.Base.Target, + assert.Equal(t, componentID("A"), branchNode.Base.Target, "the reference gets the branch schema, never the variant composed for that branch") assertInlineBranchHint(t, doc) @@ -239,7 +239,7 @@ func assertInlineBranchHint(t *testing.T, doc *ir.Document) { t.Helper() const branchID = ir.TypeID("t/anon/components/schemas/InlineHost/oneOf/0") - host, ok := doc.Types[namedID("InlineHost")].(*ir.Union) + host, ok := doc.Types[componentID("InlineHost")].(*ir.Union) require.True(t, ok, "the inline union is the value") require.Len(t, host.Variants, 2) assert.Equal(t, branchID, host.Variants[0].Type.Target, @@ -250,7 +250,7 @@ func assertInlineBranchHint(t *testing.T, doc *ir.Document) { assert.Equal(t, "variant_0", branch.Common().Name.Hint, "named by position, which is all an inline branch has to be named by") - outsider, ok := doc.Types[namedID("InlineOutsider")].(*ir.Model) + outsider, ok := doc.Types[componentID("InlineOutsider")].(*ir.Model) require.True(t, ok) require.Len(t, outsider.Properties, 1) assert.Equal(t, branchID, outsider.Properties[0].Type.Target, @@ -263,16 +263,16 @@ func assertInlineBranchHint(t *testing.T, doc *ir.Document) { // diagnostic that claims the node has no home for it — the node demonstrably // does, since ViaModel's sibling proves the field exists and is read. func assertAllOfPositionConstraints(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - viaModel, ok := doc.Types[namedID("ViaModel")].(*ir.Model) + viaModel, ok := doc.Types[componentID("ViaModel")].(*ir.Model) require.True(t, ok) require.NotNil(t, viaModel.Constraints) require.NotNil(t, viaModel.Constraints.MinProps) assert.Equal(t, int64(2), *viaModel.Constraints.MinProps) - viaAllOf, ok := doc.Types[namedID("ViaAllOf")].(*ir.Model) + viaAllOf, ok := doc.Types[componentID("ViaAllOf")].(*ir.Model) require.True(t, ok) require.NotNil(t, viaAllOf.Base, "the sole allOf $ref still becomes Base") - assert.Equal(t, namedID("Base"), viaAllOf.Base.Target) + assert.Equal(t, componentID("Base"), viaAllOf.Base.Target) require.NotNil(t, viaAllOf.Constraints, "the composing position's own minProperties reaches the model it built, exactly as a plain object's does") require.NotNil(t, viaAllOf.Constraints.MinProps) assert.Equal(t, int64(2), *viaAllOf.Constraints.MinProps) @@ -283,30 +283,30 @@ func assertAllOfPositionConstraints(t *testing.T, doc *ir.Document, diags []ir.D } func assertOneOfDiscriminated(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Union) + pet, ok := doc.Types[componentID("Pet")].(*ir.Union) require.True(t, ok, "oneOf survives as a Union node, never collapsed") assert.True(t, pet.Exclusive) assert.True(t, pet.WireTagged, "the discriminator's tag names the variant") require.Len(t, pet.Variants, 2) require.NotNil(t, pet.Discriminator) assert.Equal(t, "petType", pet.Discriminator.PropertyName) - assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"]) - assert.Equal(t, namedID("Dog"), pet.Discriminator.Mapping["dog"]) + assert.Equal(t, componentID("Cat"), pet.Discriminator.Mapping["cat"]) + assert.Equal(t, componentID("Dog"), pet.Discriminator.Mapping["dog"]) } // assertAnyOfDiscriminated pins that the tag mode and Exclusive are independent // (ir-design §4.4): a discriminator on an anyOf tags the union without making // it exclusive, since a payload may still satisfy both branches. func assertAnyOfDiscriminated(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - u, ok := doc.Types[namedID("Resource")].(*ir.Union) + u, ok := doc.Types[componentID("Resource")].(*ir.Union) require.True(t, ok, "anyOf survives as a Union node, never collapsed") assert.False(t, u.Exclusive, "anyOf admits a payload both branches satisfy") assert.True(t, u.WireTagged, "the discriminator's tag names the variant") require.Len(t, u.Variants, 2) require.NotNil(t, u.Discriminator) assert.Equal(t, "kind", u.Discriminator.PropertyName) - assert.Equal(t, namedID("Named"), u.Discriminator.Mapping["named"]) - assert.Equal(t, namedID("Labelled"), u.Discriminator.Mapping["labelled"]) + assert.Equal(t, componentID("Named"), u.Discriminator.Mapping["named"]) + assert.Equal(t, componentID("Labelled"), u.Discriminator.Mapping["labelled"]) } // assertDiscriminatorInheritance covers the model-hierarchy form of a @@ -318,7 +318,7 @@ func assertAnyOfDiscriminated(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) // places: a subtype the base's mapping names takes the mapping key, and one the // mapping omits takes OpenAPI's implicit schema name. func assertDiscriminatorInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) require.True(t, ok, "the base is a Model, not a Union") require.NotNil(t, pet.Discriminator) tag, ok := propByWire(pet, "petType") @@ -327,13 +327,13 @@ func assertDiscriminatorInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagn "a declared tag property binds by identity, not by wire name") assert.Empty(t, pet.Discriminator.PropertyName, "the name spelling is for a tag no property declares") - assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"]) + assert.Equal(t, componentID("Cat"), pet.Discriminator.Mapping["cat"]) for name, value := range map[string]string{"Cat": "cat", "Dog": "Dog"} { - sub, ok := doc.Types[namedID(name)].(*ir.Model) + sub, ok := doc.Types[componentID(name)].(*ir.Model) require.True(t, ok, "%s composes as a Model", name) require.NotNil(t, sub.Base, "the discriminated base becomes Base, not a Mixin") - assert.Equal(t, namedID("Pet"), sub.Base.Target) + assert.Equal(t, componentID("Pet"), sub.Base.Target) assert.Equal(t, value, sub.DiscriminatorValue) assert.Nil(t, sub.Discriminator, "a subtype does not restate its base's discriminator") } @@ -346,7 +346,7 @@ func assertDiscriminatorInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagn // discriminated base. Kennel's Dog shares its key with the component Dog on // purpose: a tag read off the pointer gives both the same value. func assertDiscriminatorInlineSubtype(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) require.True(t, ok, "the base is a Model") require.NotNil(t, pet.Discriminator) assert.Empty(t, pet.Discriminator.Mapping, "every tag in this document is implicit") @@ -355,7 +355,7 @@ func assertDiscriminatorInlineSubtype(t *testing.T, doc *ir.Document, _ []ir.Dia id ir.TypeID value string }{ - {namedID("Dog"), "Dog"}, + {componentID("Dog"), "Dog"}, {"t/anon/components/schemas/Kennel/properties/Dog", ""}, {"t/anon/components/schemas/Kennel/properties/pet", ""}, {"t/anon/components/schemas/Either/oneOf/0", ""}, @@ -363,7 +363,7 @@ func assertDiscriminatorInlineSubtype(t *testing.T, doc *ir.Document, _ []ir.Dia sub, ok := doc.Types[tc.id].(*ir.Model) require.True(t, ok, "%s composes as a Model", tc.id) require.NotNil(t, sub.Base, "%s composes the discriminated base", tc.id) - assert.Equal(t, namedID("Pet"), sub.Base.Target) + assert.Equal(t, componentID("Pet"), sub.Base.Target) assert.Equal(t, tc.value, sub.DiscriminatorValue, "%s: only a component schema has a name to answer to", tc.id) } @@ -378,11 +378,11 @@ func assertDiscriminatorInlineSubtype(t *testing.T, doc *ir.Document, _ []ir.Dia // is named by the mapping, routing a wire value straight onto a grandchild; // Whelp is named by nothing and answers to its own schema name three hops down. func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) require.True(t, ok, "the root of the hierarchy is a Model") require.NotNil(t, pet.Discriminator) - assert.Equal(t, namedID("Dog"), pet.Discriminator.Mapping["dog"]) - assert.Equal(t, namedID("Puppy"), pet.Discriminator.Mapping["puppy"], + assert.Equal(t, componentID("Dog"), pet.Discriminator.Mapping["dog"]) + assert.Equal(t, componentID("Puppy"), pet.Discriminator.Mapping["puppy"], "the base maps a wire value straight onto its grandchild") // base is the schema each subtype's own allOf branch names, which is the only @@ -392,10 +392,10 @@ func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagno {"Puppy", "Dog", "puppy"}, {"Whelp", "Puppy", "Whelp"}, } { - sub, ok := doc.Types[namedID(tc.name)].(*ir.Model) + sub, ok := doc.Types[componentID(tc.name)].(*ir.Model) require.True(t, ok, "%s composes as a Model", tc.name) require.NotNil(t, sub.Base, "%s composes a base", tc.name) - assert.Equal(t, namedID(tc.base), sub.Base.Target, + assert.Equal(t, componentID(tc.base), sub.Base.Target, "%s composes %s, not the schema anchoring the hierarchy", tc.name, tc.base) assert.Equal(t, tc.value, sub.DiscriminatorValue, "%s answers to the tag its ancestor spells for it", tc.name) @@ -403,7 +403,7 @@ func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagno } for _, name := range []string{"Shrub", "Sapling"} { - sub, ok := doc.Types[namedID(name)].(*ir.Model) + sub, ok := doc.Types[componentID(name)].(*ir.Model) require.True(t, ok, "%s composes as a Model", name) require.NotNil(t, sub.Base, "%s composes a base", name) assert.Empty(t, sub.DiscriminatorValue, @@ -418,13 +418,13 @@ func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagno // mapping keeps both, and the election is reported at the subtype as // information since nothing is lost. func assertDiscriminatorAliasMapping(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) require.True(t, ok, "the base is a Model") require.NotNil(t, pet.Discriminator) - assert.Equal(t, map[string]ir.TypeID{"alpha": namedID("Dog"), "zulu": namedID("Dog")}, + assert.Equal(t, map[string]ir.TypeID{"alpha": componentID("Dog"), "zulu": componentID("Dog")}, pet.Discriminator.Mapping, "the base keeps every key") - dog, ok := doc.Types[namedID("Dog")].(*ir.Model) + dog, ok := doc.Types[componentID("Dog")].(*ir.Model) require.True(t, ok, "Dog composes as a Model") assert.Equal(t, "alpha", dog.DiscriminatorValue, "the smallest key in byte order, not the first written") @@ -442,12 +442,12 @@ func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []i "/components/schemas/Pet/discriminator/defaultMapping"), "a 3.2 keyword in a 3.2 document is not an invalid schema") - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) require.True(t, ok) require.NotNil(t, pet.Discriminator) - assert.Equal(t, namedID("Dog"), pet.Discriminator.Default, + assert.Equal(t, componentID("Dog"), pet.Discriminator.Default, "defaultMapping names the variant an unrecognized tag falls back to") - assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"], + assert.Equal(t, componentID("Cat"), pet.Discriminator.Mapping["cat"], "and it is read separately from the mapping") } @@ -461,7 +461,7 @@ func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags [ assert.Empty(t, pass.Validate(doc)) for _, name := range []string{"Pet", "Animal"} { - m, ok := doc.Types[namedID(name)].(*ir.Model) + m, ok := doc.Types[componentID(name)].(*ir.Model) require.True(t, ok, name) assert.Nil(t, m.Discriminator, "%s routes to a non-subtype, so the model does not carry it", name) entry, ok := m.Unmodeled["openapi:discriminator"] @@ -470,20 +470,20 @@ func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags [ assert.Contains(t, m.Unmodeled, "openapi:oneOf", "beside the union it routes through") assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name+"/discriminator")) } - _, isUnion := doc.Types[namedID("Bird")].(*ir.Union) + _, isUnion := doc.Types[componentID("Bird")].(*ir.Union) assert.True(t, isUnion, "Bird writes allOf to Animal but lowers to a union, which is no subtype") - vehicle, ok := doc.Types[namedID("Vehicle")].(*ir.Union) + vehicle, ok := doc.Types[componentID("Vehicle")].(*ir.Union) require.True(t, ok, "a multi-typed body lowers to a union") assert.Contains(t, vehicle.Unmodeled, "openapi:discriminator", "kept beside the union it was written for") variant, ok := doc.Types["t/anon/components/schemas/Vehicle/type/0"].(*ir.Model) require.True(t, ok) assert.Nil(t, variant.Discriminator, "the object variant does not carry it") - shape, ok := doc.Types[namedID("Shape")].(*ir.Model) + shape, ok := doc.Types[componentID("Shape")].(*ir.Model) require.True(t, ok) require.NotNil(t, shape.Discriminator, "every target is a subtype, so it stays") - assert.Equal(t, namedID("Circle"), shape.Discriminator.Mapping["circle"]) + assert.Equal(t, componentID("Circle"), shape.Discriminator.Mapping["circle"]) assert.NotContains(t, shape.Unmodeled, "openapi:discriminator") } @@ -491,21 +491,21 @@ func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags [ // anyOf that collapses to nullable X is kept verbatim, with a diagnostic, // where the collapse leaves no node with a field for it. func assertNullableUnionDiscriminator(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - maybe, ok := doc.Types[namedID("MaybeCat")].(*ir.Scalar) + maybe, ok := doc.Types[componentID("MaybeCat")].(*ir.Scalar) require.True(t, ok, "the component is an alias over the nullable reference") - assert.Equal(t, ir.TypeRef{Target: namedID("Cat"), Nullable: true}, *maybe.Base) + assert.Equal(t, ir.TypeRef{Target: componentID("Cat"), Nullable: true}, *maybe.Base) assert.Contains(t, maybe.Unmodeled, "openapi:discriminator") assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/MaybeCat")) - owner, ok := doc.Types[namedID("Owner")].(*ir.Model) + owner, ok := doc.Types[componentID("Owner")].(*ir.Model) require.True(t, ok) require.Len(t, owner.Properties, 1) - assert.Equal(t, ir.TypeRef{Target: namedID("Cat"), Nullable: true}, owner.Properties[0].Type) + assert.Equal(t, ir.TypeRef{Target: componentID("Cat"), Nullable: true}, owner.Properties[0].Type) assert.Contains(t, owner.Properties[0].Unmodeled, "openapi:discriminator", "the property carries it") } func assertAnyOfUntagged(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - u, ok := doc.Types[namedID("StringOrNumber")].(*ir.Union) + u, ok := doc.Types[componentID("StringOrNumber")].(*ir.Union) require.True(t, ok) assert.False(t, u.Exclusive, "anyOf is a non-exclusive union") assert.False(t, u.WireTagged, "with no discriminator, validation infers the variant") @@ -518,7 +518,7 @@ func assertAnyOfUntagged(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // over Pet that keeps the description, so no information is lost, and its // variant is named for Pet while the node keeps the positional hint. func assertUnionDecoratedBranch(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - u, ok := doc.Types[namedID("U")].(*ir.Union) + u, ok := doc.Types[componentID("U")].(*ir.Union) require.True(t, ok) require.Len(t, u.Variants, 2) assert.Equal(t, "user", u.Variants[0].Name.Hint) @@ -528,13 +528,13 @@ func assertUnionDecoratedBranch(t *testing.T, doc *ir.Document, _ []ir.Diagnosti require.True(t, ok, "the wrapped branch is a node of its own") base, decorates := ir.Decorated(branch) require.True(t, decorates, "a description beside the wrapped $ref is an annotation, not a shape") - assert.Equal(t, namedID("Pet"), base.Target) + assert.Equal(t, componentID("Pet"), base.Target) assert.Equal(t, "a pet", branch.Docs.Description, "the annotation is kept") assert.Equal(t, "variant_1", branch.Name.Hint, "the node keeps the positional hint") } func assertNegationNot(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("NotFoo")].(*ir.Model) + m, ok := doc.Types[componentID("NotFoo")].(*ir.Model) require.True(t, ok) raw, ok := m.Unmodeled["openapi:not"] require.True(t, ok, "not-keyword kept verbatim under Unmodeled") diff --git a/compilers/openapi/e2e/conformance_content_test.go b/compilers/openapi/e2e/conformance_content_test.go index e1dd7523..35dead22 100644 --- a/compilers/openapi/e2e/conformance_content_test.go +++ b/compilers/openapi/e2e/conformance_content_test.go @@ -201,7 +201,7 @@ func assertPartHeaders(t *testing.T, doc *ir.Document, headers []ir.Property) { sum, ok := byWire["X-Part-Checksum"] require.True(t, ok, "$ref'd part header lowered; got %v", byWire) - assert.Equal(t, namedID("Digest"), sum.Type.Target, "a $ref'd header takes the referent's schema") + assert.Equal(t, componentID("Digest"), sum.Type.Target, "a $ref'd header takes the referent's schema") assert.Equal(t, "sha-256 digest of this part", sum.Docs.Description) for _, h := range headers { assert.Contains(t, doc.Types, h.Type.Target, "part header %q must reference a live node", h.WireName) diff --git a/compilers/openapi/e2e/conformance_docs_test.go b/compilers/openapi/e2e/conformance_docs_test.go index d7a8512c..0b4cebab 100644 --- a/compilers/openapi/e2e/conformance_docs_test.go +++ b/compilers/openapi/e2e/conformance_docs_test.go @@ -14,7 +14,7 @@ func assertDeprecation(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { op, ok := opByName(doc, "oldOp") require.True(t, ok) assert.NotNil(t, op.Deprecation) - m, ok := doc.Types[namedID("OldModel")].(*ir.Model) + m, ok := doc.Types[componentID("OldModel")].(*ir.Model) require.True(t, ok) assert.NotNil(t, m.Deprecation) require.Len(t, m.Properties, 1) @@ -22,13 +22,13 @@ func assertDeprecation(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // A parameter is its own carrier: deprecated written on the parameter object // reaches it without any schema being involved. - legacy, ok := paramByName(op, "legacy") + legacy, ok := openapitest.ParamBySource(op, "legacy") require.True(t, ok) assert.NotNil(t, legacy.Deprecation) } func assertExamples(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) // A schema that reduced to a shared primitive keeps its examples on the @@ -42,7 +42,7 @@ func assertExamples(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { require.Len(t, doc.Types[inner.Type.Target].Common().Examples, 1) // A component's example belongs to the component, not to a use site. - c, ok := doc.Types[namedID("C")] + c, ok := doc.Types[componentID("C")] require.True(t, ok) require.Len(t, c.Common().Examples, 1) require.NotNil(t, c.Common().Examples[0].Value) @@ -52,7 +52,7 @@ func assertExamples(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // type's. op, ok := opByName(doc, "getItem") require.True(t, ok) - q, ok := paramByName(op, "q") + q, ok := openapitest.ParamBySource(op, "q") require.True(t, ok) require.Len(t, q.Examples, 1) assert.Equal(t, "one", q.Examples[0].Name) @@ -118,7 +118,7 @@ func assertDocsSummaryDesc(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // A documented scalar alias is documented too: its docs must not depend on // what its body happens to lower to (GitHub #114). - sc, ok := doc.Types[namedID("UserId")].(*ir.Scalar) + sc, ok := doc.Types[componentID("UserId")].(*ir.Scalar) require.True(t, ok) assert.Equal(t, "User identifier", sc.Docs.Summary) assert.Equal(t, "Stable identifier for a user.", sc.Docs.Description) @@ -183,11 +183,11 @@ func assertRefSiteDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // parameters — the two-mount case: the first writes both siblings, the second // only a description, and the declaration's own description is overridden by // both rather than surviving on either. - widgetLimit, ok := paramByName(widgets, "limit") + widgetLimit, ok := openapitest.ParamBySource(widgets, "limit") require.True(t, ok) assert.Equal(t, "Page size", widgetLimit.Docs.Summary) assert.Equal(t, "How many widgets to return.", widgetLimit.Docs.Description) - gadgetLimit, ok := paramByName(gadgets, "limit") + gadgetLimit, ok := openapitest.ParamBySource(gadgets, "limit") require.True(t, ok) assert.Empty(t, gadgetLimit.Docs.Summary, "the second mount writes no summary, so none is invented") assert.Equal(t, "How many gadgets to return.", gadgetLimit.Docs.Description) @@ -242,7 +242,7 @@ func assertAliasHopDocs(t *testing.T, doc *ir.Document) { t.Helper() sprocket, ok := opByName(doc, "makeSprocket") require.True(t, ok) - limit, ok := paramByName(sprocket, "limit") + limit, ok := openapitest.ParamBySource(sprocket, "limit") require.True(t, ok) assert.Equal(t, ir.Docs{Summary: "Sprocket page size", Description: "The alias's own description."}, limit.Docs, "the use site's summary over the alias's description over the declaration's") @@ -268,7 +268,7 @@ func assertExamples32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { require.True(t, ok) // parameters - limit, ok := paramByName(op, "limit") + limit, ok := openapitest.ParamBySource(op, "limit") require.True(t, ok) require.Len(t, limit.Examples, 1) require.NotNil(t, limit.Examples[0].Value, "a parameter's dataValue reaches Example.Value") diff --git a/compilers/openapi/e2e/conformance_extensions_test.go b/compilers/openapi/e2e/conformance_extensions_test.go index ff2b9ebe..49948f2c 100644 --- a/compilers/openapi/e2e/conformance_extensions_test.go +++ b/compilers/openapi/e2e/conformance_extensions_test.go @@ -11,7 +11,7 @@ import ( ) func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) raw, ok := m.Unmodeled["openapi:x-rate-limit"] require.True(t, ok, "x-* extensions are namespaced under openapi:") @@ -140,7 +140,7 @@ func assertInlineAnnotations(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) assertAnnotatedScalar(t, doc, "t/anon/paths/~1codes/get/responses/200/content/application~1json/schema", 8192) - bare, ok := doc.Types[namedID("Bare")].(*ir.List) + bare, ok := doc.Types[componentID("Bare")].(*ir.List) require.True(t, ok) assert.Equal(t, ir.TypeID("t/prim/string"), bare.Elem.Target, "an element declaring nothing must not grow a node of its own") diff --git a/compilers/openapi/e2e/conformance_nullability_test.go b/compilers/openapi/e2e/conformance_nullability_test.go index 0dec1aa5..6f18eba5 100644 --- a/compilers/openapi/e2e/conformance_nullability_test.go +++ b/compilers/openapi/e2e/conformance_nullability_test.go @@ -11,7 +11,7 @@ import ( ) func assertNullabilityFourStates(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) require.Len(t, m.Properties, 4) states := openapitest.PropsByWire(m.Properties) @@ -26,27 +26,27 @@ func assertNullabilityFourStates(t *testing.T, doc *ir.Document, _ []ir.Diagnost } func assertNullable30(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) require.Len(t, m.Properties, 1) assert.True(t, m.Properties[0].Type.Nullable, "3.0 nullable lowers to the same IR bit") } func assertNullable31Ref(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("Owner")].(*ir.Model) + m, ok := doc.Types[componentID("Owner")].(*ir.Model) require.True(t, ok) require.Len(t, m.Properties, 2) byName := openapitest.PropsByWire(m.Properties) assert.True(t, byName["p"].Type.Nullable, "3.1's type-array null spelling normalizes to the same IR bit at a $ref site") - assert.Equal(t, namedID("Target"), byName["p"].Type.Target, "the ref resolves to the named component") + assert.Equal(t, componentID("Target"), byName["p"].Type.Target, "the ref resolves to the named component") assert.True(t, byName["q"].Type.Nullable, "a union's null branch is stripped into the ref's Nullable bit, so the ref must carry it") - assert.Equal(t, namedID("UnionTarget"), byName["q"].Type.Target, "the ref resolves to the union component") + assert.Equal(t, componentID("UnionTarget"), byName["q"].Type.Target, "the ref resolves to the union component") - u, ok := doc.Types[namedID("UnionTarget")].(*ir.Union) + u, ok := doc.Types[componentID("UnionTarget")].(*ir.Union) require.True(t, ok) assert.Len(t, u.Variants, 2, "the null branch lifts to the ref rather than becoming a variant") @@ -65,7 +65,7 @@ func assertCollapsedBranchHint(t *testing.T, doc *ir.Document) { t.Helper() const branchID = ir.TypeID("t/anon/components/schemas/Collapsed/oneOf/0") - collapsed, ok := doc.Types[namedID("Collapsed")].(*ir.Scalar) + collapsed, ok := doc.Types[componentID("Collapsed")].(*ir.Scalar) require.True(t, ok, "a {X, null} set resolves to its one branch rather than to a union node") require.NotNil(t, collapsed.Base) assert.True(t, collapsed.Base.Nullable, "the null branch lifts onto the reference") @@ -76,7 +76,7 @@ func assertCollapsedBranchHint(t *testing.T, doc *ir.Document) { assert.Equal(t, "variant_0", branch.Common().Name.Hint, "the branch is named by its position in the composition, which is what a $ref to it derives too") - ref, ok := doc.Types[namedID("BranchRef")].(*ir.Scalar) + ref, ok := doc.Types[componentID("BranchRef")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, ref.Base) assert.Equal(t, branchID, ref.Base.Target, "the outside reference reaches that same node") @@ -90,7 +90,7 @@ func assertCollapsedBranchHint(t *testing.T, doc *ir.Document) { // strip, since the type array already carries the bit and deleting the `null` // member moves only the source hash; the member count below does. func assertNullableEnum31(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - e, ok := doc.Types[namedID("Color")].(*ir.Enum) + e, ok := doc.Types[componentID("Color")].(*ir.Enum) require.True(t, ok, "the null member does not cost the declaration its enum-ness") assert.True(t, e.Closed) assert.Equal(t, ir.PrimString, e.ValueType, "null contributes no value type of its own") @@ -101,48 +101,48 @@ func assertNullableEnum31(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) assert.NotEqual(t, "openapi/degraded-construct", d.Code, "normalizing is not degrading: %+v", d) } - m, ok := doc.Types[namedID("Holder")].(*ir.Model) + m, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok) one, ok := propByWire(m, "one") require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("Color"), Nullable: true}, one.Type, + assert.Equal(t, ir.TypeRef{Target: componentID("Color"), Nullable: true}, one.Type, "a property $ref admits the null the members no longer carry") many, ok := propByWire(m, "many") require.True(t, ok) list, ok := doc.Types[many.Type.Target].(*ir.List) require.True(t, ok, "the array property hoists a List") - assert.Equal(t, ir.TypeRef{Target: namedID("Color"), Nullable: true}, list.Elem, + assert.Equal(t, ir.TypeRef{Target: componentID("Color"), Nullable: true}, list.Elem, "and so does a list element, which holds its own reference") assert.False(t, many.Type.Nullable, "the list itself was never declared nullable") op, ok := opByName(doc, "pick") require.True(t, ok) - p, ok := paramByName(op, "color") + p, ok := openapitest.ParamBySource(op, "color") require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("Color"), Nullable: true}, p.Type, + assert.Equal(t, ir.TypeRef{Target: componentID("Color"), Nullable: true}, p.Type, "a parameter reaches the same declaration through the operation walk") // The two spellings beside Color. A non-empty enum fixes the value space, so // the members decide null admission whether or not a type keyword is written // beside them — and when one is, the two conjoin rather than the type winning. - bare, ok := doc.Types[namedID("BareColor")].(*ir.Enum) + bare, ok := doc.Types[componentID("BareColor")].(*ir.Enum) require.True(t, ok, "a bare enum listing null is still an enum of its scalar members") require.Len(t, bare.Members, 2, "the null member is normalized away here too") assert.Equal(t, ir.PrimString, bare.ValueType, "the kept members supply the value type") - narrowed, ok := doc.Types[namedID("NarrowedColor")].(*ir.Enum) + narrowed, ok := doc.Types[componentID("NarrowedColor")].(*ir.Enum) require.True(t, ok) require.Len(t, narrowed.Members, 2, "nothing to strip: the members never listed null") bareRef, ok := propByWire(m, "bare") require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("BareColor"), Nullable: true}, bareRef.Type, + assert.Equal(t, ir.TypeRef{Target: componentID("BareColor"), Nullable: true}, bareRef.Type, "the bare spelling admits null at its uses, like the type-array spelling of the same set") narrowedRef, ok := propByWire(m, "narrowed") require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("NarrowedColor"), Nullable: false}, narrowedRef.Type, + assert.Equal(t, ir.TypeRef{Target: componentID("NarrowedColor"), Nullable: false}, narrowedRef.Type, "an enum excluding null must not admit it, whatever the type keyword names") } @@ -157,7 +157,7 @@ func assertNullableEnum31(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) // composition carrying its own would state one fact in two places that can // disagree. func assertNullabilityConjunction(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("Holder")].(*ir.Model) + m, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok) props := openapitest.PropsByWire(m.Properties) require.Len(t, props, 5) @@ -172,7 +172,7 @@ func assertNullabilityConjunction(t *testing.T, doc *ir.Document, _ []ir.Diagnos "one conjunct forbidding null decides the conjunction") for _, name := range []string{"WrapNullableScalar", "WrapNullableModel", "WrapBoth"} { - wrap, isModel := doc.Types[namedID(name)].(*ir.Model) + wrap, isModel := doc.Types[componentID(name)].(*ir.Model) require.True(t, isModel, "%s composes as a model", name) if wrap.Base != nil { assert.False(t, wrap.Base.Nullable, "%s.Base names a conjunct, not a usage", name) @@ -182,9 +182,9 @@ func assertNullabilityConjunction(t *testing.T, doc *ir.Document, _ []ir.Diagnos } } - plain, ok := doc.Types[namedID("PlainUnion")].(*ir.Union) + plain, ok := doc.Types[componentID("PlainUnion")].(*ir.Union) require.True(t, ok) - distributed, ok := doc.Types[namedID("DistributedUnion")].(*ir.Union) + distributed, ok := doc.Types[componentID("DistributedUnion")].(*ir.Union) require.True(t, ok) require.Len(t, plain.Variants, 2) require.Len(t, distributed.Variants, 2) diff --git a/compilers/openapi/e2e/conformance_params_test.go b/compilers/openapi/e2e/conformance_params_test.go index 8b0811ac..22c44346 100644 --- a/compilers/openapi/e2e/conformance_params_test.go +++ b/compilers/openapi/e2e/conformance_params_test.go @@ -43,9 +43,9 @@ func assertParamStyles(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { // interns its schema once, at the path item's own pointer (issue #36). clearSearch, ok := opByName(doc, "clearSearch") require.True(t, ok, "the path item's second operation is registered") - searchReqID, ok := paramByName(op, "requestId") + searchReqID, ok := openapitest.ParamBySource(op, "requestId") require.True(t, ok, "search inherits the path-item-level parameter") - clearReqID, ok := paramByName(clearSearch, "requestId") + clearReqID, ok := openapitest.ParamBySource(clearSearch, "requestId") require.True(t, ok, "clearSearch inherits the same path-item-level parameter") assert.Equal(t, searchReqID.Type.Target, clearReqID.Type.Target, "both operations resolve the shared path-item parameter to the same interned schema") @@ -61,7 +61,7 @@ func assertParamStyles(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { // carrier at this position with an Unmodeled map. func assertAllowEmptyValueKept(t *testing.T, op ir.Operation) { t.Helper() - bare, ok := paramByName(op, "bare") + bare, ok := openapitest.ParamBySource(op, "bare") require.True(t, ok, "the allowEmptyValue parameter still lowers") entry, ok := bare.Unmodeled["openapi:allowEmptyValue"] require.True(t, ok, "and its declared flag is kept beside it") @@ -402,7 +402,7 @@ func assertParamAllowReservedLocations(t *testing.T, doc *ir.Document, diags []i func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { op, ok := opByName(doc, "listItems") require.True(t, ok) - cursor, ok := paramByName(op, "cursor") + cursor, ok := openapitest.ParamBySource(op, "cursor") require.True(t, ok) assert.Equal(t, ir.Docs{Summary: "Cursor", Description: "Opaque pagination cursor."}, cursor.Docs) assert.NotNil(t, cursor.Deprecation) @@ -410,7 +410,7 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic assert.Equal(t, "0", cursor.Default.Str) assert.Nil(t, cursor.Constraints, "the referent's bound stays on the referent") - override, ok := paramByName(op, "override") + override, ok := openapitest.ParamBySource(op, "override") require.True(t, ok) assert.Equal(t, "Cursor for this endpoint only.", override.Docs.Description, "a description beside the $ref wins over the referent's") @@ -423,7 +423,7 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic assert.Equal(t, int64(100), *override.Constraints.MaxLength, "and it is the use site's own, not narrowed against the referent's here") - holder, ok := doc.Types[namedID("Holder")].(*ir.Model) + holder, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok) prop, ok := propByWire(holder, "cursor") require.True(t, ok) @@ -438,7 +438,7 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic assert.Equal(t, override.Constraints, overrideProp.Constraints, "and a property keeps its own bound exactly as the parameter does") - decl, ok := doc.Types[namedID("Cursor")].(*ir.Scalar) + decl, ok := doc.Types[componentID("Cursor")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, decl.Constraints) require.NotNil(t, decl.Constraints.MaxLength) @@ -484,7 +484,7 @@ func assertHeaderContentSchema(t *testing.T, doc *ir.Document, diags []ir.Diagno byRef, ok := headerByWire(headers, "X-Report-Ref") require.True(t, ok) - assert.Equal(t, namedID("ReportID"), byRef.Type.Target, + assert.Equal(t, componentID("ReportID"), byRef.Type.Target, "a $ref under content resolves to the named component, not to an anonymous copy") assertHeaderStyleAndExplodeKept(t, headers) @@ -526,7 +526,7 @@ func assertParamContentFields(t *testing.T, doc *ir.Document, diags []ir.Diagnos op, ok := opByName(doc, "search") require.True(t, ok) - filter, ok := paramByName(op, "filter") + filter, ok := openapitest.ParamBySource(op, "filter") require.True(t, ok) require.Len(t, filter.Examples, 1, "the media type's examples reach the parameter") assert.Equal(t, "one", filter.Examples[0].Name) @@ -537,7 +537,7 @@ func assertParamContentFields(t *testing.T, doc *ir.Document, diags []ir.Diagnos assert.Equal(t, ir.ReasonNoIRHome, filter.Unmodeled[scope+"itemSchema"].Reason, "a parser-modelled field with no ir.Parameter home is kept rather than dropped") - plain, ok := paramByName(op, "plain") + plain, ok := openapitest.ParamBySource(op, "plain") require.True(t, ok) assert.Empty(t, plain.Examples, "the schema spelling elects no media type to take examples from") assert.Empty(t, plain.Unmodeled, "nor any content-scoped entry") diff --git a/compilers/openapi/e2e/conformance_responses_test.go b/compilers/openapi/e2e/conformance_responses_test.go index c16b70c2..38a25fe8 100644 --- a/compilers/openapi/e2e/conformance_responses_test.go +++ b/compilers/openapi/e2e/conformance_responses_test.go @@ -115,10 +115,10 @@ func assertDefaultResponse(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { assert.Equal(t, []ir.StatusRange{{From: 200, To: 200}}, item.Responses[0].Conditions.StatusCodes) require.NotNil(t, item.Default.Payload) require.Len(t, item.Default.Payload.Contents, 1) - assert.Equal(t, namedID("Problem"), item.Default.Payload.Contents[0].Type.Target, + assert.Equal(t, componentID("Problem"), item.Default.Payload.Contents[0].Type.Target, "the default keeps its own body, apart from the success one") require.NotNil(t, item.Responses[0].Payload) - assert.Equal(t, namedID("Item"), item.Responses[0].Payload.Contents[0].Type.Target) + assert.Equal(t, componentID("Item"), item.Responses[0].Payload.Contents[0].Type.Target) ping := byOp["ping"] assert.Empty(t, ping.Responses) diff --git a/compilers/openapi/e2e/conformance_scalars_test.go b/compilers/openapi/e2e/conformance_scalars_test.go index c0dabb09..3103d05d 100644 --- a/compilers/openapi/e2e/conformance_scalars_test.go +++ b/compilers/openapi/e2e/conformance_scalars_test.go @@ -16,7 +16,7 @@ import ( // Enum as well: an `enum` keyword lowers to an Enum whatever its member count, // and only `const` lowers to a Literal (ir-design §4.5). func assertEnumString(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - e, ok := doc.Types[namedID("Color")].(*ir.Enum) + e, ok := doc.Types[componentID("Color")].(*ir.Enum) require.True(t, ok) assert.Equal(t, ir.PrimString, e.ValueType) assert.True(t, e.Closed) @@ -24,8 +24,8 @@ func assertEnumString(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { assert.Equal(t, "red", e.Members[0].Value.Str) assert.Equal(t, "blue", e.Members[2].Value.Str) - pinned, ok := doc.Types[namedID("Pinned")].(*ir.Enum) - require.True(t, ok, "a one-member enum is an Enum; got %T", doc.Types[namedID("Pinned")]) + pinned, ok := doc.Types[componentID("Pinned")].(*ir.Enum) + require.True(t, ok, "a one-member enum is an Enum; got %T", doc.Types[componentID("Pinned")]) assert.Equal(t, ir.PrimString, pinned.ValueType) assert.True(t, pinned.Closed) require.Len(t, pinned.Members, 1) @@ -33,7 +33,7 @@ func assertEnumString(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { } func assertEnumNumeric(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - e, ok := doc.Types[namedID("BigCodes")].(*ir.Enum) + e, ok := doc.Types[componentID("BigCodes")].(*ir.Enum) require.True(t, ok) assert.Equal(t, ir.PrimInteger, e.ValueType) require.Len(t, e.Members, 2) @@ -57,8 +57,8 @@ func assertEmptyEnum(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { "BesideAllOf": ir.PrimAny, } for name, valueType := range empty { - e, ok := doc.Types[namedID(name)].(*ir.Enum) - require.True(t, ok, "%s lowers to an Enum, got %T", name, doc.Types[namedID(name)]) + e, ok := doc.Types[componentID(name)].(*ir.Enum) + require.True(t, ok, "%s lowers to an Enum, got %T", name, doc.Types[componentID(name)]) assert.True(t, e.Closed, "%s admits its members and nothing else", name) assert.Empty(t, e.Members, "%s declares no member", name) assert.Equal(t, valueType, e.ValueType, @@ -70,14 +70,14 @@ func assertEmptyEnum(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { // An Enum has no home for a property set or a composition, so what the empty // enum is written beside stays verbatim rather than being traded for it. - props := unmodeledEntry(t, doc.Types[namedID("BesideProperties")].Common().Unmodeled, "openapi:properties") + props := unmodeledEntry(t, doc.Types[componentID("BesideProperties")].Common().Unmodeled, "openapi:properties") assert.Equal(t, ir.ReasonDegradedLowering, props.Reason) assert.JSONEq(t, `{"x":{"type":"string"}}`, string(props.Value)) - composed := unmodeledEntry(t, doc.Types[namedID("BesideAllOf")].Common().Unmodeled, "openapi:allOf") + composed := unmodeledEntry(t, doc.Types[componentID("BesideAllOf")].Common().Unmodeled, "openapi:allOf") assert.Equal(t, ir.ReasonDegradedLowering, composed.Reason) assert.JSONEq(t, `[{"$ref":"#/components/schemas/Base"}]`, string(composed.Value)) - holder, ok := doc.Types[namedID("Holder")].(*ir.Model) + holder, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok) colour, ok := propByWire(holder, "colour") require.True(t, ok) @@ -111,13 +111,13 @@ func assertMissingArrayItems(t *testing.T, doc *ir.Document, diags []ir.Diagnost "/paths/~1things/get/parameters/0/schema", }, got) - bare, ok := doc.Types[namedID("Bare")].(*ir.List) - require.True(t, ok, "Bare still lowers to a List, got %T", doc.Types[namedID("Bare")]) + bare, ok := doc.Types[componentID("Bare")].(*ir.List) + require.True(t, ok, "Bare still lowers to a List, got %T", doc.Types[componentID("Bare")]) assert.Equal(t, ir.TypeID("t/prim/any"), bare.Elem.Target, "over the top type") } func assertScalarFormat(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - h, ok := doc.Types[namedID("Holder")].(*ir.Model) + h, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok) color, ok := propByWire(h, "color") require.True(t, ok) @@ -165,7 +165,7 @@ func assertScalarFormat(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // parameter, which has no field. It reads the nodes rather than the golden's // bytes, so a fact that moved to another carrier still fails (GitHub #579). func assertSecretFormat(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - pw := requireSensitiveAlias(t, doc, namedID("Pw"), ir.PrimTypeID(ir.PrimString), "component schema") + pw := requireSensitiveAlias(t, doc, componentID("Pw"), ir.PrimTypeID(ir.PrimString), "component schema") require.NotNil(t, pw.Constraints, "the bound written beside the format stays on the node") assert.Equal(t, new(int64(8)), pw.Constraints.MinLength) @@ -180,7 +180,7 @@ func assertSecretFormat(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { assert.Equal(t, ir.PrimTypeID(ir.PrimString), plain.Type.Target) assert.False(t, plain.Secret) assert.False(t, doc.Types[ir.PrimTypeID(ir.PrimString)].Common().Sensitive) - assert.False(t, doc.Types[namedID("Plain")].Common().Sensitive, "a referent beside which a use wrote it") + assert.False(t, doc.Types[componentID("Plain")].Common().Sensitive, "a referent beside which a use wrote it") } // assertSecretCarriers covers the properties of Login that own no node, where @@ -195,9 +195,9 @@ func assertSecretCarriers(t *testing.T, doc *ir.Document) { }{ {"inline", ir.PrimTypeID(ir.PrimString)}, {"pin", ir.PrimTypeID(ir.PrimInteger)}, - {"viaRef", namedID("Pw")}, // inherited from the referent, as deprecated is - {"viaAlias", namedID("Chain")}, // from the end of the $ref chain - {"beside", namedID("Plain")}, // written beside the $ref, binding the use + {"viaRef", componentID("Pw")}, // inherited from the referent, as deprecated is + {"viaAlias", componentID("Chain")}, // from the end of the $ref chain + {"beside", componentID("Plain")}, // written beside the $ref, binding the use } { p, ok := propByWire(login, tc.wire) require.True(t, ok, tc.wire) @@ -213,18 +213,18 @@ func assertSecretCarriers(t *testing.T, doc *ir.Document) { nullable, ok := propByWire(login, "nullable") require.True(t, ok) assert.False(t, nullable.Secret, "the node holds it, not a copy on the use") - assert.Equal(t, ir.TypeRef{Target: namedID("Pw"), Nullable: true}, nullable.Type) + assert.Equal(t, ir.TypeRef{Target: componentID("Pw"), Nullable: true}, nullable.Type) wrapped, ok := propByWire(login, "wrapped") require.True(t, ok) assert.False(t, wrapped.Secret, "the node holds it, not a copy on the use") m, ok := doc.Types[wrapped.Type.Target].(*ir.Model) require.True(t, ok, "an allOf over one $ref is a model") require.NotNil(t, m.Base) - assert.Equal(t, namedID("Pw"), m.Base.Target) - chain, ok := doc.Types[namedID("Chain")].(*ir.Scalar) + assert.Equal(t, componentID("Pw"), m.Base.Target) + chain, ok := doc.Types[componentID("Chain")].(*ir.Scalar) require.True(t, ok, "an alias component is a Scalar") require.NotNil(t, chain.Base) - assert.Equal(t, namedID("Pw"), chain.Base.Target) + assert.Equal(t, componentID("Pw"), chain.Base.Target) // A property whose schema owns a node leaves the redaction on it. pick, ok := propByWire(login, "pick") @@ -252,24 +252,24 @@ func assertSecretNodes(t *testing.T, doc *ir.Document) { requireSensitiveAlias(t, doc, bag.AdditionalProps.Patterns[0].Value.Target, ir.PrimTypeID(ir.PrimString), "patternProperties") - pair, ok := doc.Types[namedID("Pair")].(*ir.Tuple) + pair, ok := doc.Types[componentID("Pair")].(*ir.Tuple) require.True(t, ok) require.Len(t, pair.Elems, 1) requireSensitiveAlias(t, doc, pair.Elems[0].Target, ir.PrimTypeID(ir.PrimString), "prefixItems") - tokens, ok := doc.Types[namedID("Tokens")].(*ir.List) + tokens, ok := doc.Types[componentID("Tokens")].(*ir.List) require.True(t, ok) - requireSensitiveAlias(t, doc, tokens.Elem.Target, namedID("Plain"), "items beside a $ref") + requireSensitiveAlias(t, doc, tokens.Elem.Target, componentID("Plain"), "items beside a $ref") - either, ok := doc.Types[namedID("Either")].(*ir.Union) + either, ok := doc.Types[componentID("Either")].(*ir.Union) require.True(t, ok) require.Len(t, either.Variants, 2) requireSensitiveAlias(t, doc, either.Variants[0].Type.Target, ir.PrimTypeID(ir.PrimString), "union branch") assert.False(t, either.Sensitive, "the branch is redacted, not the union") - assert.Equal(t, ir.KindLiteral, requireSensitive(t, doc, namedID("Code"), "const").Kind()) - requireSensitiveAlias(t, doc, namedID("Opaque"), ir.PrimTypeID(ir.PrimAny), "untyped") - encoded, ok := requireSensitive(t, doc, namedID("Encoded"), "beside contentEncoding").(*ir.Scalar) + assert.Equal(t, ir.KindLiteral, requireSensitive(t, doc, componentID("Code"), "const").Kind()) + requireSensitiveAlias(t, doc, componentID("Opaque"), ir.PrimTypeID(ir.PrimAny), "untyped") + encoded, ok := requireSensitive(t, doc, componentID("Encoded"), "beside contentEncoding").(*ir.Scalar) require.True(t, ok) require.NotNil(t, encoded.Encoding) assert.Equal(t, "base64", encoded.Encoding.Name, "contentEncoding alone names the encoding") @@ -281,7 +281,7 @@ func assertSecretOperation(t *testing.T, doc *ir.Document, diags []ir.Diagnostic op, ok := opByName(doc, "login") require.True(t, ok) - token, ok := paramByName(op, "X-Token") + token, ok := openapitest.ParamBySource(op, "X-Token") require.True(t, ok) assert.Equal(t, ir.PrimTypeID(ir.PrimString), token.Type.Target, "a parameter owns no node for it") assert.Equal(t, "the session token", token.Docs.Description) @@ -289,9 +289,9 @@ func assertSecretOperation(t *testing.T, doc *ir.Document, diags []ir.Diagnostic assert.Equal(t, ir.ReasonNoIRHome, token.Unmodeled["openapi:format"].Reason) assert.Equal(t, []ir.Severity{ir.SeverityInfo}, diagsAt(diags, "openapi/degraded-construct", "/paths/~1login/post/parameters/0/schema/format")) - key, ok := paramByName(op, "api_key") + key, ok := openapitest.ParamBySource(op, "api_key") require.True(t, ok) - assert.Equal(t, namedID("Pw"), key.Type.Target, "a referent's redaction is its own node's") + assert.Equal(t, componentID("Pw"), key.Type.Target, "a referent's redaction is its own node's") assert.NotContains(t, key.Unmodeled, "openapi:format") str := ir.PrimTypeID(ir.PrimString) @@ -303,13 +303,13 @@ func assertSecretOperation(t *testing.T, doc *ir.Document, diags []ir.Diagnostic header := op.Responses[0].Headers[0] assert.True(t, header.Secret, "a header is a property carrier") assert.Equal(t, ir.PrimTypeID(ir.PrimString), header.Type.Target) - assert.Equal(t, ir.TypeRef{Target: namedID("Pw"), Nullable: true}, op.Responses[0].Headers[1].Type) + assert.Equal(t, ir.TypeRef{Target: componentID("Pw"), Nullable: true}, op.Responses[0].Headers[1].Type) } // secretModel returns the secret-format component name as a Model. func secretModel(t *testing.T, doc *ir.Document, name string) *ir.Model { t.Helper() - m, ok := doc.Types[namedID(name)].(*ir.Model) + m, ok := doc.Types[componentID(name)].(*ir.Model) require.True(t, ok, "%s is a model", name) return m } @@ -341,7 +341,7 @@ func requireSensitiveAlias(t *testing.T, doc *ir.Document, id, base ir.TypeID, p // holds a per-use override. A position that owns a node keeps them there, so // which carrier a hint lands on follows what the position lowered to. func assertXMLHints(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - book, ok := doc.Types[namedID("Book")].(*ir.Model) + book, ok := doc.Types[componentID("Book")].(*ir.Model) require.True(t, ok) require.NotNil(t, book.XML) assert.Equal(t, ir.XMLHints{Name: "book", Namespace: "urn:example:books", Prefix: "bk"}, *book.XML) @@ -366,7 +366,7 @@ func assertXMLHints(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { } func assertEncodingByte(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - h, ok := doc.Types[namedID("Holder")].(*ir.Model) + h, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok) require.Len(t, h.Properties, 1) sc, ok := doc.Types[h.Properties[0].Type.Target].(*ir.Scalar) @@ -392,7 +392,7 @@ func assertEncodingByte(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // already existed and its GoDoc already named the version, so the fix is the // wiring, and the dialect's key is no longer reported as undefined. func assertXMLNodeType32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - report, ok := doc.Types[namedID("Report")].(*ir.Model) + report, ok := doc.Types[componentID("Report")].(*ir.Model) require.True(t, ok) id, ok := propByWire(report, "id") require.True(t, ok) diff --git a/compilers/openapi/e2e/conformance_types_test.go b/compilers/openapi/e2e/conformance_types_test.go index f35f4cce..52c9fa2a 100644 --- a/compilers/openapi/e2e/conformance_types_test.go +++ b/compilers/openapi/e2e/conformance_types_test.go @@ -17,13 +17,13 @@ import ( ) func assertNamedTypes(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - user, ok := doc.Types[namedID("User")].(*ir.Model) + user, ok := doc.Types[componentID("User")].(*ir.Model) require.True(t, ok, "named schema User present under its pointer-derived ID") assert.False(t, user.Anonymous) addr, ok := propByWire(user, "address") require.True(t, ok) - assert.Equal(t, namedID("Address"), addr.Type.Target) - _, ok = doc.Types[namedID("Address")].(*ir.Model) + assert.Equal(t, componentID("Address"), addr.Type.Target) + _, ok = doc.Types[componentID("Address")].(*ir.Model) assert.True(t, ok, "referenced Address resolves in the registry") } @@ -51,7 +51,7 @@ func assertNeutralNaming(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { assert.Equal(t, "widget_id", byParam["widget.id"], "path parameter") assert.Equal(t, "filter_name", byParam["filter[name]"], "bracketed query parameter") - widget, ok := doc.Types[namedID("com.example.Widget")].(*ir.Model) + widget, ok := doc.Types[componentID("com.example.Widget")].(*ir.Model) require.True(t, ok, "namespaced component keeps its pointer-derived ID") assert.Equal(t, "com_example_widget", widget.Name.Canonical, "component name") assert.Equal(t, "com.example.Widget", widget.Name.Source, "the source spelling is kept beside it") @@ -127,7 +127,7 @@ func assertEmptyNames(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // collision the IR is answerable for. func assertEmptyEnumMember(t *testing.T, doc *ir.Document) { t.Helper() - rollout, ok := doc.Types[namedID("Rollout")].(*ir.Enum) + rollout, ok := doc.Types[componentID("Rollout")].(*ir.Enum) require.True(t, ok) require.Len(t, rollout.Members, 3, "no member is merged away by the shared word") @@ -148,7 +148,7 @@ func assertEmptyEnumMember(t *testing.T, doc *ir.Document) { // hint is derived from the same empty name. func assertEmptyNamedProperty(t *testing.T, doc *ir.Document) { t.Helper() - widget, ok := doc.Types[namedID("Widget")].(*ir.Model) + widget, ok := doc.Types[componentID("Widget")].(*ir.Model) require.True(t, ok) require.Len(t, widget.Properties, 1) prop := widget.Properties[0] @@ -194,7 +194,7 @@ var composedHints = []struct { // so only the $ref form the Host case uses reaches composedVariant. func assertEmptyDerivedHints(t *testing.T, doc *ir.Document) { t.Helper() - mixed, ok := doc.Types[namedID("Mixed")].(*ir.Model) + mixed, ok := doc.Types[componentID("Mixed")].(*ir.Model) require.True(t, ok) require.Len(t, mixed.Properties, 1) union, ok := doc.Types[mixed.Properties[0].Type.Target].(*ir.Union) @@ -209,7 +209,7 @@ func assertEmptyDerivedHints(t *testing.T, doc *ir.Document) { } func assertInlineTypes(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - order, ok := doc.Types[namedID("Order")].(*ir.Model) + order, ok := doc.Types[componentID("Order")].(*ir.Model) require.True(t, ok) require.Len(t, order.Properties, 1) shipping := order.Properties[0] @@ -277,7 +277,7 @@ func inlineHoistPositionRefs(t *testing.T, doc *ir.Document) map[string]ir.TypeI t.Helper() order, ok := opByName(doc, "placeOrder") require.True(t, ok) - audit, ok := paramByName(order, "audit") + audit, ok := openapitest.ParamBySource(order, "audit") require.True(t, ok, "the operation declares its inline-schema parameter") require.Len(t, order.Responses, 1) require.Len(t, order.Responses[0].Headers, 1, "the response declares its inline-schema header") @@ -392,7 +392,7 @@ func assertComponentReuse(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { } func assertReadOnlyWriteOnly(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) r, ok := propByWire(m, "r") require.True(t, ok) @@ -407,7 +407,7 @@ func assertReadOnlyWriteOnly(t *testing.T, doc *ir.Document, diags []ir.Diagnost require.True(t, ok) assert.Equal(t, r.Visibility, viaRef.Visibility, "the referent's readOnly binds the reference site") - decl, ok := doc.Types[namedID("DeclaredReadOnly")].(*ir.Scalar) + decl, ok := doc.Types[componentID("DeclaredReadOnly")].(*ir.Scalar) require.True(t, ok) entry, ok := decl.Unmodeled["openapi:readOnly"] require.True(t, ok, "the declaration's own readOnly is kept as residue") @@ -436,11 +436,11 @@ func assertBothFlagsAreVisibleNowhere(t *testing.T, m *ir.Model, diags []ir.Diag } func assertRecursive(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - n, ok := doc.Types[namedID("Node")].(*ir.Model) + n, ok := doc.Types[componentID("Node")].(*ir.Model) require.True(t, ok) next, ok := propByWire(n, "next") require.True(t, ok) - assert.Equal(t, namedID("Node"), next.Type.Target, "self-reference terminates on the interned ID") + assert.Equal(t, componentID("Node"), next.Type.Target, "self-reference terminates on the interned ID") } // assertMaps pins ir-design §4.6's line between the two map shapes: an object @@ -455,25 +455,25 @@ func assertMaps(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { "AnyMap": {Value: ir.TypeRef{Target: "t/prim/any"}}, "BoundedMap": {Value: ir.TypeRef{Target: "t/prim/string"}, Constraints: &ir.Constraints{MinProps: i64(1), MaxProps: i64(10)}}, } { - mt, ok := doc.Types[namedID(name)].(*ir.MapT) - require.True(t, ok, "%s is a map and nothing else; got %T", name, doc.Types[namedID(name)]) + mt, ok := doc.Types[componentID(name)].(*ir.MapT) + require.True(t, ok, "%s is a map and nothing else; got %T", name, doc.Types[componentID(name)]) assert.Nil(t, mt.Key, "%s has string keys", name) assert.Equal(t, want.Value, mt.Value, name) assert.Empty(t, cmp.Diff(want.Constraints, mt.Constraints), name) } - pm, ok := doc.Types[namedID("PatternMap")].(*ir.Model) + pm, ok := doc.Types[componentID("PatternMap")].(*ir.Model) require.True(t, ok) require.NotNil(t, pm.AdditionalProps) require.Len(t, pm.AdditionalProps.Patterns, 1) assert.Equal(t, "^x-", pm.AdditionalProps.Patterns[0].Pattern) - rec, ok := doc.Types[namedID("OpenRecord")].(*ir.Model) + rec, ok := doc.Types[componentID("OpenRecord")].(*ir.Model) require.True(t, ok) require.NotNil(t, rec.AdditionalProps, "an explicit true is a catch-all, not an unwritten keyword") assert.Equal(t, ir.TypeRef{Target: "t/prim/any"}, rec.AdditionalProps.Value) - cr, ok := doc.Types[namedID("ClosedRecord")].(*ir.Model) + cr, ok := doc.Types[componentID("ClosedRecord")].(*ir.Model) require.True(t, ok) assert.Equal(t, ir.AdditionalClosed, cr.Additional) - ca, ok := doc.Types[namedID("ClosedAfter")].(*ir.Model) + ca, ok := doc.Types[componentID("ClosedAfter")].(*ir.Model) require.True(t, ok) assert.Equal(t, ir.AdditionalClosedAfterComposition, ca.Additional) } @@ -499,8 +499,8 @@ func assertTuples(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { {"ExactPair", pair, "", &ir.Constraints{MinItems: i64(2)}}, {"BoundedClosedPair", pair, "", &ir.Constraints{MinItems: i64(1), MaxItems: i64(2), UniqueItems: true}}, } { - tup, ok := doc.Types[namedID(tc.name)].(*ir.Tuple) - require.True(t, ok, "%s: prefixItems hoists a Tuple; got %T", tc.name, doc.Types[namedID(tc.name)]) + tup, ok := doc.Types[componentID(tc.name)].(*ir.Tuple) + require.True(t, ok, "%s: prefixItems hoists a Tuple; got %T", tc.name, doc.Types[componentID(tc.name)]) assert.Equal(t, tc.elems, tup.Elems, tc.name) if tc.rest == "" { assert.Nil(t, tup.Rest, "%s: no element may follow the head", tc.name) @@ -520,15 +520,15 @@ func assertTuples(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { } func assertLiteralConst(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - l, ok := doc.Types[namedID("Version")].(*ir.Literal) + l, ok := doc.Types[componentID("Version")].(*ir.Literal) require.True(t, ok, "const hoists a Literal") assert.Equal(t, ir.ValueString, l.Value.Kind) assert.Equal(t, "v1", l.Value.Str) // An unrepresentable const still hoists a node, and a node that admits any // value is the only honest one: a Literal here would have to invent a value. - _, ok = doc.Types[namedID("Opaque")].(*ir.Any) - assert.True(t, ok, "an unconvertible const hoists the top type; got %T", doc.Types[namedID("Opaque")]) + _, ok = doc.Types[componentID("Opaque")].(*ir.Any) + assert.True(t, ok, "an unconvertible const hoists the top type; got %T", doc.Types[componentID("Opaque")]) assert.Equal(t, []ir.Severity{ir.SeverityWarning}, diagsAt(diags, "openapi/degraded-construct", "/components/schemas/Opaque"), "degrading a declared value is a warning, not silent") diff --git a/compilers/openapi/e2e/conformance_unmodeled_test.go b/compilers/openapi/e2e/conformance_unmodeled_test.go index 91c158f6..d6a33d69 100644 --- a/compilers/openapi/e2e/conformance_unmodeled_test.go +++ b/compilers/openapi/e2e/conformance_unmodeled_test.go @@ -39,7 +39,7 @@ func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic "UntypedDiscriminator": {"discriminator", `{"propertyName":"kind"}`}, } for name, want := range untyped { - sc, ok := doc.Types[namedID(name)].(*ir.Scalar) + sc, ok := doc.Types[componentID(name)].(*ir.Scalar) require.True(t, ok, "%s owns a node of its own to keep the keyword on", name) require.NotNil(t, sc.Base) assert.Equal(t, ir.TypeID("t/prim/any"), sc.Base.Target, @@ -51,18 +51,18 @@ func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic diagsAt(diags, "openapi/degraded-construct", "/components/schemas/"+name)) } - e, ok := doc.Types[namedID("EnumWithProperties")].(*ir.Enum) + e, ok := doc.Types[componentID("EnumWithProperties")].(*ir.Enum) require.True(t, ok, "the enum half is taken") assert.JSONEq(t, `{"f":{"type":"integer"}}`, string(unmodeledEntry(t, e.Unmodeled, "openapi:properties").Value), "the property set the enum contradicts is kept beside it") - sc, ok := doc.Types[namedID("ScalarWithProperties")].(*ir.Scalar) + sc, ok := doc.Types[componentID("ScalarWithProperties")].(*ir.Scalar) require.True(t, ok, "the scalar half is taken") assert.JSONEq(t, `{"g":{"type":"integer"}}`, string(unmodeledEntry(t, sc.Unmodeled, "openapi:properties").Value)) - m, ok := doc.Types[namedID("ObjectWithItems")].(*ir.Model) + m, ok := doc.Types[componentID("ObjectWithItems")].(*ir.Model) require.True(t, ok) assert.JSONEq(t, `{"type":"integer"}`, string(unmodeledEntry(t, m.Unmodeled, "openapi:items").Value), @@ -72,7 +72,7 @@ func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic // The tuple's control beside the array's: a collection bound beside // prefixItems is the Tuple's own, so the census keeps nothing. - boundTuple, ok := doc.Types[namedID("ItemsBoundOnTuple")].(*ir.Tuple) + boundTuple, ok := doc.Types[componentID("ItemsBoundOnTuple")].(*ir.Tuple) require.True(t, ok) assert.Empty(t, boundTuple.Unmodeled, "the bound reached Tuple.Constraints") require.NotNil(t, boundTuple.Constraints) @@ -83,7 +83,7 @@ func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic // The union case reaches this through hasUnionSiblings, which used to treat // items as not-a-sibling and lower the union over it. Both halves survive: // the applicator here, the branches under the union's own keys. - u, ok := doc.Types[namedID("UnionWithItems")].(*ir.Scalar) + u, ok := doc.Types[componentID("UnionWithItems")].(*ir.Scalar) require.True(t, ok, "a union with siblings lowers its structural body and keeps the union") assert.JSONEq(t, `{"type":"string"}`, string(unmodeledEntry(t, u.Unmodeled, "openapi:items").Value)) @@ -109,10 +109,10 @@ func assertRefSiteKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic "RefAdditionalProperties": {"additionalProperties", "RefTargetObj", `false`}, "RefDiscriminator": {"discriminator", "RefTargetObj", `{"propertyName":"a"}`}, } { - sc, ok := doc.Types[namedID(name)].(*ir.Scalar) + sc, ok := doc.Types[componentID(name)].(*ir.Scalar) require.True(t, ok, "%s hoists an alias to hold what it wrote beside its $ref", name) require.NotNil(t, sc.Base) - assert.Equal(t, namedID(want.target), sc.Base.Target, "%s still aliases its target", name) + assert.Equal(t, componentID(want.target), sc.Base.Target, "%s still aliases its target", name) entry := unmodeledEntry(t, sc.Unmodeled, "openapi:"+want.keyword) assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) assert.JSONEq(t, want.raw, string(entry.Value)) @@ -120,18 +120,18 @@ func assertRefSiteKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic diagsAt(diags, "openapi/degraded-construct", "/components/schemas/"+name)) } - ctl, ok := doc.Types[namedID("RefConstrained")].(*ir.Scalar) + ctl, ok := doc.Types[componentID("RefConstrained")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, ctl.Constraints) assert.Equal(t, int64(3), *ctl.Constraints.MinLength, "a bound beside a $ref always had a home") assert.Equal(t, "kept", ctl.Docs.Description) assert.Empty(t, ctl.Unmodeled, "so it keeps nothing verbatim") - carrier, ok := doc.Types[namedID("RefCarrier")].(*ir.Model) + carrier, ok := doc.Types[componentID("RefCarrier")].(*ir.Model) require.True(t, ok) p, ok := propByWire(carrier, "p") require.True(t, ok) - assert.Equal(t, namedID("RefTargetStr"), p.Type.Target, + assert.Equal(t, componentID("RefTargetStr"), p.Type.Target, "a carrier hoists no node, so it still resolves straight to the target") assert.JSONEq(t, `"email"`, string(unmodeledEntry(t, p.Unmodeled, "openapi:format").Value), "and keeps the keyword on itself instead") @@ -159,10 +159,10 @@ func assertUnionBesideRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) "H": {"anyOf", `[{"type":"string"},{"type":"integer"}]`, unionWhy}, "I": {"allOf", `[{"type":"string"}]`, "no home for allOf"}, } { - sc, ok := doc.Types[namedID(name)].(*ir.Scalar) + sc, ok := doc.Types[componentID(name)].(*ir.Scalar) require.True(t, ok, "%s hoists an alias to hold what it wrote beside its $ref", name) require.NotNil(t, sc.Base) - assert.Equal(t, namedID("Base"), sc.Base.Target, "%s still aliases Base", name) + assert.Equal(t, componentID("Base"), sc.Base.Target, "%s still aliases Base", name) entry := unmodeledEntry(t, sc.Unmodeled, "openapi:"+want.keyword) assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) assert.JSONEq(t, want.raw, string(entry.Value)) @@ -171,11 +171,11 @@ func assertUnionBesideRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) want.wantMsg, name) } - carrier, ok := doc.Types[namedID("Carrier")].(*ir.Model) + carrier, ok := doc.Types[componentID("Carrier")].(*ir.Model) require.True(t, ok) p, ok := propByWire(carrier, "p") require.True(t, ok) - assert.Equal(t, namedID("Base"), p.Type.Target, + assert.Equal(t, componentID("Base"), p.Type.Target, "a carrier hoists no node, so it still resolves straight to the target") entry := unmodeledEntry(t, p.Unmodeled, "openapi:oneOf") assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) @@ -193,22 +193,22 @@ func assertUnionBesideRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) // already and loses nothing, and asserts nothing about `string` at all. func assertElectedLoweringKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { t.Helper() - scalarType, ok := doc.Types[namedID("AllOfWithScalarType")].(*ir.Model) + scalarType, ok := doc.Types[componentID("AllOfWithScalarType")].(*ir.Model) require.True(t, ok) assert.JSONEq(t, `"string"`, string(unmodeledEntry(t, scalarType.Unmodeled, "openapi:type").Value)) - objectType, ok := doc.Types[namedID("AllOfWithObjectType")].(*ir.Model) + objectType, ok := doc.Types[componentID("AllOfWithObjectType")].(*ir.Model) require.True(t, ok) assert.Empty(t, objectType.Unmodeled, "the composed Model already asserts `object`") assert.Empty(t, diagsAt(diags, "openapi/degraded-construct", "/components/schemas/AllOfWithObjectType")) - format, ok := doc.Types[namedID("ConstWithFormat")].(*ir.Literal) + format, ok := doc.Types[componentID("ConstWithFormat")].(*ir.Literal) require.True(t, ok) assert.JSONEq(t, `"int32"`, string(unmodeledEntry(t, format.Unmodeled, "openapi:format").Value), "a Literal has no Encoding field") - bound, ok := doc.Types[namedID("ConstWithBound")].(*ir.Literal) + bound, ok := doc.Types[componentID("ConstWithBound")].(*ir.Literal) require.True(t, ok) assert.JSONEq(t, `1`, string(unmodeledEntry(t, bound.Unmodeled, "openapi:maxLength").Value), "nor a Constraints field, and it owns its pointer so no alias carries one either") @@ -223,7 +223,7 @@ func assertElectedLoweringKeywords(t *testing.T, doc *ir.Document, diags []ir.Di // construct as a branch composed an *open* empty model — the most permissive // shape the IR has, for a source that admits nothing. func assertAllOfBooleanBranch(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - never, ok := doc.Types[namedID("Never")].(*ir.Model) + never, ok := doc.Types[componentID("Never")].(*ir.Model) require.True(t, ok) assert.Equal(t, ir.AdditionalClosed, never.Additional, "a false conjunct closes the composition, as a bare false schema closes its own model") @@ -236,14 +236,14 @@ func assertAllOfBooleanBranch(t *testing.T, doc *ir.Document, diags []ir.Diagnos _, ok = propByWire(never, "id") assert.True(t, ok, "the other branch still contributes; closing is not emptying") - always, ok := doc.Types[namedID("Always")].(*ir.Model) + always, ok := doc.Types[componentID("Always")].(*ir.Model) require.True(t, ok) assert.Empty(t, always.Additional, "a true conjunct constrains nothing, so it closes nothing") assert.Empty(t, always.Unmodeled, "and there is nothing about it to keep") assert.Empty(t, diagsAt(diags, "openapi/false-schema", "/components/schemas/Always/allOf/0"), "nor anything to report") - bare, ok := doc.Types[namedID("BareFalse")].(*ir.Model) + bare, ok := doc.Types[componentID("BareFalse")].(*ir.Model) require.True(t, ok) assert.Equal(t, ir.AdditionalClosed, bare.Additional, "the rule the composed case is held to") assert.Empty(t, bare.Properties) @@ -261,7 +261,7 @@ func assertAllOfBooleanBranch(t *testing.T, doc *ir.Document, diags []ir.Diagnos // And the claim that motivates it: the two shapes are now distinguishable. // Both are closed empty models, so nothing but the preserved schema separates // them, and asserting the pair is what stops the entry being dropped again. - closed, ok := doc.Types[namedID("ClosedEmpty")].(*ir.Model) + closed, ok := doc.Types[componentID("ClosedEmpty")].(*ir.Model) require.True(t, ok) assert.Equal(t, bare.Additional, closed.Additional, "the two agree on everything else") assert.Empty(t, closed.Properties) @@ -279,7 +279,7 @@ func assertAllOfBooleanBranch(t *testing.T, doc *ir.Document, diags []ir.Diagnos // object contradicts the ir.Model the composition produced, so it is a warning; // a branch that only narrows the model further is info. func assertAllOfInlineResidue(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - narrowed, ok := doc.Types[namedID("Narrowed")].(*ir.Model) + narrowed, ok := doc.Types[componentID("Narrowed")].(*ir.Model) require.True(t, ok) require.NotNil(t, narrowed.Base, "the $ref branch still becomes Base") _, ok = propByWire(narrowed, "name") @@ -292,7 +292,7 @@ func assertAllOfInlineResidue(t *testing.T, doc *ir.Document, diags []ir.Diagnos diagsAt(diags, "openapi/degraded-construct", "/components/schemas/Narrowed/allOf/1"), "a branch that only narrows the model is info") - contradicted, ok := doc.Types[namedID("Contradicted")].(*ir.Model) + contradicted, ok := doc.Types[componentID("Contradicted")].(*ir.Model) require.True(t, ok) entry = unmodeledEntry(t, contradicted.Unmodeled, "openapi:allOf/1") assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) @@ -313,7 +313,7 @@ func assertAllOfInlineResidue(t *testing.T, doc *ir.Document, diags []ir.Diagnos // fold's one default slot: the second declaration is kept whole and the // disagreement named. func assertAllOfConflictingType(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - repo, ok := doc.Types[namedID("Repository")].(*ir.Model) + repo, ok := doc.Types[componentID("Repository")].(*ir.Model) require.True(t, ok) clone, ok := propByWire(repo, "clone_url") require.True(t, ok, "the two declarations still reconcile to one property") @@ -329,7 +329,7 @@ func assertAllOfConflictingType(t *testing.T, doc *ir.Document, diags []ir.Diagn "/components/schemas/Repository/allOf/1/properties/clone_url"), "and the conflict is still reported") - identified, ok := doc.Types[namedID("Identified")].(*ir.Model) + identified, ok := doc.Types[componentID("Identified")].(*ir.Model) require.True(t, ok) id, ok := propByWire(identified, "id") require.True(t, ok) @@ -340,7 +340,7 @@ func assertAllOfConflictingType(t *testing.T, doc *ir.Document, diags []ir.Diagn "openapi:conflicting-redeclaration/components/schemas/Identified/allOf/1/properties/id", `{"type":["string","null"]}`) - described, ok := doc.Types[namedID("Described")].(*ir.Model) + described, ok := doc.Types[componentID("Described")].(*ir.Model) require.True(t, ok) did, ok := propByWire(described, "id") require.True(t, ok) @@ -356,7 +356,7 @@ func assertAllOfConflictingType(t *testing.T, doc *ir.Document, diags []ir.Diagn diagsAt(diags, "openapi/degraded-construct", describedPtr), "the title, the description, the external link, the default and the example are each named as differing") - overlapping, ok := doc.Types[namedID("Overlapping")].(*ir.Model) + overlapping, ok := doc.Types[componentID("Overlapping")].(*ir.Model) require.True(t, ok) n, ok := propByWire(overlapping, "n") require.True(t, ok) @@ -503,7 +503,7 @@ func assertInadmissibleDefault(t *testing.T, doc *ir.Document, diags []ir.Diagno // narrowedProp returns property p of the named component model. func narrowedProp(t *testing.T, doc *ir.Document, model string) ir.Property { t.Helper() - m, ok := doc.Types[namedID(model)].(*ir.Model) + m, ok := doc.Types[componentID(model)].(*ir.Model) require.True(t, ok, "%s is a model", model) p, ok := propByWire(m, "p") require.True(t, ok, "%s reconciles to one property", model) @@ -518,7 +518,7 @@ func narrowedProp(t *testing.T, doc *ir.Document, model string) ir.Property { // bare branch pins the other half: a `$ref` writing nothing beside itself still // composes straight to the target, costing no node. func assertAllOfRefBranchSiblings(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - annotated, ok := doc.Types[namedID("Annotated")].(*ir.Model) + annotated, ok := doc.Types[componentID("Annotated")].(*ir.Model) require.True(t, ok) require.NotNil(t, annotated.Base, "the $ref branch still composes as Base") assert.Equal(t, ir.TypeID("t/anon/components/schemas/Annotated/allOf/0"), annotated.Base.Target, @@ -527,7 +527,7 @@ func assertAllOfRefBranchSiblings(t *testing.T, doc *ir.Document, diags []ir.Dia branch, ok := doc.Types[annotated.Base.Target].(*ir.Scalar) require.True(t, ok, "the branch position hoists an alias over its target") require.NotNil(t, branch.Base) - assert.Equal(t, namedID("Base"), branch.Base.Target, "the alias still resolves to the referenced schema") + assert.Equal(t, componentID("Base"), branch.Base.Target, "the alias still resolves to the referenced schema") assert.Equal(t, "Base as this composition uses it", branch.Docs.Description) require.NotNil(t, branch.Constraints) require.NotNil(t, branch.Constraints.MinProps) @@ -550,7 +550,7 @@ func assertAllOfRefBranchSiblings(t *testing.T, doc *ir.Document, diags []ir.Dia // preserveUnionSiblings (oneOf/anyOf), reused rather than duplicated. func assertAllOfRefBranchUnion(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { t.Helper() - union, ok := doc.Types[namedID("UnionBranch")].(*ir.Model) + union, ok := doc.Types[componentID("UnionBranch")].(*ir.Model) require.True(t, ok) require.NotNil(t, union.Base, "the branch still composes as Base") assert.Equal(t, ir.TypeID("t/anon/components/schemas/UnionBranch/allOf/0"), union.Base.Target, @@ -558,7 +558,7 @@ func assertAllOfRefBranchUnion(t *testing.T, doc *ir.Document, diags []ir.Diagno branch, ok := doc.Types[union.Base.Target].(*ir.Scalar) require.True(t, ok, "the branch position hoists an alias over its target") require.NotNil(t, branch.Base) - assert.Equal(t, namedID("Base"), branch.Base.Target, "the alias still resolves to the referenced schema") + assert.Equal(t, componentID("Base"), branch.Base.Target, "the alias still resolves to the referenced schema") entry := unmodeledEntry(t, branch.Unmodeled, "openapi:oneOf") assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) assert.JSONEq(t, `[{"type":"string"},{"type":"integer"}]`, string(entry.Value)) @@ -567,14 +567,14 @@ func assertAllOfRefBranchUnion(t *testing.T, doc *ir.Document, diags []ir.Diagno "/components/schemas/UnionBranch/allOf/0"), unionWhy) - nested, ok := doc.Types[namedID("NestedAllOfBranch")].(*ir.Model) + nested, ok := doc.Types[componentID("NestedAllOfBranch")].(*ir.Model) require.True(t, ok) require.NotNil(t, nested.Base) assert.Equal(t, ir.TypeID("t/anon/components/schemas/NestedAllOfBranch/allOf/0"), nested.Base.Target) nestedBranch, ok := doc.Types[nested.Base.Target].(*ir.Scalar) require.True(t, ok) require.NotNil(t, nestedBranch.Base) - assert.Equal(t, namedID("Base"), nestedBranch.Base.Target) + assert.Equal(t, componentID("Base"), nestedBranch.Base.Target) entry = unmodeledEntry(t, nestedBranch.Unmodeled, "openapi:allOf") assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) assert.JSONEq(t, `[{"type":"string"}]`, string(entry.Value)) @@ -588,18 +588,18 @@ func assertAllOfRefBranchUnion(t *testing.T, doc *ir.Document, diags []ir.Diagno // does not reach: a mixin position, and a bare $ref that hoists nothing. func assertAllOfRefBranchShapes(t *testing.T, doc *ir.Document) { t.Helper() - mixed, ok := doc.Types[namedID("Mixed")].(*ir.Model) + mixed, ok := doc.Types[componentID("Mixed")].(*ir.Model) require.True(t, ok) require.Len(t, mixed.Mixins, 2, "two unqualified refs stay mixins") assert.Equal(t, ir.TypeID("t/anon/components/schemas/Mixed/allOf/0"), mixed.Mixins[0].Target, "a mixin's siblings get the same home a base's do") - assert.Equal(t, namedID("Extra"), mixed.Mixins[1].Target, + assert.Equal(t, componentID("Extra"), mixed.Mixins[1].Target, "the branch writing nothing beside its $ref is untouched") - bare, ok := doc.Types[namedID("Bare")].(*ir.Model) + bare, ok := doc.Types[componentID("Bare")].(*ir.Model) require.True(t, ok) require.NotNil(t, bare.Base) - assert.Equal(t, namedID("Base"), bare.Base.Target, + assert.Equal(t, componentID("Base"), bare.Base.Target, "a bare $ref branch composes straight to its target, hoisting no node") _, hoisted := doc.Types[ir.TypeID("t/anon/components/schemas/Bare/allOf/0")] assert.False(t, hoisted, "nothing to keep, so no node for the branch position") @@ -614,13 +614,13 @@ func assertParamXMLResidue(t *testing.T, doc *ir.Document, diags []ir.Diagnostic require.True(t, ok) require.Len(t, op.Params, 2) - tag, ok := paramByName(op, "tag") + tag, ok := openapitest.ParamBySource(op, "tag") require.True(t, ok) entry := unmodeledEntry(t, tag.Unmodeled, "openapi:xml") assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) assert.JSONEq(t, `{"name":"Tag","namespace":"urn:example:docs"}`, string(entry.Value)) - body, ok := paramByName(op, "body") + body, ok := openapitest.ParamBySource(op, "body") require.True(t, ok) entry = unmodeledEntry(t, body.Unmodeled, "openapi:xml") assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) @@ -641,7 +641,7 @@ func assertParamXMLResidue(t *testing.T, doc *ir.Document, diags []ir.Diagnostic // keyword. The library binds no field for it, which is why it was the one member // of that family being dropped silently (GitHub #125). func assertDependentRequired(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - card, ok := doc.Types[namedID("Card")].(*ir.Model) + card, ok := doc.Types[componentID("Card")].(*ir.Model) require.True(t, ok, "the structural body still lowers") assert.Len(t, card.Properties, 2) entry := unmodeledEntry(t, card.Unmodeled, "openapi:dependentRequired") @@ -657,14 +657,14 @@ func assertDependentRequired(t *testing.T, doc *ir.Document, diags []ir.Diagnost // with no Encoding field at all keeps all three verbatim (GitHub #125, // GitHub #426). func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - thumb, ok := doc.Types[namedID("Thumbnail")].(*ir.Scalar) + thumb, ok := doc.Types[componentID("Thumbnail")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, thumb.Encoding) assert.Equal(t, "base64", thumb.Encoding.Name) assert.Equal(t, "image/png", thumb.Encoding.MediaType) assert.Empty(t, thumb.Unmodeled, "a string position lowers both keywords structurally") - env, ok := doc.Types[namedID("Envelope")].(*ir.Scalar) + env, ok := doc.Types[componentID("Envelope")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, env.Encoding) assert.Equal(t, "application/json", env.Encoding.MediaType) @@ -675,7 +675,7 @@ func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) require.Len(t, decoded.Properties, 1) assert.Equal(t, "id", decoded.Properties[0].WireName) - bag, ok := doc.Types[namedID("Bag")].(*ir.Model) + bag, ok := doc.Types[componentID("Bag")].(*ir.Model) require.True(t, ok) for _, key := range []string{"openapi:contentEncoding", "openapi:contentMediaType", "openapi:contentSchema"} { assert.Equal(t, ir.ReasonNoIRHome, unmodeledEntry(t, bag.Unmodeled, key).Reason, @@ -686,7 +686,7 @@ func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) // under it is still spelled from the declaration: the order-invariance oracle // is what proves the two orders agree, and this is what says which spelling // won (§4.3). - feed, ok := doc.Types[namedID("Feed")].(*ir.Scalar) + feed, ok := doc.Types[componentID("Feed")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, feed.Encoding) require.NotNil(t, feed.Encoding.Schema) @@ -701,7 +701,7 @@ func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) // of scope rather than a gap: the IR identifies types by pointer-derived ID and // describes one API surface, so it has no axis for these and none is coming. func assertDialectKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - res, ok := doc.Types[namedID("Resource")].(*ir.Model) + res, ok := doc.Types[componentID("Resource")].(*ir.Model) require.True(t, ok) for _, keyword := range []string{"$id", "$schema", "$vocabulary"} { entry := unmodeledEntry(t, res.Unmodeled, "openapi:"+keyword) @@ -728,11 +728,11 @@ func assertSchemaIdentifiers(t *testing.T, doc *ir.Document, diags []ir.Diagnost assert.NotEqual(t, ir.SeverityError, d.Severity, "every identifier resolves: %s", d.Message) } for model, props := range map[string]map[string]ir.TypeID{ - "Pet": {"owner": namedID("Person"), "tag": namedID("Tag"), "address": namedID("Address")}, - "Address": {"country": namedID("Country"), "next": namedID("Address"), + "Pet": {"owner": componentID("Person"), "tag": componentID("Tag"), "address": componentID("Address")}, + "Address": {"country": componentID("Country"), "next": componentID("Address"), "zip": ids.Document{}.ForPointer("/components/schemas/Address/$defs/zip")}, } { - m, ok := doc.Types[namedID(model)].(*ir.Model) + m, ok := doc.Types[componentID(model)].(*ir.Model) require.True(t, ok, model) for wire, want := range props { p, ok := propByWire(m, wire) @@ -755,12 +755,12 @@ func assertSchemaIdentifiers(t *testing.T, doc *ir.Document, diags []ir.Diagnost // makes '%2D' and '-' one character, so it must reach the same anchor the plain // spelling would (GitHub #233). func assertDynamicRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - tree, ok := doc.Types[namedID("Tree")].(*ir.Model) + tree, ok := doc.Types[componentID("Tree")].(*ir.Model) require.True(t, ok) child, ok := propByWire(tree, "child") require.True(t, ok) - assert.Equal(t, namedID("Node"), child.Type.Target, + assert.Equal(t, componentID("Node"), child.Type.Target, "the single matching $dynamicAnchor is the property's type") assert.Empty(t, child.Unmodeled, "an expanded reference must not also be preserved") assert.Equal(t, []ir.Severity{ir.SeverityInfo}, diagsAt(diags, "openapi/dynamic-ref-expanded", @@ -768,7 +768,7 @@ func assertDynamicRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { escaped, ok := propByWire(tree, "escaped") require.True(t, ok) - assert.Equal(t, namedID("Leaf"), escaped.Type.Target, + assert.Equal(t, componentID("Leaf"), escaped.Type.Target, "a percent-encoded fragment names the anchor its decoded spelling names") assert.Empty(t, escaped.Unmodeled, "an expanded reference must not also be preserved") assert.Equal(t, []ir.Severity{ir.SeverityInfo}, diagsAt(diags, "openapi/dynamic-ref-expanded", @@ -829,7 +829,7 @@ func assertInlineResidue(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { "openapi:default": `"x"`, "openapi:readOnly": `true`, }) - batch, ok := doc.Types[namedID("Batch")].(*ir.List) + batch, ok := doc.Types[componentID("Batch")].(*ir.List) require.True(t, ok) elem, ok := doc.Types[batch.Elem.Target] require.True(t, ok, "the annotated element owns a node") @@ -837,7 +837,7 @@ func assertInlineResidue(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { "openapi:default": `7`, "openapi:writeOnly": `true`, }) - carried, ok := doc.Types[namedID("Carried")].(*ir.Model) + carried, ok := doc.Types[componentID("Carried")].(*ir.Model) require.True(t, ok) p, ok := propByWire(carried, "p") require.True(t, ok) @@ -910,20 +910,20 @@ func assertResponseLinks(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // ReasonDegradedLowering, and the position says so. func assertCoDeclaredKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { base := `[{"$ref":"#/components/schemas/Base"}]` - e, ok := doc.Types[namedID("NarrowedEnum")].(*ir.Enum) + e, ok := doc.Types[componentID("NarrowedEnum")].(*ir.Enum) require.True(t, ok, "the enum is the value") assertKeptRaw(t, e.Unmodeled, "openapi:allOf", base) - lit, ok := doc.Types[namedID("NarrowedConst")].(*ir.Literal) + lit, ok := doc.Types[componentID("NarrowedConst")].(*ir.Literal) require.True(t, ok, "const outranks the composition beside it") assertKeptRaw(t, lit.Unmodeled, "openapi:allOf", base) - within, ok := doc.Types[namedID("ConstWithinEnum")].(*ir.Literal) + within, ok := doc.Types[componentID("ConstWithinEnum")].(*ir.Literal) require.True(t, ok, "const is the narrower of the two") assertKeptRaw(t, within.Unmodeled, "openapi:enum", `["a","b"]`) anyOf := `[{"type":"number"},{"type":"boolean"}]` - both, ok := doc.Types[namedID("BothCombinators")].(*ir.Union) + both, ok := doc.Types[componentID("BothCombinators")].(*ir.Union) require.True(t, ok, "oneOf becomes the union") assert.Len(t, both.Variants, 2, "with a variant per oneOf branch") assertKeptRaw(t, both.Unmodeled, "openapi:anyOf", anyOf) @@ -931,7 +931,7 @@ func assertCoDeclaredKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnos // Suppressing the {X, null} collapse is what gives this one a node of its // own: collapsing would resolve the position to the shared string primitive, // which must never carry one declaration's keywords. - nullable, ok := doc.Types[namedID("NullableWithCombinators")].(*ir.Union) + nullable, ok := doc.Types[componentID("NullableWithCombinators")].(*ir.Union) require.True(t, ok, "a co-declared anyOf stops the {X, null} collapse") assert.Len(t, nullable.Variants, 1, "the null branch still lifts off the variant list") assertKeptRaw(t, nullable.Unmodeled, "openapi:anyOf", anyOf) @@ -1001,7 +1001,7 @@ func assertNullOnlyUnion(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) // at the same position — both lower to. func assertNullOnlyScalar(t *testing.T, doc *ir.Document, name string) *ir.Scalar { t.Helper() - s, ok := doc.Types[namedID(name)].(*ir.Scalar) + s, ok := doc.Types[componentID(name)].(*ir.Scalar) require.True(t, ok, "%s lowers to a scalar over any, the same node a bare "+ "`{type: null}` schema at this position would", name) require.NotNil(t, s.Base) @@ -1026,7 +1026,7 @@ func assertCoDeclaredSchemaContent(t *testing.T, doc *ir.Document, diags []ir.Di base := "/paths/~1x/get/parameters/" for i, name := range []string{"p", "q"} { - param, found := paramByName(op, name) + param, found := openapitest.ParamBySource(op, name) require.True(t, found, "parameter %s", name) assert.Equal(t, ir.TypeID("t/prim/string"), param.Type.Target, "parameter %s takes its type from the elected content entry, not from the schema", name) diff --git a/compilers/openapi/e2e/conformance_values_test.go b/compilers/openapi/e2e/conformance_values_test.go index 337c3273..ec2a3f2a 100644 --- a/compilers/openapi/e2e/conformance_values_test.go +++ b/compilers/openapi/e2e/conformance_values_test.go @@ -10,7 +10,7 @@ import ( ) func assertDefaults(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) n, ok := propByWire(m, "n") require.True(t, ok) @@ -25,7 +25,7 @@ func assertDefaults(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { require.NotNil(t, viaRef.Default, "the referent's default binds the reference site") assert.Equal(t, ir.BigVal("41"), viaRef.Default.Num) - decl, ok := doc.Types[namedID("DeclaredDefault")].(*ir.Scalar) + decl, ok := doc.Types[componentID("DeclaredDefault")].(*ir.Scalar) require.True(t, ok) entry, ok := decl.Unmodeled["openapi:default"] require.True(t, ok, "the declaration's own default is kept as residue") @@ -86,18 +86,18 @@ func assertYAMLTimestampScalars(t *testing.T, doc *ir.Document, diags []ir.Diagn "the §4.7 carve-out is the only other notice this fixture expects: %+v", d) } - d, ok := doc.Types[namedID("D")].(*ir.Enum) + d, ok := doc.Types[componentID("D")].(*ir.Enum) require.True(t, ok, "D stays a closed Enum of the real dates, not a union of null literals") assert.Equal(t, ir.PrimString, d.ValueType) require.Len(t, d.Members, 2) assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, d.Members[0].Value) assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2022-02-02"}, d.Members[1].Value) - k, ok := doc.Types[namedID("K")].(*ir.Literal) + k, ok := doc.Types[componentID("K")].(*ir.Literal) require.True(t, ok, "K's const hoists a real Literal, not the schemaless top type") assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, k.Value) - s, ok := doc.Types[namedID("S")].(*ir.Model) + s, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) require.Len(t, s.Properties, 1) prop := s.Properties[0] @@ -126,7 +126,7 @@ func assertYAMLTimestampScalars(t *testing.T, doc *ir.Document, diags []ir.Diagn // nobody asked for, and the resolved form cannot be read back to the spelling. func assertRawPreservedDates(t *testing.T, doc *ir.Document) { t.Helper() - r, ok := doc.Types[namedID("R")].(*ir.Model) + r, ok := doc.Types[componentID("R")].(*ir.Model) require.True(t, ok) kept := r.Unmodeled @@ -143,7 +143,7 @@ func assertRawPreservedDates(t *testing.T, doc *ir.Document) { } func assertConstraints(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) ratio, ok := propByWire(m, "ratio") require.True(t, ok) @@ -220,7 +220,7 @@ func assertCoDeclaredBoundKept(t *testing.T, doc *ir.Document, m *ir.Model) { require.True(t, ok) assert.Empty(t, high.Unmodeled, "and neither does the side settled the other way") - alias, ok := doc.Types[namedID("Bounded")].(*ir.Scalar) + alias, ok := doc.Types[componentID("Bounded")].(*ir.Scalar) require.True(t, ok, "a component reducing to a shared primitive owns an alias node") require.NotNil(t, alias.Constraints) require.NotNil(t, alias.Constraints.Min) @@ -256,7 +256,7 @@ func assertLengthAndCollectionBounds(t *testing.T, doc *ir.Document, m *ir.Model } func assertNumericPrecision(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) + m, ok := doc.Types[componentID("S")].(*ir.Model) require.True(t, ok) // Bounds beyond float64 range and a huge integer beyond int64, exact. diff --git a/compilers/openapi/e2e/cycles_test.go b/compilers/openapi/e2e/cycles_test.go index 5efbc5ef..f590e8be 100644 --- a/compilers/openapi/e2e/cycles_test.go +++ b/compilers/openapi/e2e/cycles_test.go @@ -163,7 +163,7 @@ func TestCompile_AliasCycleIsRefused(t *testing.T) { []compilers.Source{{Path: tc.file + ".yaml", Data: readReproducer(t, tc.file)}}, compilers.Options{}) require.NoError(t, err, "a recursive anchor is a spec problem, not a Go error") assert.Nil(t, doc, "the compiler refuses to read a source whose aliases never end") - assertHasErrorCode(t, diags, diag.CyclicRef) + openapitest.AssertHasCode(t, diags, diag.CyclicRef, ir.SeverityError) assert.Equal(t, []string{tc.at}, cyclicRefSites(diags), "the alias is named once") }) } @@ -385,7 +385,7 @@ components: assert.NotEqual(t, diag.CyclicRef, d.Code, "'#x-s' names a $anchor, which the resolver's pointer walk never enters: %+v", d) } - assertHasErrorCode(t, diags, diag.UnresolvedRef) + openapitest.AssertHasCode(t, diags, diag.UnresolvedRef, ir.SeverityError) assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnresolvedRef, "/components/schemas/A"), "the reference is reported unresolved where it is written; got %+v", diags) } @@ -487,7 +487,7 @@ func TestDetectCycles_ComponentOnlyCyclesLeftToResolver(t *testing.T) { _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: tc.name + ".yaml", Data: []byte(tc.data)}}, compilers.Options{}) require.NoError(t, err) - assertHasErrorCode(t, diags, diag.UnresolvedRef) + openapitest.AssertHasCode(t, diags, diag.UnresolvedRef, ir.SeverityError) }) } } diff --git a/compilers/openapi/e2e/danglingcheck_test.go b/compilers/openapi/e2e/danglingcheck_test.go index 950649a4..31e5436c 100644 --- a/compilers/openapi/e2e/danglingcheck_test.go +++ b/compilers/openapi/e2e/danglingcheck_test.go @@ -160,7 +160,7 @@ func TestDanglingRefs_f07(t *testing.T) { t.Parallel() doc, diags := compileFile(t, danglingDir, "f07-discriminator.yaml", "f07.yaml") assert.False(t, diag.HasError(diags)) - pet, ok := doc.Types[namedID("Pet")].(*ir.Union) + pet, ok := doc.Types[componentID("Pet")].(*ir.Union) require.True(t, ok) require.NotNil(t, pet.Discriminator) target, ok := pet.Discriminator.Mapping["x"] @@ -193,7 +193,7 @@ func TestDanglingRefs_f30(t *testing.T) { func TestDanglingRefs_f10(t *testing.T) { t.Parallel() doc, _ := compileFile(t, danglingDir, "f10-refs.yaml", "f10.yaml") - foo, ok := doc.Types[namedID("Foo")].(*ir.Model) + foo, ok := doc.Types[componentID("Foo")].(*ir.Model) require.True(t, ok) x, ok := propByWire(foo, "x") require.True(t, ok) diff --git a/compilers/openapi/e2e/helpers_test.go b/compilers/openapi/e2e/helpers_test.go index 804d5359..7d4e0d1f 100644 --- a/compilers/openapi/e2e/helpers_test.go +++ b/compilers/openapi/e2e/helpers_test.go @@ -178,20 +178,15 @@ func corpusSpecs(t *testing.T) []string { return out } -// componentID is the stable TypeID of a components-named schema, or of a -// sub-schema beneath one ("Holder/properties/inner"). It stays a test-file -// copy for the reason openapitest's doc comment gives. -func componentID(name string) ir.TypeID { - return ir.TypeID("t/openapi/components/schemas/" + name) -} - // typeByName returns the named component schema's lowered TypeDef. func typeByName(doc *ir.Document, name string) ir.TypeDef { return doc.Types[componentID(name)] } -// namedID is the stable TypeID of a components-named schema. -func namedID(name string) ir.TypeID { +// componentID is the stable TypeID of a components-named schema, or of a +// sub-schema beneath one ("Holder/properties/inner"). It stays a test-file +// copy for the reason openapitest's doc comment gives. +func componentID(name string) ir.TypeID { return ir.TypeID("t/openapi/components/schemas/" + name) } @@ -258,17 +253,6 @@ func operationsNamed(doc *ir.Document, name string) []ir.Operation { return ops } -// paramByName returns the operation's logical parameter with the given source -// name. -func paramByName(op ir.Operation, name string) (ir.Parameter, bool) { - for _, p := range op.Params { - if p.Name.Source == name { - return p, true - } - } - return ir.Parameter{}, false -} - // propByWire returns the property of m with the given wire name. func propByWire(m *ir.Model, wire string) (ir.Property, bool) { for _, p := range m.Properties { @@ -370,13 +354,6 @@ func unmodeledEntry(t *testing.T, p ir.Unmodeled, key string) ir.UnmodeledEntry return entry } -// assertHasErrorCode requires diags to carry an error-severity diagnostic with -// the given code. -func assertHasErrorCode(t *testing.T, diags []ir.Diagnostic, code string) { - t.Helper() - openapitest.AssertHasCode(t, diags, code, ir.SeverityError) -} - // assertNoErrorDiags fails when any diagnostic has error severity. func assertNoErrorDiags(t *testing.T, diags []ir.Diagnostic) { t.Helper() diff --git a/compilers/openapi/e2e/load_test.go b/compilers/openapi/e2e/load_test.go index 96121dbb..644dffdd 100644 --- a/compilers/openapi/e2e/load_test.go +++ b/compilers/openapi/e2e/load_test.go @@ -40,7 +40,7 @@ func TestCompile_ResolverPanicIsADiagnostic(t *testing.T) { compilers.Options{}) require.NoError(t, err, "a malformed spec is a spec problem, not a Go error") assert.NotNil(t, doc, "resolution failure does not stop the document being lowered") - assertHasErrorCode(t, diags, diag.UnresolvedRef) + openapitest.AssertHasCode(t, diags, diag.UnresolvedRef, ir.SeverityError) } // TestCompile_AResolutionFailureIsReportedOnceAtItsRef pins the once-per-site diff --git a/compilers/openapi/e2e/nullable_widening_test.go b/compilers/openapi/e2e/nullable_widening_test.go index cbb218b4..6d6ab372 100644 --- a/compilers/openapi/e2e/nullable_widening_test.go +++ b/compilers/openapi/e2e/nullable_widening_test.go @@ -27,23 +27,11 @@ func inferred(target ir.TypeID) ir.TypeRef { // prim is the shared primitive of kind k. func prim(k ir.PrimKind) ir.TypeID { return ir.PrimTypeID(k) } -// pointersOf returns the pointers diags report code at, sorted. -func pointersOf(diags []ir.Diagnostic, code string) []jsontext.Pointer { - var out []jsontext.Pointer - for _, d := range diags { - if d.Code == code { - out = append(out, d.Provenance.Pointer) - } - } - slices.Sort(out) - return out -} - // holderProps returns the properties of the component schema Holder by wire // name. func holderProps(t *testing.T, doc *ir.Document) map[string]ir.Property { t.Helper() - m, ok := doc.Types[namedID("Holder")].(*ir.Model) + m, ok := doc.Types[componentID("Holder")].(*ir.Model) require.True(t, ok, "Holder lowers to a model") return openapitest.PropsByWire(m.Properties) } @@ -54,8 +42,8 @@ func holderProps(t *testing.T, doc *ir.Document) map[string]ir.Property { func assertNullableKeyword31(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { props := holderProps(t, doc) assert.Equal(t, inferred(prim(ir.PrimString)), props["inline"].Type) - assert.Equal(t, inferred(namedID("Name")), props["viaRef"].Type, "a target's nullable reaches its use") - assert.Equal(t, inferred(namedID("Plain")), props["besideRef"].Type, "one beside the $ref widens the usage") + assert.Equal(t, inferred(componentID("Name")), props["viaRef"].Type, "a target's nullable reaches its use") + assert.Equal(t, inferred(componentID("Plain")), props["besideRef"].Type, "one beside the $ref widens the usage") assert.Equal(t, ir.TypeRef{Target: prim(ir.PrimString), Nullable: true}, props["declared"].Type, "the type array admits null, so the bit is the document's own") assert.Equal(t, ir.TypeRef{Target: prim(ir.PrimString)}, props["notNullable"].Type) @@ -79,8 +67,8 @@ func assertNullableKeyword31(t *testing.T, doc *ir.Document, diags []ir.Diagnost "/components/schemas/Holder/properties/inline/nullable", "/components/schemas/Holder/properties/shaped/items/nullable", "/components/schemas/Name/nullable", - }, pointersOf(diags, diag.InferredNullable)) - assert.Empty(t, pointersOf(diags, diag.UnknownSchemaKeyword), "the policy reads the keyword, so none is unknown") + }, openapitest.PointersOf(diags, diag.InferredNullable)) + assert.Empty(t, openapitest.PointersOf(diags, diag.UnknownSchemaKeyword), "the policy reads the keyword, so none is unknown") } // assertNullableKeyword30 pins each 3.0 shape the declared reading gives no @@ -120,15 +108,15 @@ func assertNullableKeyword30(t *testing.T, doc *ir.Document, diags []ir.Diagnost // schema's $ref are read as 3.1 reads them and each is reported, and the // nullable among them is read by the policy, which says so once more. func assertSchemaRefSiblings30(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) require.True(t, ok) owner, ok := propByWire(pet, "owner") require.True(t, ok) - assert.Equal(t, inferred(namedID("Person")), owner.Type) + assert.Equal(t, inferred(componentID("Person")), owner.Type) assert.Equal(t, "The pet's owner.", owner.Docs.Description, "the use site's description wins") assert.Equal(t, []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleDelete, ir.LifecycleQuery}, owner.Visibility.Only) - alias := doc.Types[namedID("Owner")] + alias := doc.Types[componentID("Owner")] require.NotNil(t, alias) assert.Equal(t, "An owner.", alias.Common().Docs.Description) @@ -138,9 +126,9 @@ func assertSchemaRefSiblings30(t *testing.T, doc *ir.Document, diags []ir.Diagno "/components/schemas/Pet/properties/owner/description", "/components/schemas/Pet/properties/owner/nullable", "/components/schemas/Pet/properties/owner/readOnly", - }, pointersOf(diags, diag.LaterVersionConstruct)) + }, openapitest.PointersOf(diags, diag.LaterVersionConstruct)) assert.Equal(t, []jsontext.Pointer{"/components/schemas/Pet/properties/owner/nullable"}, - pointersOf(diags, diag.InferredNullable)) + openapitest.PointersOf(diags, diag.InferredNullable)) } // TestSchemaRefSiblings_LowerAsIn31 is GitHub #871's rule as a test: the case @@ -188,22 +176,6 @@ func compileNullable(t *testing.T, src string, off bool) (*ir.Document, []ir.Dia return doc, diags } -// holderSpec is a document of the version declaring Holder, whose property p -// is written as p, beside the schemas in rest. -func holderSpec(version, p, rest string) string { - return "openapi: " + version + ` -info: {title: t, version: "1"} -paths: {} -components: - schemas: - Plain: {type: string} - Pet: {type: object, properties: {name: {type: string}}} - Holder: - type: object - properties: - p: ` + p + "\n" + rest -} - // reportedOnceSpec references N, whose nullable 3.1 does not define, from a // property, a list's element, a union variant, a parameter and a body, with N // declared after the schemas using it or, swapped, before them. @@ -245,11 +217,11 @@ components: // with the uses and move with the order. func TestNullableWidening_AKeywordIsReportedOnceAtItself(t *testing.T) { t.Parallel() - n := inferred(namedID("N")) + n := inferred(componentID("N")) for _, swapped := range []bool{false, true} { doc, diags := compileNullable(t, reportedOnceSpec(swapped), false) assert.Equal(t, []jsontext.Pointer{"/components/schemas/N/nullable"}, - pointersOf(diags, diag.InferredNullable), "swapped: %v", swapped) + openapitest.PointersOf(diags, diag.InferredNullable), "swapped: %v", swapped) props := holderProps(t, doc) assert.Equal(t, n, props["a"].Type) @@ -294,10 +266,10 @@ func TestNullableWidening_DisabledKeepsWhatTheDialectDoesNotDefine(t *testing.T) } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - src := holderSpec(tc.version, "{type: string, nullable: true}", + src := openapitest.HolderSpec(tc.version, "{type: string, nullable: true}", " r: {$ref: '#/components/schemas/Plain', nullable: true}\n"+rest) doc, diags := compileNullable(t, src, tc.off) - assert.Equal(t, tc.kept, pointersOf(diags, diag.UnknownSchemaKeyword)) + assert.Equal(t, tc.kept, openapitest.PointersOf(diags, diag.UnknownSchemaKeyword)) props := holderProps(t, doc) for name, prop := range props { entry, ok := prop.Unmodeled["openapi:nullable"] @@ -309,7 +281,7 @@ func TestNullableWidening_DisabledKeepsWhatTheDialectDoesNotDefine(t *testing.T) assert.Equal(t, ir.ReasonOutOfScope, entry.Reason, name) assert.JSONEq(t, "true", string(entry.Value), name) } - _, keptOnN := doc.Types[namedID("N")].Common().Unmodeled["openapi:nullable"] + _, keptOnN := doc.Types[componentID("N")].Common().Unmodeled["openapi:nullable"] assert.Equal(t, slices.Contains(tc.kept, "/components/schemas/N/nullable"), keptOnN, "a nullable: false the dialect does not define is kept too") }) @@ -353,16 +325,16 @@ func TestNullableWidening_ACompositionKeepsTheMarker(t *testing.T) { t.Parallel() for _, off := range []bool{false, true} { doc, _ := compileNullable(t, compositionSpec, off) - merged, ok := doc.Types[namedID("Merged")].(*ir.Model) + merged, ok := doc.Types[componentID("Merged")].(*ir.Model) require.True(t, ok) a, ok := propByWire(merged, "a") require.True(t, ok) - narrowed, ok := doc.Types[namedID("Narrowed")].(*ir.Union) + narrowed, ok := doc.Types[componentID("Narrowed")].(*ir.Union) require.True(t, ok) require.Len(t, narrowed.Variants, 2) assert.Equal(t, ir.TypeRef{Target: "t/composed/components/schemas/Narrowed/oneOf/0"}, narrowed.Variants[0].Type, "off: %v", off) - union, ok := doc.Types[namedID("Distributed")].(*ir.Union) + union, ok := doc.Types[componentID("Distributed")].(*ir.Union) require.True(t, ok) require.Len(t, union.Variants, 2) variant := ir.TypeID("t/composed/components/schemas/Distributed/oneOf/0") diff --git a/compilers/openapi/e2e/promotion_test.go b/compilers/openapi/e2e/promotion_test.go index 5526221e..41007df2 100644 --- a/compilers/openapi/e2e/promotion_test.go +++ b/compilers/openapi/e2e/promotion_test.go @@ -38,7 +38,7 @@ func promotionCarriers(t *testing.T, doc *ir.Document) map[string]promotionCarri require.Len(t, op.Params, 1) param := op.Params[0] - model, ok := doc.Types[namedID("Old")].(*ir.Model) + model, ok := doc.Types[componentID("Old")].(*ir.Model) require.True(t, ok) prop, ok := propByWire(model, "p") require.True(t, ok) @@ -119,14 +119,14 @@ func assertEnumOpenness(t *testing.T, doc *ir.Document) { {"Fixed", true, ""}, } for _, tc := range tests { - enum, ok := doc.Types[namedID(tc.schema)].(*ir.Enum) + enum, ok := doc.Types[componentID(tc.schema)].(*ir.Enum) require.True(t, ok, "%s lowers to an enum", tc.schema) assert.Equal(t, tc.closed, enum.Closed, "%s openness", tc.schema) assert.Equal(t, tc.inferred, enum.Provenance.Inferred, "%s heuristic marker", tc.schema) } for _, schema := range []string{"Size", "Shade"} { - enum, ok := doc.Types[namedID(schema)].(*ir.Enum) + enum, ok := doc.Types[componentID(schema)].(*ir.Enum) require.True(t, ok) entry, kept := enum.Unmodeled["openapi:x-extensible-enum"] require.True(t, kept, "%s keeps the extension whether or not it was read", schema) @@ -147,7 +147,7 @@ func assertPromotionDeclined(t *testing.T, doc *ir.Document, diags []ir.Diagnost assert.Contains(t, live.Unmodeled, "openapi:x-deprecated-reason") assert.Empty(t, live.Provenance.Inferred, "nothing was inferred, so nothing is marked") - model, ok := doc.Types[namedID("Old")].(*ir.Model) + model, ok := doc.Types[componentID("Old")].(*ir.Model) require.True(t, ok) numeric, ok := propByWire(model, "n") require.True(t, ok) @@ -247,7 +247,7 @@ components: op, ok := opByName(doc, "getX") require.True(t, ok) require.NotNil(t, op.Deprecation) - enum, ok := doc.Types[namedID("E")].(*ir.Enum) + enum, ok := doc.Types[componentID("E")].(*ir.Enum) require.True(t, ok) // Read off the defaults rather than listing the pairs, so a mapping this diff --git a/compilers/openapi/e2e/unpreservable_test.go b/compilers/openapi/e2e/unpreservable_test.go index 609edc2e..156035bb 100644 --- a/compilers/openapi/e2e/unpreservable_test.go +++ b/compilers/openapi/e2e/unpreservable_test.go @@ -142,7 +142,7 @@ func TestUnpreservable_ReportsTheFailureItself(t *testing.T) { " - {type: string, maxLength: 3, x-t: " + unpreservableValue + "}\n") _, diags := parseFull(t, spec) - d, ok := firstDiagWithCode(diags, diag.UnpreservableConstruct) + d, ok := openapitest.FirstDiag(diags, diag.UnpreservableConstruct) require.True(t, ok, "the unconvertible construct is reported: %+v", diags) assert.Equal(t, ir.SeverityError, d.Severity, "a construct that reached the IR in no form at all is a losslessness failure, not a degradation") @@ -157,7 +157,7 @@ func TestUnpreservable_ReportsTheFailureItself(t *testing.T) { func TestUnpreservable_AbsentConstructIsSilent(t *testing.T) { t.Parallel() _, diags := parseFull(t, openapitest.ComponentSpec(" T:\n type: array\n items: {type: string}\n")) - _, ok := firstDiagWithCode(diags, diag.UnpreservableConstruct) + _, ok := openapitest.FirstDiag(diags, diag.UnpreservableConstruct) assert.False(t, ok, "an absent construct is not an unpreservable one: %+v", diags) } @@ -173,7 +173,7 @@ func TestUnpreservable_CompositeFailsWhole(t *testing.T) { " else: {required: [b]}\n") doc, diags := parseFull(t, spec) - _, ok := firstDiagWithCode(diags, diag.UnpreservableConstruct) + _, ok := openapitest.FirstDiag(diags, diag.UnpreservableConstruct) require.True(t, ok, "the unconvertible arm is reported: %+v", diags) for key, entry := range typeUnmodeled(t, doc, ids.Document{}.NamedType(ids.Ptr("components", "schemas", "C"))) { assert.NotContains(t, key, "if-then-else", @@ -204,16 +204,6 @@ func typeUnmodeled(t *testing.T, doc *ir.Document, id ir.TypeID) ir.Unmodeled { return td.Common().Unmodeled } -// firstDiagWithCode returns the first diagnostic carrying code. -func firstDiagWithCode(diags []ir.Diagnostic, code string) (ir.Diagnostic, bool) { - for _, d := range diags { - if d.Code == code { - return d, true - } - } - return ir.Diagnostic{}, false -} - // TestPreserve_AllReasonsReachable pins that every UnmodeledReason an OpenAPI // document can provoke is produced by some lowering here, so a consumer // switching on the enum meets no value this compiler can never emit. There is no diff --git a/compilers/openapi/helpers_internal_test.go b/compilers/openapi/helpers_internal_test.go index 4a5bb85f..37aa83ab 100644 --- a/compilers/openapi/helpers_internal_test.go +++ b/compilers/openapi/helpers_internal_test.go @@ -1,11 +1,6 @@ package openapi -import ( - "testing" - - "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/ir" -) +import "github.com/dexpace/morphic/ir" // componentID is the stable TypeID of a components-named schema, or of a // sub-schema beneath one ("Holder/properties/inner"). @@ -24,10 +19,3 @@ func componentID(name string) ir.TypeID { func typeByName(doc *ir.Document, name string) ir.TypeDef { return doc.Types[componentID(name)] } - -// assertHasErrorCode requires diags to carry an error-severity diagnostic with -// the given code. -func assertHasErrorCode(t *testing.T, diags []ir.Diagnostic, code string) { - t.Helper() - openapitest.AssertHasCode(t, diags, code, ir.SeverityError) -} diff --git a/compilers/openapi/internal/annotation/constraints_internal_test.go b/compilers/openapi/internal/annotation/constraints_internal_test.go index f4b3f725..6adaed26 100644 --- a/compilers/openapi/internal/annotation/constraints_internal_test.go +++ b/compilers/openapi/internal/annotation/constraints_internal_test.go @@ -54,12 +54,6 @@ func schemaFromYAMLUnvalidated(t *testing.T, body string) *oas3.Schema { return s } -// bigOf returns a pointer to v as a BigVal, which every numeric bound is. -func bigOf(v string) *ir.BigVal { - b := ir.BigVal(v) - return &b -} - // TestConstraints_ReadsEveryScalarKeyword pins the whole scalar set in one // place. A keyword read into the wrong field, or not read at all, is a // constraint the source declared and the IR does not carry — and the emptiness @@ -73,9 +67,9 @@ func TestConstraints_ReadsEveryScalarKeyword(t *testing.T) { require.Empty(t, diags) require.NotNil(t, got) - assert.Equal(t, bigOf("1"), got.Min) - assert.Equal(t, bigOf("9"), got.Max) - assert.Equal(t, bigOf("3"), got.MultipleOf) + assert.Equal(t, new(ir.BigVal("1")), got.Min) + assert.Equal(t, new(ir.BigVal("9")), got.Max) + assert.Equal(t, new(ir.BigVal("3")), got.MultipleOf) assert.Equal(t, new(int64(2)), got.MinLength) assert.Equal(t, new(int64(8)), got.MaxLength) assert.Equal(t, "^a", got.Pattern) @@ -109,8 +103,8 @@ func TestConstraints_KeepsTheExactLiteral(t *testing.T) { require.Empty(t, diags) require.NotNil(t, got) - assert.Equal(t, bigOf("9007199254740993"), got.Min, "an integer past float64's exact range") - assert.Equal(t, bigOf("0.30000000000000004"), got.Max, "and a decimal it cannot represent") + assert.Equal(t, new(ir.BigVal("9007199254740993")), got.Min, "an integer past float64's exact range") + assert.Equal(t, new(ir.BigVal("0.30000000000000004")), got.Max, "and a decimal it cannot represent") } // TestNumericBounds_AMalformedLiteralIsReportedNotDropped pins the error route. @@ -149,17 +143,17 @@ func TestApplyExclusive_BothDialects(t *testing.T) { { name: "3.0 boolean turns the bound beside it exclusive", exclusiveBoolean: true, body: "minimum: 1\nexclusiveMinimum: true\nmaximum: 9\nexclusiveMaximum: true\n", - wantExclMin: bigOf("1"), wantExclMax: bigOf("9"), + wantExclMin: new(ir.BigVal("1")), wantExclMax: new(ir.BigVal("9")), }, { name: "3.0 false leaves the bound inclusive", exclusiveBoolean: true, body: "minimum: 1\nexclusiveMinimum: false\n", - wantMin: bigOf("1"), + wantMin: new(ir.BigVal("1")), }, { name: "2020-12 numeric carries the bound itself", exclusiveBoolean: false, body: "exclusiveMinimum: 1\nexclusiveMaximum: 9\n", - wantExclMin: bigOf("1"), wantExclMax: bigOf("9"), + wantExclMin: new(ir.BigVal("1")), wantExclMax: new(ir.BigVal("9")), }, } for _, tc := range tests { @@ -210,7 +204,7 @@ func TestApplyExclusive_TheWrongFormForTheDialectIsReported(t *testing.T) { assert.Contains(t, diags[0].Message, "exclusiveMinimum") require.NotNil(t, got, "the sibling minimum is still read") assert.Nil(t, got.ExclusiveMin, "the mismatched value sets no bound") - assert.Equal(t, bigOf("1"), got.Min, "and it stays inclusive, unmoved") + assert.Equal(t, new(ir.BigVal("1")), got.Min, "and it stays inclusive, unmoved") }) } } @@ -249,45 +243,45 @@ func TestConstraints_CoDeclaredBoundsBothReachAField(t *testing.T) { { name: "minimum is the tighter of the pair", body: "minimum: 10\nexclusiveMinimum: 0\n", - want: ir.Constraints{Min: bigOf("10"), ExclusiveMin: bigOf("0")}, + want: ir.Constraints{Min: new(ir.BigVal("10")), ExclusiveMin: new(ir.BigVal("0"))}, }, { name: "exclusiveMinimum is the tighter of the pair", body: "minimum: 0\nexclusiveMinimum: 10\n", - want: ir.Constraints{Min: bigOf("0"), ExclusiveMin: bigOf("10")}, + want: ir.Constraints{Min: new(ir.BigVal("0")), ExclusiveMin: new(ir.BigVal("10"))}, }, { name: "equal minimums are two keywords, not one", body: "minimum: 5\nexclusiveMinimum: 5\n", - want: ir.Constraints{Min: bigOf("5"), ExclusiveMin: bigOf("5")}, + want: ir.Constraints{Min: new(ir.BigVal("5")), ExclusiveMin: new(ir.BigVal("5"))}, }, { name: "maximum is the tighter of the pair", body: "maximum: 10\nexclusiveMaximum: 100\n", - want: ir.Constraints{Max: bigOf("10"), ExclusiveMax: bigOf("100")}, + want: ir.Constraints{Max: new(ir.BigVal("10")), ExclusiveMax: new(ir.BigVal("100"))}, }, { name: "exclusiveMaximum is the tighter of the pair", body: "maximum: 100\nexclusiveMaximum: 10\n", - want: ir.Constraints{Max: bigOf("100"), ExclusiveMax: bigOf("10")}, + want: ir.Constraints{Max: new(ir.BigVal("100")), ExclusiveMax: new(ir.BigVal("10"))}, }, { name: "both sides co-declared keep all four keywords", body: "minimum: 10\nexclusiveMinimum: 20\nmaximum: 100\nexclusiveMaximum: 999\n", want: ir.Constraints{ - Min: bigOf("10"), ExclusiveMin: bigOf("20"), - Max: bigOf("100"), ExclusiveMax: bigOf("999"), + Min: new(ir.BigVal("10")), ExclusiveMin: new(ir.BigVal("20")), + Max: new(ir.BigVal("100")), ExclusiveMax: new(ir.BigVal("999")), }, }, { name: "a pair no float64 tells apart keeps both literals", body: "minimum: 9007199254740993\nexclusiveMinimum: 9007199254740992\n", - want: ir.Constraints{Min: bigOf("9007199254740993"), ExclusiveMin: bigOf("9007199254740992")}, + want: ir.Constraints{Min: new(ir.BigVal("9007199254740993")), ExclusiveMin: new(ir.BigVal("9007199254740992"))}, }, { name: "one value spelled two ways stays two keywords", body: "minimum: 1e2\nexclusiveMinimum: 100\n", - want: ir.Constraints{Min: bigOf("1e2"), ExclusiveMin: bigOf("100")}, + want: ir.Constraints{Min: new(ir.BigVal("1e2")), ExclusiveMin: new(ir.BigVal("100"))}, }, } for _, tc := range tests { @@ -318,8 +312,8 @@ func TestConstraints_ABoundNoFloatHoldsIsCarriedVerbatim(t *testing.T) { require.NotNil(t, got) want := ir.Constraints{ - Min: bigOf("1.0e2000000"), ExclusiveMin: bigOf("5"), - Max: bigOf("1e-1000001"), ExclusiveMax: bigOf("5"), + Min: new(ir.BigVal("1.0e2000000")), ExclusiveMin: new(ir.BigVal("5")), + Max: new(ir.BigVal("1e-1000001")), ExclusiveMax: new(ir.BigVal("5")), } if diff := cmp.Diff(want, *got); diff != "" { t.Errorf("constraints (-want +got):\n%s", diff) @@ -369,17 +363,17 @@ func TestApplyExclusiveFlag_ThreeZeroModifierMovesTheBound(t *testing.T) { { name: "both sides modified", body: "minimum: 10\nexclusiveMinimum: true\nmaximum: 20\nexclusiveMaximum: true\n", - want: ir.Constraints{ExclusiveMin: bigOf("10"), ExclusiveMax: bigOf("20")}, + want: ir.Constraints{ExclusiveMin: new(ir.BigVal("10")), ExclusiveMax: new(ir.BigVal("20"))}, }, { name: "only the side that wrote the modifier moves", body: "minimum: 10\nexclusiveMinimum: true\nmaximum: 20\n", - want: ir.Constraints{ExclusiveMin: bigOf("10"), Max: bigOf("20")}, + want: ir.Constraints{ExclusiveMin: new(ir.BigVal("10")), Max: new(ir.BigVal("20"))}, }, { name: "a false modifier leaves the bound where it is", body: "minimum: 10\nexclusiveMinimum: false\nmaximum: 20\nexclusiveMaximum: false\n", - want: ir.Constraints{Min: bigOf("10"), Max: bigOf("20")}, + want: ir.Constraints{Min: new(ir.BigVal("10")), Max: new(ir.BigVal("20"))}, }, } for _, tc := range tests { diff --git a/compilers/openapi/internal/auth/helpers_test.go b/compilers/openapi/internal/auth/helpers_test.go index 77b2ad3a..e43f4ab3 100644 --- a/compilers/openapi/internal/auth/helpers_test.go +++ b/compilers/openapi/internal/auth/helpers_test.go @@ -8,17 +8,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// firstDiagAt returns the first diagnostic carrying code, so a test can assert -// on its provenance pointer. -func firstDiagAt(diags []ir.Diagnostic, code string) (ir.Diagnostic, bool) { - for _, d := range diags { - if d.Code == code { - return d, true - } - } - return ir.Diagnostic{}, false -} - // messagesAt returns the message of every diagnostic carrying code, in the order // they were reported, so a caller can pin which names were reported and not only // how many. diff --git a/compilers/openapi/internal/auth/requirements_test.go b/compilers/openapi/internal/auth/requirements_test.go index a98b1110..908f99cd 100644 --- a/compilers/openapi/internal/auth/requirements_test.go +++ b/compilers/openapi/internal/auth/requirements_test.go @@ -123,7 +123,7 @@ paths: {} `) assert.Nil(t, svc.Auth, "the only option is broken; the list is not left as [{}] or as []") assert.Empty(t, doc.Auth, "no scheme is declared at all") - d, ok := firstDiagAt(diags, diag.UnresolvedRef) + d, ok := openapitest.FirstDiag(diags, diag.UnresolvedRef) require.True(t, ok, "an unresolved-ref diagnostic: %+v", diags) assert.Equal(t, jsontext.Pointer("/security/0"), d.Provenance.Pointer, "points at the requirement that named the missing scheme, not a nonexistent components entry") @@ -156,7 +156,7 @@ components: assert.Equal(t, ids.Auth("key"), svc.Auth[0].Schemes[0].Scheme, "the first option survives in place") assert.Empty(t, svc.Auth[1].Schemes, "the trailing empty option still means no-auth-is-fine") assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.UnresolvedRef, ir.SeverityError)) - d, ok := firstDiagAt(diags, diag.UnresolvedRef) + d, ok := openapitest.FirstDiag(diags, diag.UnresolvedRef) require.True(t, ok, "an unresolved-ref diagnostic: %+v", diags) assert.Equal(t, jsontext.Pointer("/security/1"), d.Provenance.Pointer, "the pointer names the broken option's own index, not the list or a constant 0") @@ -184,7 +184,7 @@ paths: require.NotEmpty(t, svc.Groups[0].Operations, "the operation was lowered") op := svc.Groups[0].Operations[0] assert.Nil(t, op.Auth, "the operation's sole option is broken; it now inherits the service default") - d, ok := firstDiagAt(diags, diag.UnresolvedRef) + d, ok := openapitest.FirstDiag(diags, diag.UnresolvedRef) require.True(t, ok, "an unresolved-ref diagnostic: %+v", diags) assert.Equal(t, jsontext.Pointer("/paths/~1x/get/security/0"), d.Provenance.Pointer, "points at the operation's own requirement, not a nonexistent components entry") diff --git a/compilers/openapi/internal/auth/schemes_test.go b/compilers/openapi/internal/auth/schemes_test.go index 18854acd..19bb609d 100644 --- a/compilers/openapi/internal/auth/schemes_test.go +++ b/compilers/openapi/internal/auth/schemes_test.go @@ -177,7 +177,7 @@ components: assert.NotContains(t, doc.Auth, ids.Auth("ghost"), "the entry interns no scheme") assert.Contains(t, doc.Auth, ids.Auth("key"), "its complete sibling still does") - d, ok := firstDiagAt(diags, diag.IncompleteSecurityScheme) + d, ok := openapitest.FirstDiag(diags, diag.IncompleteSecurityScheme) require.True(t, ok, "the refusal is reported: %+v", diags) assert.Equal(t, ir.SeverityError, d.Severity) assert.Equal(t, jsontext.Pointer("/components/securitySchemes/ghost"), d.Provenance.Pointer, @@ -357,7 +357,7 @@ components: `) assert.Equal(t, []string{diag.DegradedConstruct}, codesAtPointer(diags, "/components/securitySchemes/s/in"), "the kept field is announced exactly once, the one report of a field OpenAPI bars from no type: %+v", diags) - d, ok := firstDiagAt(diags, diag.DegradedConstruct) + d, ok := openapitest.FirstDiag(diags, diag.DegradedConstruct) require.True(t, ok) assert.Contains(t, d.Message, "in") assert.Contains(t, d.Message, "mutualTLS", "and names the type that gave it no meaning") @@ -423,7 +423,7 @@ components: require.True(t, ok, "the scheme still lowers; only the one field could not be kept") assert.Empty(t, s.Unmodeled, "nothing was kept, so nothing claims to have been") - d, ok := firstDiagAt(diags, diag.UnpreservableConstruct) + d, ok := openapitest.FirstDiag(diags, diag.UnpreservableConstruct) require.True(t, ok, "the loss is reported: %+v", diags) assert.Equal(t, ir.SeverityError, d.Severity) require.Equal(t, []string{diag.UnpreservableConstruct, "openapi/validation/validation-type-mismatch"}, diff --git a/compilers/openapi/internal/load/remotelocal_internal_test.go b/compilers/openapi/internal/load/remotelocal_internal_test.go index ae29366b..ec30d478 100644 --- a/compilers/openapi/internal/load/remotelocal_internal_test.go +++ b/compilers/openapi/internal/load/remotelocal_internal_test.go @@ -8,8 +8,8 @@ import ( soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/engine/reader" ) @@ -114,7 +114,7 @@ func TestExternal_DoRefusesANetworkDocumentReachingALocalFile(t *testing.T) { // as a reference. func TestReachesLocal_PassesALocalDocumentAndANetworkOne(t *testing.T) { t.Parallel() - local := mustParse(t, remoteDoc("/etc/passwd")) + local := openapitest.YAMLDocument(t, remoteDoc("/etc/passwd")) _, refuse := reachesLocal(local, "/dir/a.yaml") assert.False(t, refuse, "a local document's own $refs are not judged against the network rule") @@ -124,7 +124,7 @@ func TestReachesLocal_PassesALocalDocumentAndANetworkOne(t *testing.T) { " C: {$id: './v2/', $ref: '#/components/schemas/A'}\n" + " D: {$id: 'urn:example:d', $ref: './d.yaml#/x'}\n" + " E: {description: '/etc/passwd', default: 'file:///etc/passwd'}\n" - _, refuse = reachesLocal(mustParse(t, onHost), "http://host/dir/a.yaml") + _, refuse = reachesLocal(openapitest.YAMLDocument(t, onHost), "http://host/dir/a.yaml") assert.False(t, refuse, "a network document staying on the host reaches no local file") } @@ -178,11 +178,3 @@ func TestLoad_ANetworkDocumentReachingALocalFileIsRefusedAndReadsNothing(t *test assert.NotContains(t, unresolved[0], "./local.yaml", "the source's own reference to the file resolves") } } - -// mustParse parses src as a document tree, failing the test if it will not. -func mustParse(t *testing.T, src string) *yaml.Node { - t.Helper() - var root yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(src), &root)) - return &root -} diff --git a/compilers/openapi/internal/merge/constraints_internal_test.go b/compilers/openapi/internal/merge/constraints_internal_test.go index 740e3260..731213bf 100644 --- a/compilers/openapi/internal/merge/constraints_internal_test.go +++ b/compilers/openapi/internal/merge/constraints_internal_test.go @@ -41,29 +41,29 @@ func TestBigValConflictDetail_ComparesByMagnitude(t *testing.T) { a, b *ir.BigVal want string }{ - {name: "only one side bounds", keyword: "minimum", a: bigVal("10")}, + {name: "only one side bounds", keyword: "minimum", a: new(ir.BigVal("10"))}, {name: "neither side bounds", keyword: "minimum"}, { name: "the same magnitude, spelled differently", - keyword: "minimum", a: bigVal("10"), b: bigVal("10.0"), + keyword: "minimum", a: new(ir.BigVal("10")), b: new(ir.BigVal("10.0")), }, { - name: "differing magnitudes", keyword: "minimum", a: bigVal("10"), b: bigVal("20"), + name: "differing magnitudes", keyword: "minimum", a: new(ir.BigVal("10")), b: new(ir.BigVal("20")), want: "conflicting minimum (10 and 20)", }, { name: "the exclusive bound reports under its own keyword", - keyword: "exclusiveMinimum", a: bigVal("10"), b: bigVal("20"), + keyword: "exclusiveMinimum", a: new(ir.BigVal("10")), b: new(ir.BigVal("20")), want: "conflicting exclusiveMinimum (10 and 20)", }, { name: "multipleOf shares the comparison", - keyword: "multipleOf", a: bigVal("3"), b: bigVal("5"), + keyword: "multipleOf", a: new(ir.BigVal("3")), b: new(ir.BigVal("5")), want: "conflicting multipleOf (3 and 5)", }, { name: "unparseable operands compare exactly", - keyword: "minimum", a: bigVal("nan"), b: bigVal("other"), + keyword: "minimum", a: new(ir.BigVal("nan")), b: new(ir.BigVal("other")), want: "conflicting minimum (nan and other)", }, } diff --git a/compilers/openapi/internal/merge/fold_cases_internal_test.go b/compilers/openapi/internal/merge/fold_cases_internal_test.go index 75346ef4..651d12ef 100644 --- a/compilers/openapi/internal/merge/fold_cases_internal_test.go +++ b/compilers/openapi/internal/merge/fold_cases_internal_test.go @@ -14,12 +14,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// bigVal returns a pointer to v, which the optional constraint bounds take. -func bigVal(v string) *ir.BigVal { - b := ir.BigVal(v) - return &b -} - // TestFold_AConstraintDisagreementIsReported pins the second of the two // conflict routes. Two declarations that agree on type but pin one keyword to // different values fold to the first one's bound, so the other, which may be @@ -30,18 +24,18 @@ func TestFold_AConstraintDisagreementIsReported(t *testing.T) { dst := ir.Property{ WireName: "age", Provenance: ir.Provenance{Pointer: "/components/schemas/A/properties/age"}, - Constraints: &ir.Constraints{Min: bigVal("10")}, + Constraints: &ir.Constraints{Min: new(ir.BigVal("10"))}, } src := ir.Property{ WireName: "age", Provenance: ptrAt("/components/schemas/B/properties/age"), - Constraints: &ir.Constraints{Min: bigVal("20")}, + Constraints: &ir.Constraints{Min: new(ir.BigVal("20"))}, } dst = foldTwo(g, dst, src, written(`{"minimum":20}`)) require.Len(t, *recorded, 1) - assert.Equal(t, bigVal("10"), dst.Constraints.Min, "the first bound is kept") + assert.Equal(t, new(ir.BigVal("10")), dst.Constraints.Min, "the first bound is kept") assert.Equal(t, ir.SeverityWarning, (*recorded)[0].Severity) assert.Equal(t, diag.ConflictingRedecl, (*recorded)[0].Code) assert.Contains(t, (*recorded)[0].Message, "conflicting minimum (10 and 20)") diff --git a/compilers/openapi/internal/merge/fold_narrowing_internal_test.go b/compilers/openapi/internal/merge/fold_narrowing_internal_test.go index 358d85a4..6f03c97d 100644 --- a/compilers/openapi/internal/merge/fold_narrowing_internal_test.go +++ b/compilers/openapi/internal/merge/fold_narrowing_internal_test.go @@ -145,7 +145,7 @@ func TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail(t *testing.T) { WireName: "x", Type: primRef(ir.PrimNumber), Default: &ir.Value{Kind: ir.ValueNumber, Num: "1"}, - Constraints: &ir.Constraints{Min: bigVal("0")}, + Constraints: &ir.Constraints{Min: new(ir.BigVal("0"))}, Provenance: ptrAt("/allOf/0/properties/x"), } narrow := ir.Property{WireName: "x", Type: primRef(ir.PrimInteger), Provenance: ptrAt("/allOf/1/properties/x")} @@ -158,7 +158,7 @@ func TestFold_ANarrowingAdoptsTheBroaderDeclarationsDetail(t *testing.T) { assert.Equal(t, primRef(ir.PrimInteger).Target, dst.Type.Target) require.NotNil(t, dst.Constraints) - assert.Equal(t, bigVal("0"), dst.Constraints.Min, "the broader declaration's bound lands on the merged field") + assert.Equal(t, new(ir.BigVal("0")), dst.Constraints.Min, "the broader declaration's bound lands on the merged field") require.NotNil(t, dst.Default) assert.Equal(t, ir.BigVal("1"), dst.Default.Num, "and so does its default") assert.Empty(t, *recorded) diff --git a/compilers/openapi/internal/openapitest/result.go b/compilers/openapi/internal/openapitest/result.go index a672f3b1..ea07573f 100644 --- a/compilers/openapi/internal/openapitest/result.go +++ b/compilers/openapi/internal/openapitest/result.go @@ -35,6 +35,17 @@ func FirstOp(t TB, svc ir.Service) ir.Operation { return svc.Groups[0].Operations[0] } +// ParamBySource returns the operation's logical parameter with the given +// source name. +func ParamBySource(op ir.Operation, name string) (ir.Parameter, bool) { + for _, p := range op.Params { + if p.Name.Source == name { + return p, true + } + } + return ir.Parameter{}, false +} + // IndexBy builds a lookup keyed by key(item), the shape behind every // hand-rolled "m := map[K]T{}; for _, x := range xs { m[key(x)] = x }" loop // these suites used to repeat per test. @@ -88,6 +99,17 @@ func HasDiag(diags []ir.Diagnostic, code string) bool { return slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { return d.Code == code }) } +// FirstDiag returns the first diagnostic carrying code, at any severity, so a +// test can assert on its provenance or its message. +func FirstDiag(diags []ir.Diagnostic, code string) (ir.Diagnostic, bool) { + for _, d := range diags { + if d.Code == code { + return d, true + } + } + return ir.Diagnostic{}, false +} + // HasDiagAt reports whether diags contains a diagnostic with the exact code at // the exact severity. func HasDiagAt(diags []ir.Diagnostic, code string, sev ir.Severity) bool { @@ -116,6 +138,18 @@ func CountDiagsAt(diags []ir.Diagnostic, code string, sev ir.Severity) int { return n } +// PointersOf returns the pointers diags report code at, sorted. +func PointersOf(diags []ir.Diagnostic, code string) []jsontext.Pointer { + var out []jsontext.Pointer + for _, d := range diags { + if d.Code == code { + out = append(out, d.Provenance.Pointer) + } + } + slices.Sort(out) + return out +} + // DiagMessageAt returns the message of the single diagnostic matching code, // severity and provenance pointer. Tests that only compare a diagnostic's code // cannot tell two lowerings apart when both report the same code with different diff --git a/compilers/openapi/internal/openapitest/result_test.go b/compilers/openapi/internal/openapitest/result_test.go index 3efef77b..08707cf0 100644 --- a/compilers/openapi/internal/openapitest/result_test.go +++ b/compilers/openapi/internal/openapitest/result_test.go @@ -82,6 +82,22 @@ func TestFirstOp_ReturnsTheFirstOperationOfTheFirstGroup(t *testing.T) { assert.Equal(t, "getA", openapitest.FirstOp(t, opNamed("getA", "getB")).Name.Source) } +// TestParamBySource_FindsTheParameterBySourceName covers the match and the +// miss, which reports absence rather than a zero parameter that looks found. +func TestParamBySource_FindsTheParameterBySourceName(t *testing.T) { + t.Parallel() + op := ir.Operation{Params: []ir.Parameter{ + {ID: "p/a", Name: ir.Naming{Source: "a"}}, + {ID: "p/b", Name: ir.Naming{Source: "b"}}, + }} + p, ok := openapitest.ParamBySource(op, "b") + require.True(t, ok) + assert.Equal(t, ir.ParamID("p/b"), p.ID) + + _, ok = openapitest.ParamBySource(op, "c") + assert.False(t, ok) +} + // TestIndexBy_KeysEveryItem covers the generic lookup and PropsByWire, the one // keying it is used for often enough to have a name. func TestIndexBy_KeysEveryItem(t *testing.T) { @@ -147,6 +163,23 @@ func TestHasDiag_MatchesCodeAtAnySeverity(t *testing.T) { assert.False(t, openapitest.HasDiagAt(diags, "a", ir.SeverityError)) } +// TestFirstDiag_ReturnsTheFirstCarryingTheCode pins the order, since a test +// asserts on that one diagnostic's provenance, and the miss. +func TestFirstDiag_ReturnsTheFirstCarryingTheCode(t *testing.T) { + t.Parallel() + diags := []ir.Diagnostic{ + diagAt("a", ir.SeverityInfo, "/p"), + diagAt("b", ir.SeverityError, "/q"), + diagAt("b", ir.SeverityInfo, "/r"), + } + d, ok := openapitest.FirstDiag(diags, "b") + require.True(t, ok) + assert.Equal(t, jsontext.Pointer("/q"), d.Provenance.Pointer) + + _, ok = openapitest.FirstDiag(diags, "c") + assert.False(t, ok) +} + // TestHasDiagCodeAt_RequiresTheExactPointer pins that the pointer is part of // the match, not decoration. func TestHasDiagCodeAt_RequiresTheExactPointer(t *testing.T) { @@ -169,6 +202,19 @@ func TestCountDiagsAt_CountsExactMatchesOnly(t *testing.T) { assert.Equal(t, 0, openapitest.CountDiagsAt(diags, "", ir.SeverityWarning)) } +// TestPointersOf_SortsThePointersOfOneCode pins that only the code's pointers +// are listed, and in sorted rather than report order. +func TestPointersOf_SortsThePointersOfOneCode(t *testing.T) { + t.Parallel() + diags := []ir.Diagnostic{ + diagAt("a", ir.SeverityWarning, "/r"), + diagAt("b", ir.SeverityWarning, "/q"), + diagAt("a", ir.SeverityError, "/p"), + } + assert.Equal(t, []jsontext.Pointer{"/p", "/r"}, openapitest.PointersOf(diags, "a")) + assert.Empty(t, openapitest.PointersOf(diags, "c")) +} + // TestDiagMessageAt_ReturnsTheSingleMatchingMessage pins that the message is // reachable by code, severity and pointer together. func TestDiagMessageAt_ReturnsTheSingleMatchingMessage(t *testing.T) { diff --git a/compilers/openapi/internal/openapitest/spec.go b/compilers/openapi/internal/openapitest/spec.go index 07d35510..8162602c 100644 --- a/compilers/openapi/internal/openapitest/spec.go +++ b/compilers/openapi/internal/openapitest/spec.go @@ -34,6 +34,22 @@ func ComponentSpecVer(version, schemas string) string { "components:\n schemas:\n" + schemas } +// HolderSpec is a document of the version declaring Holder, whose property p +// is written as p, beside the schemas Plain and Pet and those in rest. +func HolderSpec(version, p, rest string) string { + return "openapi: " + version + ` +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Plain: {type: string} + Pet: {type: object, properties: {name: {type: string}}} + Holder: + type: object + properties: + p: ` + p + "\n" + rest +} + // PathsSpec wraps a paths block in a minimal 3.1 document with no components. func PathsSpec(paths string) string { return PathsSpecVer("3.1.0", paths) @@ -123,6 +139,15 @@ func YAMLNode(t TB, src string) *yaml.Node { return doc.Content[0] } +// YAMLDocument parses src as a document tree and returns its document node, the +// node a loader indexes and hands on. +func YAMLDocument(t TB, src string) *yaml.Node { + t.Helper() + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &root)) + return &root +} + // SchemaFromYAML unmarshals body as a JSONSchema, keeping the reference and // boolean forms a bare *oas3.Schema cannot represent. func SchemaFromYAML(ctx context.Context, t TB, body string) *oas3.JSONSchema[oas3.Referenceable] { diff --git a/compilers/openapi/internal/openapitest/spec_test.go b/compilers/openapi/internal/openapitest/spec_test.go index f7875a59..bbc9c5e6 100644 --- a/compilers/openapi/internal/openapitest/spec_test.go +++ b/compilers/openapi/internal/openapitest/spec_test.go @@ -1,6 +1,8 @@ package openapitest_test import ( + "maps" + "slices" "strings" "testing" @@ -37,6 +39,23 @@ func TestComponentSpec_WrapsSchemasInAMinimalDocument(t *testing.T) { assert.True(t, strings.HasPrefix(openapitest.ComponentSpecVer("3.0.3", ""), "openapi: 3.0.3\n")) } +// TestHolderSpec_DeclaresHolderBesideTheRest pins the parts a caller writes: +// the version, Holder's property p, and the schemas of rest after Holder. +func TestHolderSpec_DeclaresHolderBesideTheRest(t *testing.T) { + t.Parallel() + got := openapitest.HolderSpec("3.0.3", "{type: string, nullable: true}", " Extra: {type: integer}\n") + var doc struct { + OpenAPI string `yaml:"openapi"` + Components struct { + Schemas map[string]yaml.Node `yaml:"schemas"` + } `yaml:"components"` + } + require.NoError(t, yaml.Unmarshal([]byte(got), &doc), got) + assert.Equal(t, "3.0.3", doc.OpenAPI) + assert.ElementsMatch(t, []string{"Plain", "Pet", "Holder", "Extra"}, slices.Collect(maps.Keys(doc.Components.Schemas))) + assert.Contains(t, got, " properties:\n p: {type: string, nullable: true}\n") +} + // TestPathsSpec_WrapsPathsInAMinimalDocument is the paths counterpart: no // components block, and the same settable version. func TestPathsSpec_WrapsPathsInAMinimalDocument(t *testing.T) { @@ -122,6 +141,22 @@ func TestYAMLNode_ReturnsTheRootValueNode(t *testing.T) { assert.Equal(t, "a", node.Content[0].Value) } +// TestYAMLDocument_ReturnsTheDocumentNode checks the document node is kept, not +// unwrapped as YAMLNode unwraps it, and that a fixture that does not parse +// fails the test. +func TestYAMLDocument_ReturnsTheDocumentNode(t *testing.T) { + t.Parallel() + doc := openapitest.YAMLDocument(t, "{a: 1}") + require.NotNil(t, doc) + assert.Equal(t, yaml.DocumentNode, doc.Kind) + require.Len(t, doc.Content, 1) + assert.Equal(t, yaml.MappingNode, doc.Content[0].Kind) + + rec := &recorder{} + openapitest.YAMLDocument(rec, "{a: [") + assert.True(t, rec.failed(), "a fixture that does not parse is reported") +} + // TestScalarNode_BuildsABareScalar covers both builders, StrNode being the // string-tagged case of ScalarNode. func TestScalarNode_BuildsABareScalar(t *testing.T) { diff --git a/compilers/openapi/internal/operation/helpers_test.go b/compilers/openapi/internal/operation/helpers_test.go index faaff087..e981aceb 100644 --- a/compilers/openapi/internal/operation/helpers_test.go +++ b/compilers/openapi/internal/operation/helpers_test.go @@ -128,14 +128,3 @@ paths: examples: empty: {summary: no value here} ` - -// paramBySource returns the operation's logical parameter with the given source -// name. -func paramBySource(op ir.Operation, name string) (ir.Parameter, bool) { - for _, p := range op.Params { - if p.Name.Source == name { - return p, true - } - } - return ir.Parameter{}, false -} diff --git a/compilers/openapi/internal/operation/params_identity_test.go b/compilers/openapi/internal/operation/params_identity_test.go index 3a81642e..c38752e4 100644 --- a/compilers/openapi/internal/operation/params_identity_test.go +++ b/compilers/openapi/internal/operation/params_identity_test.go @@ -554,7 +554,7 @@ paths: openapitest.RequireNoErrorDiags(t, diags) out := map[string]string{} for _, name := range []string{"a", "b"} { - p, ok := paramBySource(openapitest.FindOp(t, doc, name), "limit") + p, ok := openapitest.ParamBySource(openapitest.FindOp(t, doc, name), "limit") require.True(t, ok) out[name] = p.Docs.Description } diff --git a/compilers/openapi/internal/operation/params_schema_test.go b/compilers/openapi/internal/operation/params_schema_test.go index 0d6097b0..9a3a6132 100644 --- a/compilers/openapi/internal/operation/params_schema_test.go +++ b/compilers/openapi/internal/operation/params_schema_test.go @@ -563,12 +563,12 @@ components: doc, _, diags := compileService(t, spec) openapitest.RequireNoErrorDiags(t, diags) - withSiblings, ok := paramBySource(openapitest.FindOp(t, doc, "a"), "limit") + withSiblings, ok := openapitest.ParamBySource(openapitest.FindOp(t, doc, "a"), "limit") require.True(t, ok) assert.Equal(t, "S", withSiblings.Docs.Summary) assert.Equal(t, "D", withSiblings.Docs.Description) - without, ok := paramBySource(openapitest.FindOp(t, doc, "b"), "limit") + without, ok := openapitest.ParamBySource(openapitest.FindOp(t, doc, "b"), "limit") require.True(t, ok) assert.Empty(t, without.Docs.Summary, "no sibling means no summary is invented") assert.Equal(t, "declared", without.Docs.Description, diff --git a/compilers/openapi/internal/schema/allof_nesting_test.go b/compilers/openapi/internal/schema/allof_nesting_test.go index 71b36bfe..43de2160 100644 --- a/compilers/openapi/internal/schema/allof_nesting_test.go +++ b/compilers/openapi/internal/schema/allof_nesting_test.go @@ -14,6 +14,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // TestAllOf_NestedLowersAsFlat holds the claim of the flattening itself: a @@ -496,7 +497,7 @@ func compileView(t *testing.T, m string) (composedView, []string) { t.Helper() doc, diags := parseFull(t, openapitest.ComponentSpec(equivalenceDefs+m)) openapitest.RequireNoErrorDiags(t, diags) - return viewOf(doc, componentID("M"), 1), diagCodes(diags) + return viewOf(doc, componentID("M"), 1), irtest.DiagCodes(diags) } // equivalencePairs are (nested, flat) spellings of one composition M. Each @@ -652,15 +653,6 @@ const equivalenceDefs = ` A: {type: object, properties: {a: {type: string}}} C: {type: object, properties: {c: {type: integer}}} ` -// diagCodes lists the codes doc's compilation reported, in order. -func diagCodes(diags []ir.Diagnostic) []string { - out := make([]string, 0, len(diags)) - for _, d := range diags { - out = append(out, d.Code) - } - return out -} - // pet is a discriminated base routing to M. const pet = ` Pet: type: object diff --git a/compilers/openapi/internal/schema/constraints_test.go b/compilers/openapi/internal/schema/constraints_test.go index d7a30535..3a10d3a0 100644 --- a/compilers/openapi/internal/schema/constraints_test.go +++ b/compilers/openapi/internal/schema/constraints_test.go @@ -45,25 +45,25 @@ func TestCoDeclaredBound_BothKeywordsReachTheCarriersConstraints(t *testing.T) { name: "alias node where the minimum is the tighter", unmod: typeByName(doc, "Alias").Common().Unmodeled, bound: typeByName(doc, "Alias").(*ir.Scalar).Constraints, - read: minSide, wantIncl: bigValOf("10"), wantExcl: bigValOf("0"), + read: minSide, wantIncl: new(ir.BigVal("10")), wantExcl: new(ir.BigVal("0")), }, { name: "alias node where the exclusive minimum is the tighter", unmod: typeByName(doc, "Tight").Common().Unmodeled, bound: typeByName(doc, "Tight").(*ir.Scalar).Constraints, - read: minSide, wantIncl: bigValOf("0"), wantExcl: bigValOf("10"), + read: minSide, wantIncl: new(ir.BigVal("0")), wantExcl: new(ir.BigVal("10")), }, { name: "property where the exclusive maximum is the tighter", unmod: propertyOf(t, doc, "Holder", "low").Unmodeled, bound: propertyOf(t, doc, "Holder", "low").Constraints, - read: maxSide, wantIncl: bigValOf("100"), wantExcl: bigValOf("5"), + read: maxSide, wantIncl: new(ir.BigVal("100")), wantExcl: new(ir.BigVal("5")), }, { name: "property where the maximum is the tighter", unmod: propertyOf(t, doc, "Holder", "high").Unmodeled, bound: propertyOf(t, doc, "Holder", "high").Constraints, - read: maxSide, wantIncl: bigValOf("5"), wantExcl: bigValOf("100"), + read: maxSide, wantIncl: new(ir.BigVal("5")), wantExcl: new(ir.BigVal("100")), }, } for _, tc := range tests { @@ -150,12 +150,6 @@ func minSide(c *ir.Constraints) (incl, excl *ir.BigVal) { return c.Min, c.Exclus func maxSide(c *ir.Constraints) (incl, excl *ir.BigVal) { return c.Max, c.ExclusiveMax } -// bigValOf is the *ir.BigVal a bound assertion compares against. -func bigValOf(v string) *ir.BigVal { - b := ir.BigVal(v) - return &b -} - func TestConstraints_ExclusiveBoolean30(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpecVer("3.0.3", ` S: diff --git a/compilers/openapi/internal/schema/nulls_test.go b/compilers/openapi/internal/schema/nulls_test.go index 3ba8eb6e..37d39211 100644 --- a/compilers/openapi/internal/schema/nulls_test.go +++ b/compilers/openapi/internal/schema/nulls_test.go @@ -3,7 +3,6 @@ package schema_test import ( "encoding/json/jsontext" "fmt" - "slices" "strings" "testing" @@ -39,22 +38,6 @@ func compileNullable(t *testing.T, src string, off bool) (*ir.Document, []ir.Dia return doc, diags } -// holderSpec is a document of the version declaring Holder, whose property p -// is written as p, beside the schemas in rest. -func holderSpec(version, p, rest string) string { - return "openapi: " + version + ` -info: {title: t, version: "1"} -paths: {} -components: - schemas: - Plain: {type: string} - Pet: {type: object, properties: {name: {type: string}}} - Holder: - type: object - properties: - p: ` + p + "\n" + rest -} - // holderP is the type of Holder's property p. func holderP(t *testing.T, doc *ir.Document) ir.TypeRef { t.Helper() @@ -63,18 +46,6 @@ func holderP(t *testing.T, doc *ir.Document) ir.TypeRef { return openapitest.PropsByWire(m.Properties)["p"].Type } -// pointersOf returns the pointers diags report code at, sorted. -func pointersOf(diags []ir.Diagnostic, code string) []jsontext.Pointer { - var out []jsontext.Pointer - for _, d := range diags { - if d.Code == code { - out = append(out, d.Provenance.Pointer) - } - } - slices.Sort(out) - return out -} - // diagMessage returns the message of the first diagnostic of code in diags. func diagMessage(diags []ir.Diagnostic, code string) string { for _, d := range diags { @@ -188,10 +159,10 @@ func TestNullableWidening_EachShapeReadsByItsVersion(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - src := holderSpec(tc.version, tc.p, tc.rest) + src := openapitest.HolderSpec(tc.version, tc.p, tc.rest) for _, off := range []bool{false, true} { doc, diags := compileNullable(t, src, off) - want, reports := tc.on, pointersOf(diags, diag.InferredNullable) + want, reports := tc.on, openapitest.PointersOf(diags, diag.InferredNullable) if off { want = tc.off } @@ -219,7 +190,7 @@ func TestNullableWidening_EachShapeReadsByItsVersion(t *testing.T) { // is a union of literals as it would be with no nullable written at all. func TestNullableWidening_AnEnumsNullMemberFollowsTheReadingInForce(t *testing.T) { t.Parallel() - src := holderSpec("3.1.0", "{type: string, nullable: true, enum: [a, null]}", "") + src := openapitest.HolderSpec("3.1.0", "{type: string, nullable: true, enum: [a, null]}", "") at := ir.TypeID("t/anon/components/schemas/Holder/properties/p") on, _ := compileNullable(t, src, false) @@ -243,7 +214,7 @@ func chainSpec(version, end string, n int) string { fmt.Fprintf(&b, " A%d: {$ref: '#/components/schemas/%s'}\n", i, prev) prev = fmt.Sprintf("A%d", i) } - return holderSpec(version, "{$ref: '#/components/schemas/"+prev+"'}", b.String()) + return openapitest.HolderSpec(version, "{$ref: '#/components/schemas/"+prev+"'}", b.String()) } // TestNullableWidening_ALongChainOfAliasesReadsAsAShortOne pins how a $ref's diff --git a/compilers/openapi/internal/sourceindex/export_test.go b/compilers/openapi/internal/sourceindex/export_test.go index a5b45870..c0d6514f 100644 --- a/compilers/openapi/internal/sourceindex/export_test.go +++ b/compilers/openapi/internal/sourceindex/export_test.go @@ -1,20 +1,4 @@ package sourceindex -import ( - "testing" - - "github.com/stretchr/testify/require" - yaml "gopkg.in/yaml.v3" -) - -// Decode is the one place these tests parse source text, so a fixture written as -// YAML is indexed over exactly the tree a compile would index. -func Decode(t *testing.T, src string) *yaml.Node { - t.Helper() - var root yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(src), &root)) - return &root -} - // MaxTrackedDepth is maxTrackedDepth, for the external tests. const MaxTrackedDepth = maxTrackedDepth diff --git a/compilers/openapi/internal/sourceindex/measure_internal_test.go b/compilers/openapi/internal/sourceindex/measure_internal_test.go index d99bee83..c54e5d41 100644 --- a/compilers/openapi/internal/sourceindex/measure_internal_test.go +++ b/compilers/openapi/internal/sourceindex/measure_internal_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) @@ -25,7 +26,7 @@ func TestMeasure_MeasuresEachNodeOnce(t *testing.T) { for i := 1; i < anchors; i++ { fmt.Fprintf(&b, "a%d: &a%d [*a%d, *a%d]\n", i, i, i-1, i-1) } - root := Decode(t, b.String()) + root := openapitest.YAMLDocument(t, b.String()) idx := Build(root, MaxIndexedNodes) m := NewMeasure(MaxIndexedNodes) @@ -69,7 +70,7 @@ func TestMeasure_StopsAtItsLimit(t *testing.T) { assert.False(t, Build(root, MaxIndexedNodes).Truncated()) assert.True(t, Build(root, 3).Truncated(), "a bound the tree as written fits and its alias does not") - whole := Decode(t, "a: &x [1, 1, 1, 1, 1, 1, 1, 1]\nb: *x\n") + whole := openapitest.YAMLDocument(t, "a: &x [1, 1, 1, 1, 1, 1, 1, 1]\nb: *x\n") nodes := Build(whole, MaxIndexedNodes).Nodes() assert.False(t, Build(whole, nodes).Truncated(), "a parsed tree at its node bound, its alias measuring half of it, stays within the limit") @@ -94,7 +95,7 @@ func TestMeasure_NilAndTargetlessAliasNestNothing(t *testing.T) { // one that truncates the first measure. func TestNewMeasure_SaturatesALimitPastOverflow(t *testing.T) { t.Parallel() - root := Decode(t, "a: &x [1]\nb: *x\n") + root := openapitest.YAMLDocument(t, "a: &x [1]\nb: *x\n") assert.False(t, Build(root, 1<<61).Truncated()) assert.Equal(t, int64(math.MaxInt64), NewMeasure(math.MaxInt64).limit) diff --git a/compilers/openapi/internal/sourceindex/sourceindex_test.go b/compilers/openapi/internal/sourceindex/sourceindex_test.go index dca4f47b..67f722e2 100644 --- a/compilers/openapi/internal/sourceindex/sourceindex_test.go +++ b/compilers/openapi/internal/sourceindex/sourceindex_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/compilers/openapi/internal/sourceindex" "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) @@ -39,7 +40,7 @@ func TestBuild_EmptyDocumentIndexesNothing(t *testing.T) { // having failed. func TestBuild_WhitespaceOnlySourceIsOneEmptyNode(t *testing.T) { t.Parallel() - idx := sourceindex.Build(sourceindex.Decode(t, "\n\n\n"), sourceindex.MaxIndexedNodes) + idx := sourceindex.Build(openapitest.YAMLDocument(t, "\n\n\n"), sourceindex.MaxIndexedNodes) require.NotNil(t, idx.Root()) assert.Equal(t, yaml.Kind(0), idx.Root().Kind, "yaml.v3 leaves the node untouched") @@ -150,7 +151,7 @@ func TestAnchorCycle_AliasToAnAncestorIsFound(t *testing.T) { // use anchors at all. func TestAnchorCycle_LegalReuseIsNotACycle(t *testing.T) { t.Parallel() - root := sourceindex.Decode(t, "a: &x {p: 1}\nb: *x\n") + root := openapitest.YAMLDocument(t, "a: &x {p: 1}\nb: *x\n") assert.Empty(t, sourceindex.Build(root, sourceindex.MaxIndexedNodes).AnchorCycles(), "an alias to a node that is not an ancestor is legal reuse") } @@ -243,7 +244,7 @@ func TestBuild_TracksAncestorsAtItsDepthBound(t *testing.T) { // downstream can vary with when or how often the index was built. func TestBuild_IsAFunctionOfTheTreeAlone(t *testing.T) { t.Parallel() - root := sourceindex.Decode(t, "a: &x {p: 1}\nb: [*x, {c: 2}]\n") + root := openapitest.YAMLDocument(t, "a: &x {p: 1}\nb: [*x, {c: 2}]\n") first, second := sourceindex.Build(root, sourceindex.MaxIndexedNodes), sourceindex.Build(root, sourceindex.MaxIndexedNodes) assert.Same(t, first.Root(), second.Root()) @@ -260,7 +261,7 @@ func TestBuild_IsAFunctionOfTheTreeAlone(t *testing.T) { // node reported is the mapping itself, where the author wrote the tag. func TestTaggedMapping_LocalTagIsFound(t *testing.T) { t.Parallel() - root := sourceindex.Decode(t, "a:\n b: !content:\n c: 1\n") + root := openapitest.YAMLDocument(t, "a:\n b: !content:\n c: 1\n") want := root.Content[0].Content[1].Content[1] require.Equal(t, "!content:", want.Tag, "the fixture's tag lands where this test reads it") @@ -279,9 +280,9 @@ func TestTaggedMapping_LocalTagIsFound(t *testing.T) { func TestTaggedMapping_AnyTagButMapIsOne(t *testing.T) { t.Parallel() for name, root := range map[string]*yaml.Node{ - "!!str": sourceindex.Decode(t, "a: !!str {b: 1}\n"), - "!!set": sourceindex.Decode(t, "a: !!set {b: 1}\n"), - "at the root": sourceindex.Decode(t, "--- !x\na: 1\n"), + "!!str": openapitest.YAMLDocument(t, "a: !!str {b: 1}\n"), + "!!set": openapitest.YAMLDocument(t, "a: !!set {b: 1}\n"), + "at the root": openapitest.YAMLDocument(t, "--- !x\na: 1\n"), "assembled": ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")), } { t.Run(name, func(t *testing.T) { @@ -309,7 +310,7 @@ func TestTaggedMapping_OnlyAMappingCounts(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - assert.Empty(t, sourceindex.Build(sourceindex.Decode(t, src), sourceindex.MaxIndexedNodes).TaggedMappings()) + assert.Empty(t, sourceindex.Build(openapitest.YAMLDocument(t, src), sourceindex.MaxIndexedNodes).TaggedMappings()) }) } } @@ -319,7 +320,7 @@ func TestTaggedMapping_OnlyAMappingCounts(t *testing.T) { // chooses which a refusal names. func TestTaggedMappings_EveryOneIsListedInDocumentOrder(t *testing.T) { t.Parallel() - root := sourceindex.Decode(t, "a: !x {b: 1}\nc: !y {d: 2}\n") + root := openapitest.YAMLDocument(t, "a: !x {b: 1}\nc: !y {d: 2}\n") first, second := root.Content[0].Content[1], root.Content[0].Content[3] got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).TaggedMappings() @@ -333,7 +334,7 @@ func TestTaggedMappings_EveryOneIsListedInDocumentOrder(t *testing.T) { // where it is declared, and that is the node reported. func TestTaggedMapping_IsFoundThroughAnAnchor(t *testing.T) { t.Parallel() - root := sourceindex.Decode(t, "a: &x !t {b: 1}\nc: *x\n") + root := openapitest.YAMLDocument(t, "a: &x !t {b: 1}\nc: *x\n") anchored := root.Content[0].Content[1] got := sourceindex.Build(root, sourceindex.MaxIndexedNodes).TaggedMappings() @@ -361,7 +362,7 @@ func TestNesting_CountsTheMappingsAndSequencesOnTheDeepestPath(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, sourceindex.Build(sourceindex.Decode(t, tc.src), sourceindex.MaxIndexedNodes).Nesting()) + assert.Equal(t, tc.want, sourceindex.Build(openapitest.YAMLDocument(t, tc.src), sourceindex.MaxIndexedNodes).Nesting()) }) } } @@ -386,7 +387,7 @@ func TestNesting_AnAliasNestsAsItsTarget(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - idx := sourceindex.Build(sourceindex.Decode(t, tc.src), sourceindex.MaxIndexedNodes) + idx := sourceindex.Build(openapitest.YAMLDocument(t, tc.src), sourceindex.MaxIndexedNodes) require.Empty(t, idx.AnchorCycles()) assert.Equal(t, tc.want, idx.Nesting()) }) @@ -421,7 +422,7 @@ func TestNesting_CountsAMergeValueWhereItIsWritten(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, sourceindex.Build(sourceindex.Decode(t, tc.src), sourceindex.MaxIndexedNodes).Nesting()) + assert.Equal(t, tc.want, sourceindex.Build(openapitest.YAMLDocument(t, tc.src), sourceindex.MaxIndexedNodes).Nesting()) }) } } @@ -432,7 +433,7 @@ func TestNesting_CountsAMergeValueWhereItIsWritten(t *testing.T) { // names would fall short of the parser on both. func TestNesting_DoesNotReadAnchorNames(t *testing.T) { t.Parallel() - root := sourceindex.Decode(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") + root := openapitest.YAMLDocument(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") named := sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nesting() clearAnchors(root) diff --git a/engine/engine_concurrency_test.go b/engine/engine_concurrency_test.go index 3518bb18..87dd466e 100644 --- a/engine/engine_concurrency_test.go +++ b/engine/engine_concurrency_test.go @@ -14,6 +14,7 @@ import ( "github.com/dexpace/morphic/engine" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // concurrencyCorpus is the capability corpus the compiler's own conformance @@ -118,10 +119,10 @@ func TestEngine_ConcurrentRunDocumentSharesOneDocument(t *testing.T) { require.NoError(t, err) require.NotNil(t, res.Document, "%s lowered no document", spec) shared[i] = roundTrip(t, res.Document) - before[i] = marshalled(t, shared[i]) + before[i] = irtest.Encoded(t, shared[i]) alone, err := eng.RunDocument(ctx, roundTrip(t, res.Document), engine.RunOptions{}) require.NoError(t, err) - want[i] = marshalled(t, alone.Document) + want[i] = irtest.Encoded(t, alone.Document) } got := make([]workerRun, concurrentWorkers) @@ -141,7 +142,7 @@ func TestEngine_ConcurrentRunDocumentSharesOneDocument(t *testing.T) { } } for i, spec := range specs { - assert.Empty(t, cmp.Diff(before[i], marshalled(t, shared[i])), "the shared %s changed", filepath.Base(spec)) + assert.Empty(t, cmp.Diff(before[i], irtest.Encoded(t, shared[i])), "the shared %s changed", filepath.Base(spec)) } } @@ -164,14 +165,6 @@ func runDocuments(ctx context.Context, eng *engine.Engine, docs []*ir.Document, return workerRun{docs: out} } -// marshalled returns doc's JSON. -func marshalled(t *testing.T, doc *ir.Document) string { - t.Helper() - raw, err := json.Marshal(doc) - require.NoError(t, err) - return string(raw) -} - // workerRun is one goroutine's sweep of the corpus: the marshalled document per // spec, in the corpus order, or the first failure it hit. type workerRun struct { diff --git a/engine/reader/contract_test.go b/engine/reader/contract_test.go index fe339301..20ba6740 100644 --- a/engine/reader/contract_test.go +++ b/engine/reader/contract_test.go @@ -25,7 +25,7 @@ var ( // for a file, a file URI, a confined read, a URL, a limit and a missing file. func TestReaders_AnswerAlikeThroughTheContract(t *testing.T) { t.Parallel() - root := resolvedDir(t) + root := reader.ResolvedDir(t) inside := writeFile(t, root, "in/a.yaml", "alpha") outside := writeFile(t, root, "outside.yaml", "omega") srv, hits := countingServer(t, serveBody(t, http.StatusOK, "network")) diff --git a/engine/reader/disk_resolve_internal_test.go b/engine/reader/disk_resolve_internal_test.go index d04951d9..42d5e745 100644 --- a/engine/reader/disk_resolve_internal_test.go +++ b/engine/reader/disk_resolve_internal_test.go @@ -47,7 +47,7 @@ func TestReadRooted_RefusesALinkSwappedInAfterTheCheck(t *testing.T) { // directory cannot be read: the two cannot be put alike, so the file is // outside, as relativeTo answers for the same pair. func TestResolveWithin_RefusesAMixedPairWithNoWorkingDirectory(t *testing.T) { - root := resolved(t) + root := ResolvedDir(t) t.Chdir(root) abs := filepath.Join(root, "d", "a.yaml") vanishWorkingDirectory(t) diff --git a/engine/reader/disk_special_unix_test.go b/engine/reader/disk_special_unix_test.go index 94b0a1b9..4855a656 100644 --- a/engine/reader/disk_special_unix_test.go +++ b/engine/reader/disk_special_unix_test.go @@ -45,7 +45,7 @@ func readBounded(t *testing.T, req compilers.DocumentRequest) ([]byte, error) { // rather than hang the suite. func TestDisk_ReadDocumentRefusesAFIFOWithoutBlocking(t *testing.T) { t.Parallel() - dir := resolvedDir(t) + dir := reader.ResolvedDir(t) fifo := filepath.Join(dir, "p.yaml") require.NoError(t, syscall.Mkfifo(fifo, 0o600)) t.Cleanup(func() { diff --git a/engine/reader/disk_test.go b/engine/reader/disk_test.go index 5cebe58b..98a4b2e9 100644 --- a/engine/reader/disk_test.go +++ b/engine/reader/disk_test.go @@ -29,7 +29,7 @@ import ( // directory and link is somewhere a read of it could wrongly reach. func diskTree(t *testing.T) string { t.Helper() - root := resolvedDir(t) + root := reader.ResolvedDir(t) writeFile(t, root, "in/a.yaml", "inside") writeFile(t, root, "in/sub/b.yaml", "nested") writeFile(t, root, "in/with space.yaml", "spaced") @@ -167,7 +167,7 @@ func TestDisk_ReadDocumentConfinedToTheWorkingDirectory(t *testing.T) { // directory. It changes directory, so it runs alone. func TestDisk_ReadDocumentConfinedBelowALinkedWorkingDirectory(t *testing.T) { root := diskTree(t) - link := filepath.Join(resolvedDir(t), "link") + link := filepath.Join(reader.ResolvedDir(t), "link") symlink(t, root, link) t.Chdir(link) expand := expander(link, "") @@ -312,7 +312,7 @@ func cmpOr(s, fallback string) string { func TestDisk_ReadDocumentHoldsTheLimit(t *testing.T) { t.Parallel() const body = "12345" - root := resolvedDir(t) + root := reader.ResolvedDir(t) file := writeFile(t, root, "doc.yaml", body) srv, _ := countingServer(t, serveBody(t, http.StatusOK, body)) locations := map[string]string{"a path": file, "a file URI": fileURI(file), "a URL": srv.URL + "/doc.yaml"} @@ -353,7 +353,7 @@ func TestDisk_ReadDocumentHoldsTheLimit(t *testing.T) { // confined read, which opens the file through a different path. func TestDisk_ReadDocumentHoldsTheLimitWithinADirectory(t *testing.T) { t.Parallel() - root := resolvedDir(t) + root := reader.ResolvedDir(t) file := writeFile(t, root, "doc.yaml", "12345") for limit, wantOver := range map[int]bool{5: false, 4: true, 0: false, -1: false, math.MaxInt: false} { t.Run(fmt.Sprint(limit), func(t *testing.T) { diff --git a/engine/reader/export_test.go b/engine/reader/export_test.go index a6b6fcd2..a8e7d6bb 100644 --- a/engine/reader/export_test.go +++ b/engine/reader/export_test.go @@ -1,5 +1,12 @@ package reader +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + // ErrNotRegular exposes the error a read of a directory, FIFO, device or // socket fails with, so the external tests assert on it rather than on its // wording. @@ -10,3 +17,13 @@ var FilePathOf = filePathOf // ReadOpened is readOpened, for the external tests. var ReadOpened = readOpened + +// ResolvedDir returns a fresh temporary directory with its links resolved, so +// a path under it spells the directory the working directory reports where the +// system's temporary directory is itself a link. +func ResolvedDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + return dir +} diff --git a/engine/reader/helpers_test.go b/engine/reader/helpers_test.go index 0cd4db0e..af89d8a8 100644 --- a/engine/reader/helpers_test.go +++ b/engine/reader/helpers_test.go @@ -17,16 +17,6 @@ import ( "github.com/stretchr/testify/require" ) -// resolvedDir returns a fresh temporary directory with its links resolved, so -// a path under it spells the directory the working directory reports where the -// system's temporary directory is itself a link. -func resolvedDir(t *testing.T) string { - t.Helper() - dir, err := filepath.EvalSymlinks(t.TempDir()) - require.NoError(t, err) - return dir -} - // writeFile writes content to the slash-separated name under dir, making the // directories on the way, and returns the file's path. func writeFile(t *testing.T, dir, name, content string) string { diff --git a/engine/reader/location_internal_test.go b/engine/reader/location_internal_test.go index 9cabe2e3..e5f0b810 100644 --- a/engine/reader/location_internal_test.go +++ b/engine/reader/location_internal_test.go @@ -156,7 +156,7 @@ func under(root, name string) string { // path is absolute, and never otherwise: with the working directory named by // no one, a mixed pair is outside and a like pair still reads. func TestRelativeTo_ReadsTheWorkingDirectoryOnlyForAMixedPair(t *testing.T) { - root := resolved(t) + root := ResolvedDir(t) t.Chdir(root) abs := filepath.Join(root, "d", "a.yaml") @@ -184,15 +184,6 @@ func TestRelativeTo_ReadsTheWorkingDirectoryOnlyForAMixedPair(t *testing.T) { assert.False(t, ok, "an absolute directory against a relative path, no working directory") } -// resolved returns a fresh temporary directory with its links resolved, so a -// path under it spells the directory the working directory reports. -func resolved(t *testing.T) string { - t.Helper() - dir, err := filepath.EvalSymlinks(t.TempDir()) - require.NoError(t, err) - return dir -} - // vanishWorkingDirectory leaves the working directory something the system // cannot name: a path deeper than any buffer getcwd fills, whose parent // refuses a read, so the walk up that os.Getwd falls back to fails. Deleting diff --git a/ir/composition_test.go b/ir/composition_test.go index 81d14767..65b2c60f 100644 --- a/ir/composition_test.go +++ b/ir/composition_test.go @@ -6,51 +6,19 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) -// compDoc builds a registry from definitions, keyed by each one's own ID. -func compDoc(defs ...ir.TypeDef) *ir.Document { - types := ir.TypeRegistry{} - for _, d := range defs { - types[d.Common().ID] = d - } - return &ir.Document{Types: types} -} - -func compModel(id ir.TypeID, mutate func(*ir.Model)) *ir.Model { - m := &ir.Model{ID: id} - if mutate != nil { - mutate(m) - } - return m -} - -func compScalar(id, base ir.TypeID) *ir.Scalar { - s := &ir.Scalar{ID: id} - if base != "" { - s.Base = &ir.TypeRef{Target: base} - } - return s -} - -func refs(ids ...ir.TypeID) []ir.TypeRef { - out := make([]ir.TypeRef, 0, len(ids)) - for _, id := range ids { - out = append(out, ir.TypeRef{Target: id}) - } - return out -} - func TestSupertypes_BaseThenImplementsThroughAliases(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("m", func(m *ir.Model) { + doc := irtest.DocOf( + irtest.ModelOf("m", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "alias"} - m.Implements = refs("i1", "i2") - m.Mixins = refs("mix") + m.Implements = irtest.RefsTo("i1", "i2") + m.Mixins = irtest.RefsTo("mix") }), - compScalar("alias", "alias2"), compScalar("alias2", "b"), - compModel("b", nil), compModel("i1", nil), compModel("i2", nil), compModel("mix", nil), + irtest.ScalarOf("alias", "alias2"), irtest.ScalarOf("alias2", "b"), + irtest.ModelOf("b", nil), irtest.ModelOf("i1", nil), irtest.ModelOf("i2", nil), irtest.ModelOf("mix", nil), ) assert.Equal(t, []ir.TypeID{"b", "i1", "i2"}, ir.Supertypes(doc, "m")) } @@ -58,7 +26,7 @@ func TestSupertypes_BaseThenImplementsThroughAliases(t *testing.T) { func TestSupertypes_NamesNoLiveModel(t *testing.T) { t.Parallel() var nilModel *ir.Model - doc := compDoc(compScalar("s", "")) + doc := irtest.DocOf(irtest.ScalarOf("s", "")) doc.Types["nil"] = nilModel assert.Nil(t, ir.Supertypes(nil, "m")) assert.Nil(t, ir.Supertypes(doc, "missing")) @@ -69,9 +37,9 @@ func TestSupertypes_NamesNoLiveModel(t *testing.T) { func TestSupertypes_TypedNilAliasEndsTheChain(t *testing.T) { t.Parallel() var nilScalar *ir.Scalar - doc := compDoc( - compModel("m", func(m *ir.Model) { m.Implements = refs("a") }), - compScalar("a", "z"), + doc := irtest.DocOf( + irtest.ModelOf("m", func(m *ir.Model) { m.Implements = irtest.RefsTo("a") }), + irtest.ScalarOf("a", "z"), ) doc.Types["z"] = nilScalar assert.Equal(t, []ir.TypeID{"z"}, ir.Supertypes(doc, "m")) @@ -79,9 +47,9 @@ func TestSupertypes_TypedNilAliasEndsTheChain(t *testing.T) { func TestSupertypes_AnOpaqueScalarStandsForItself(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("m", func(m *ir.Model) { m.Implements = refs("opaque") }), - compScalar("opaque", ""), + doc := irtest.DocOf( + irtest.ModelOf("m", func(m *ir.Model) { m.Implements = irtest.RefsTo("opaque") }), + irtest.ScalarOf("opaque", ""), ) assert.Equal(t, []ir.TypeID{"opaque"}, ir.Supertypes(doc, "m")) } @@ -89,12 +57,12 @@ func TestSupertypes_AnOpaqueScalarStandsForItself(t *testing.T) { func TestSupertypes_SkipsAReferenceNamingNoType(t *testing.T) { t.Parallel() holey := &ir.Scalar{ID: "holey", Base: &ir.TypeRef{}} - doc := compDoc( - compModel("m", func(m *ir.Model) { + doc := irtest.DocOf( + irtest.ModelOf("m", func(m *ir.Model) { m.Base = &ir.TypeRef{} m.Implements = []ir.TypeRef{{}, {Target: "holey"}, {Target: "i"}} }), - holey, compModel("i", nil), + holey, irtest.ModelOf("i", nil), ) assert.Equal(t, []ir.TypeID{"i"}, ir.Supertypes(doc, "m")) assert.False(t, ir.IsSubtype(doc, "m", ""), "an unset reference makes no supertype to match") @@ -102,10 +70,10 @@ func TestSupertypes_SkipsAReferenceNamingNoType(t *testing.T) { func TestIsSubtype_Transitive(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("root", nil), - compModel("mid", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "root"} }), - compModel("leaf", func(m *ir.Model) { m.Implements = refs("mid") }), + doc := irtest.DocOf( + irtest.ModelOf("root", nil), + irtest.ModelOf("mid", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "root"} }), + irtest.ModelOf("leaf", func(m *ir.Model) { m.Implements = irtest.RefsTo("mid") }), ) assert.True(t, ir.IsSubtype(doc, "leaf", "root")) assert.True(t, ir.IsSubtype(doc, "leaf", "mid")) @@ -116,11 +84,11 @@ func TestIsSubtype_Transitive(t *testing.T) { func TestIsSubtype_ExcludesMixins(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("x", nil), - compModel("m", func(m *ir.Model) { m.Mixins = refs("x") }), - compModel("viaAlias", func(m *ir.Model) { m.Implements = refs("alias") }), - compScalar("alias", "x"), + doc := irtest.DocOf( + irtest.ModelOf("x", nil), + irtest.ModelOf("m", func(m *ir.Model) { m.Mixins = irtest.RefsTo("x") }), + irtest.ModelOf("viaAlias", func(m *ir.Model) { m.Implements = irtest.RefsTo("alias") }), + irtest.ScalarOf("alias", "x"), ) assert.False(t, ir.IsSubtype(doc, "m", "x"), "a mixin makes no subtype") assert.True(t, ir.IsSubtype(doc, "viaAlias", "x"), "an alias scalar is read through") @@ -128,18 +96,18 @@ func TestIsSubtype_ExcludesMixins(t *testing.T) { func TestIsSubtype_CyclicAliasChainTerminates(t *testing.T) { t.Parallel() - doc := compDoc( - compScalar("a", "b"), compScalar("b", "a"), - compModel("m", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"} }), + doc := irtest.DocOf( + irtest.ScalarOf("a", "b"), irtest.ScalarOf("b", "a"), + irtest.ModelOf("m", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"} }), ) assert.False(t, ir.IsSubtype(doc, "m", "elsewhere")) } func TestIsSubtype_CyclicCompositionTerminates(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("a", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "b"} }), - compModel("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"} }), + doc := irtest.DocOf( + irtest.ModelOf("a", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "b"} }), + irtest.ModelOf("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"} }), ) assert.True(t, ir.IsSubtype(doc, "a", "a"), "a cycle leads back to the start") assert.False(t, ir.IsSubtype(doc, "a", "elsewhere")) @@ -149,20 +117,20 @@ func prop(id ir.PropID) ir.Property { return ir.Property{ID: id} } func TestExposedProps_IncludesMixins(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("m", func(m *ir.Model) { + doc := irtest.DocOf( + irtest.ModelOf("m", func(m *ir.Model) { m.Properties = []ir.Property{prop("m.own")} m.Base = &ir.TypeRef{Target: "base"} - m.Implements = refs("iface") - m.Mixins = refs("mix", "viaAlias") + m.Implements = irtest.RefsTo("iface") + m.Mixins = irtest.RefsTo("mix", "viaAlias") }), - compModel("base", func(m *ir.Model) { m.Properties = []ir.Property{prop("base.p")} }), - compModel("iface", func(m *ir.Model) { m.Properties = []ir.Property{prop("iface.p")} }), - compModel("mix", func(m *ir.Model) { m.Properties = []ir.Property{prop("mix.p")} }), - compScalar("viaAlias", "aliased"), - compModel("aliased", func(m *ir.Model) { m.Properties = []ir.Property{prop("aliased.p")} }), - compScalar("opaque", ""), - compModel("unrelated", func(m *ir.Model) { m.Properties = []ir.Property{prop("unrelated.p")} }), + irtest.ModelOf("base", func(m *ir.Model) { m.Properties = []ir.Property{prop("base.p")} }), + irtest.ModelOf("iface", func(m *ir.Model) { m.Properties = []ir.Property{prop("iface.p")} }), + irtest.ModelOf("mix", func(m *ir.Model) { m.Properties = []ir.Property{prop("mix.p")} }), + irtest.ScalarOf("viaAlias", "aliased"), + irtest.ModelOf("aliased", func(m *ir.Model) { m.Properties = []ir.Property{prop("aliased.p")} }), + irtest.ScalarOf("opaque", ""), + irtest.ModelOf("unrelated", func(m *ir.Model) { m.Properties = []ir.Property{prop("unrelated.p")} }), ) want := map[ir.PropID]bool{ "m.own": true, "base.p": true, "iface.p": true, "mix.p": true, "aliased.p": true, @@ -175,9 +143,9 @@ func TestExposedProps_IncludesMixins(t *testing.T) { func TestExposedProps_CyclicMixinsTerminate(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("a", func(m *ir.Model) { m.Properties = []ir.Property{prop("a.p")}; m.Mixins = refs("b") }), - compModel("b", func(m *ir.Model) { m.Properties = []ir.Property{prop("b.p")}; m.Mixins = refs("a") }), + doc := irtest.DocOf( + irtest.ModelOf("a", func(m *ir.Model) { m.Properties = []ir.Property{prop("a.p")}; m.Mixins = irtest.RefsTo("b") }), + irtest.ModelOf("b", func(m *ir.Model) { m.Properties = []ir.Property{prop("b.p")}; m.Mixins = irtest.RefsTo("a") }), ) assert.Equal(t, map[ir.PropID]bool{"a.p": true, "b.p": true}, ir.ExposedProps(doc, "a")) } @@ -185,13 +153,13 @@ func TestExposedProps_CyclicMixinsTerminate(t *testing.T) { func TestExposedProps_SkipsTypedNil(t *testing.T) { t.Parallel() var nilModel *ir.Model - doc := compDoc(compModel("m", func(m *ir.Model) { m.Mixins = refs("n") })) + doc := irtest.DocOf(irtest.ModelOf("m", func(m *ir.Model) { m.Mixins = irtest.RefsTo("n") })) doc.Types["n"] = nilModel assert.Empty(t, ir.ExposedProps(doc, "m")) } func TestExposedProps_OtherKindsExposeNothing(t *testing.T) { t.Parallel() - doc := compDoc(&ir.Enum{ID: "e"}) + doc := irtest.DocOf(&ir.Enum{ID: "e"}) assert.Empty(t, ir.ExposedProps(doc, "e")) } diff --git a/ir/composition_within_test.go b/ir/composition_within_test.go index 1399fa3a..85a915f3 100644 --- a/ir/composition_within_test.go +++ b/ir/composition_within_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // withinRandom builds a registry of up to twenty types: models declaring up to @@ -27,13 +28,13 @@ func withinRandom(rng *rand.Rand) (*ir.Document, []ir.TypeID, []ir.PropID) { types = append(types, "ghost", "nilm", "") pick := func() ir.TypeID { return types[rng.IntN(len(types))] } props := []ir.PropID{"p/ghost"} - doc := compDoc() + doc := irtest.DocOf() for _, id := range declared { if rng.IntN(4) == 0 { - doc.Types[id] = compScalar(id, pick()) + doc.Types[id] = irtest.ScalarOf(id, pick()) continue } - doc.Types[id] = compModel(id, func(m *ir.Model) { + doc.Types[id] = irtest.ModelOf(id, func(m *ir.Model) { for range rng.IntN(3) { p := ir.PropID(fmt.Sprintf("p%d", rng.IntN(8))) m.Properties = append(m.Properties, prop(p)) @@ -42,8 +43,8 @@ func withinRandom(rng *rand.Rand) (*ir.Document, []ir.TypeID, []ir.PropID) { if rng.IntN(2) == 0 { m.Base = &ir.TypeRef{Target: pick()} } - m.Implements = refs(pickSome(rng, pick)...) - m.Mixins = refs(pickSome(rng, pick)...) + m.Implements = irtest.RefsTo(pickSome(rng, pick)...) + m.Mixins = irtest.RefsTo(pickSome(rng, pick)...) }) } doc.Types["nilm"] = (*ir.Model)(nil) @@ -102,13 +103,13 @@ func TestWithin_AnswersAsTheUnlimitedWalk(t *testing.T) { func TestIsSubtypeWithin_CountsEachSupertypeAndAliasHop(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("a", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "alias1"}; m.Mixins = refs("mixin") }), - compScalar("alias1", "alias2"), - compScalar("alias2", "b"), - compModel("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "c"} }), - compModel("c", nil), - compModel("mixin", nil), + doc := irtest.DocOf( + irtest.ModelOf("a", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "alias1"}; m.Mixins = irtest.RefsTo("mixin") }), + irtest.ScalarOf("alias1", "alias2"), + irtest.ScalarOf("alias2", "b"), + irtest.ModelOf("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "c"} }), + irtest.ModelOf("c", nil), + irtest.ModelOf("mixin", nil), ) for _, tc := range []struct { base ir.TypeID @@ -128,13 +129,13 @@ func TestIsSubtypeWithin_CountsEachSupertypeAndAliasHop(t *testing.T) { func TestExposesWithin_CountsEachSourceAndProperty(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("m", func(m *ir.Model) { + doc := irtest.DocOf( + irtest.ModelOf("m", func(m *ir.Model) { m.Properties = []ir.Property{prop("m.a"), prop("m.b"), prop("m.c")} - m.Mixins = refs("viaAlias", "ghost") + m.Mixins = irtest.RefsTo("viaAlias", "ghost") }), - compScalar("viaAlias", "n"), - compModel("n", func(m *ir.Model) { m.Properties = []ir.Property{prop("n.p"), prop("n.q")} }), + irtest.ScalarOf("viaAlias", "n"), + irtest.ModelOf("n", func(m *ir.Model) { m.Properties = []ir.Property{prop("n.p"), prop("n.q")} }), ) for _, tc := range []struct { prop ir.PropID diff --git a/ir/edit_test.go b/ir/edit_test.go index 5901870a..e1b7c557 100644 --- a/ir/edit_test.go +++ b/ir/edit_test.go @@ -1,7 +1,6 @@ package ir_test import ( - "encoding/json/v2" "fmt" "maps" "reflect" @@ -14,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // editedDoc returns a document of three types: a model whose one property is a @@ -39,15 +39,6 @@ func editedDoc() *ir.Document { } } -// encoded returns doc's canonical bytes, which change when anything doc -// reaches does. -func encoded(t *testing.T, doc *ir.Document) string { - t.Helper() - raw, err := json.Marshal(doc) - require.NoError(t, err) - return string(raw) -} - // refusals returns the messages of diags, each required to be an EditRefused // error locating no source. func refusals(t *testing.T, diags []ir.Diagnostic) []string { @@ -129,7 +120,7 @@ func TestEditor_WritesTheEditedRegistryAndNotTheDocument(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() doc := editedDoc() - before, nodes := encoded(t, doc), maps.Clone(doc.Types) + before, nodes := irtest.Encoded(t, doc), maps.Clone(doc.Types) e := ir.NewEditor(doc) tt.write(e) @@ -137,7 +128,7 @@ func TestEditor_WritesTheEditedRegistryAndNotTheDocument(t *testing.T) { assert.Empty(t, diags) tt.want(t, out.Types) - assert.Equal(t, before, encoded(t, doc), "the edited document changed") + assert.Equal(t, before, irtest.Encoded(t, doc), "the edited document changed") require.Len(t, doc.Types, len(nodes), "the edited document holds other types") for id, td := range nodes { assert.Same(t, td, doc.Types[id], "the edited document holds another node at %s", id) @@ -266,8 +257,8 @@ func TestEditor_RefusesWritesOnlyABugMakes(t *testing.T) { got := refusals(t, diags) require.Len(t, got, 1, "one refused write, one refusal: %v", got) assert.Contains(t, got[0], "internal: type edit: "+tt.want) - assert.Equal(t, encoded(t, want), encoded(t, out), "the refused write changed the edit") - assert.Equal(t, encoded(t, editedDoc()), encoded(t, doc), "the edited document changed") + assert.Equal(t, irtest.Encoded(t, want), irtest.Encoded(t, out), "the refused write changed the edit") + assert.Equal(t, irtest.Encoded(t, editedDoc()), irtest.Encoded(t, doc), "the edited document changed") }) } } @@ -321,8 +312,8 @@ func TestEditor_UpdateRefusesAChangeWritingOrEndingTheEdit(t *testing.T) { assert.Contains(t, got[1], "internal: type edit: "+tt.want) assert.Equal(t, `internal: type edit: update rejected: the change of "t/model" wrote to the edit `+ `or ended it; a change alters its node alone`, got[2]) - assert.Equal(t, encoded(t, want), encoded(t, out), "the change or a call it made changed the edit") - assert.Equal(t, encoded(t, editedDoc()), encoded(t, doc), "the edited document changed") + assert.Equal(t, irtest.Encoded(t, want), irtest.Encoded(t, out), "the change or a call it made changed the edit") + assert.Equal(t, irtest.Encoded(t, editedDoc()), irtest.Encoded(t, doc), "the edited document changed") }) } } @@ -465,7 +456,7 @@ func TestEditor_FinishRefusesANodeMovedAfterTheEditTookIt(t *testing.T) { for id, td := range out.Types { assert.Equal(t, id, td.Common().ID, "a node sits at a key its ID does not name") } - assert.Equal(t, encoded(t, editedDoc()), encoded(t, doc), "the edited document changed") + assert.Equal(t, irtest.Encoded(t, editedDoc()), irtest.Encoded(t, doc), "the edited document changed") }) } } @@ -502,7 +493,7 @@ func TestEditor_FinishReportsEachRefusalOnce(t *testing.T) { e := ir.NewEditor(editedDoc()) e.RemoveType("t/absent") first, firstDiags := e.Finish() - before := encoded(t, first) + before := irtest.Encoded(t, first) e.RemoveType("t/scalar") e.ReplaceType(&ir.Any{ID: "t/list"}) @@ -520,7 +511,7 @@ func TestEditor_FinishReportsEachRefusalOnce(t *testing.T) { assert.Empty(t, thirdDiags) assert.Same(t, first, second) assert.Same(t, first, third) - assert.Equal(t, before, encoded(t, first), "a write after Finish changed its document") + assert.Equal(t, before, irtest.Encoded(t, first), "a write after Finish changed its document") } // TestNewEditor_NilDocumentIsRefused pins that an edit of no document still diff --git a/ir/irfacts/components_test.go b/ir/irfacts/components_test.go index 108a5e29..b2fa5058 100644 --- a/ir/irfacts/components_test.go +++ b/ir/irfacts/components_test.go @@ -103,7 +103,7 @@ func TestIndex_SelfContainmentIsRecursive(t *testing.T) { for name, edge := range usageEdgeCases() { t.Run(name, func(t *testing.T) { t.Parallel() - facts := irfacts.New(compDoc(edge.build("x"))) + facts := irfacts.New(irtest.DocOf(edge.build("x"))) assert.True(t, facts.Recursive("x"), "a type holding or including itself by this edge is recursive") assert.Equal(t, []ir.TypeID{"x"}, facts.Component("x")) }) @@ -115,15 +115,15 @@ func TestIndex_ReferencesThatAreNoPartAreNotRecursive(t *testing.T) { self := ir.TypeRef{Target: "x"} selfValue := &ir.Value{Kind: ir.ValueRefKind, Ref: &ir.ValueRef{Type: "x"}} for name, td := range map[string]ir.TypeDef{ - "model discriminator mapping": compModel("x", func(m *ir.Model) { + "model discriminator mapping": irtest.ModelOf("x", func(m *ir.Model) { m.Discriminator = &ir.Discriminator{Mapping: map[string]ir.TypeID{"a": "x"}, Default: "x"} }), "union discriminator mapping": &ir.Union{ID: "x", Discriminator: &ir.Discriminator{Mapping: map[string]ir.TypeID{"a": "x"}}}, - "property default": compModel("x", func(m *ir.Model) { m.Properties = []ir.Property{{Default: selfValue}} }), - "property wire type": compModel("x", func(m *ir.Model) { + "property default": irtest.ModelOf("x", func(m *ir.Model) { m.Properties = []ir.Property{{Default: selfValue}} }), + "property wire type": irtest.ModelOf("x", func(m *ir.Model) { m.Properties = []ir.Property{{Encoding: &ir.Encoding{WireType: &self}}} }), - "field argument": compModel("x", func(m *ir.Model) { m.Properties = []ir.Property{{Args: []ir.Parameter{{Type: self}}}} }), + "field argument": irtest.ModelOf("x", func(m *ir.Model) { m.Properties = []ir.Property{{Args: []ir.Parameter{{Type: self}}}} }), "scalar wire type": &ir.Scalar{ID: "x", Encoding: &ir.Encoding{WireType: &self}}, "list wire type": &ir.List{ID: "x", Encoding: &ir.Encoding{WireType: &self}}, "literal value": &ir.Literal{ID: "x", Value: *selfValue}, @@ -133,7 +133,7 @@ func TestIndex_ReferencesThatAreNoPartAreNotRecursive(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - facts := irfacts.New(compDoc(td)) + facts := irfacts.New(irtest.DocOf(td)) assert.False(t, facts.Recursive("x"), "the reference names a type without making a value hold one") assert.Equal(t, []ir.TypeID{"x"}, facts.Component("x")) }) @@ -145,14 +145,14 @@ func TestIndex_ReferencesThatAreNoPartAreNotRecursive(t *testing.T) { func componentsTree(hold func() ir.Property, extra ...ir.TypeDef) *ir.Document { defs := make([]ir.TypeDef, 0, 3+len(extra)) defs = append(defs, - compModel("node", func(m *ir.Model) { m.Discriminator = &ir.Discriminator{PropertyName: "kind"} }), - compModel("branch", func(m *ir.Model) { + irtest.ModelOf("node", func(m *ir.Model) { m.Discriminator = &ir.Discriminator{PropertyName: "kind"} }), + irtest.ModelOf("branch", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "node"} m.Properties = []ir.Property{hold()} }), - compModel("leaf", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "node"} }), + irtest.ModelOf("leaf", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "node"} }), ) - return compDoc(append(defs, extra...)...) + return irtest.DocOf(append(defs, extra...)...) } func TestIndex_RecursionThroughAPolymorphicReference(t *testing.T) { @@ -160,7 +160,7 @@ func TestIndex_RecursionThroughAPolymorphicReference(t *testing.T) { for name, doc := range map[string]*ir.Document{ "held directly": componentsTree(func() ir.Property { return useProp("node") }), "held in a list": componentsTree(func() ir.Property { return useProp("children") }, &ir.List{ID: "children", Elem: useRef("node")}), - "held through alias": componentsTree(func() ir.Property { return useProp("nodeAlias") }, compScalar("nodeAlias", "node")), + "held through alias": componentsTree(func() ir.Property { return useProp("nodeAlias") }, irtest.ScalarOf("nodeAlias", "node")), } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -183,7 +183,7 @@ func TestIndex_RecursionThroughAPolymorphicReference(t *testing.T) { func TestIndex_ABaseAndItsSubtypesAreNotRecursive(t *testing.T) { t.Parallel() - doc := compDoc(usagePets(func(id ir.TypeID, m *ir.Model) { + doc := irtest.DocOf(usagePets(func(id ir.TypeID, m *ir.Model) { if id == "pet" { m.Discriminator = &ir.Discriminator{Mapping: map[string]ir.TypeID{"cat": "cat", "dog": "dog"}} } @@ -197,7 +197,7 @@ func TestIndex_ABaseAndItsSubtypesAreNotRecursive(t *testing.T) { func TestIndex_MutualContainmentIsOneComponent(t *testing.T) { t.Parallel() - doc := compDoc( + doc := irtest.DocOf( useHolder("a", "items"), &ir.List{ID: "items", Elem: useRef("b")}, useHolder("b", "a", "leaf"), @@ -214,7 +214,7 @@ func TestIndex_MutualContainmentIsOneComponent(t *testing.T) { func TestIndex_AnAcyclicDiamondIsNotRecursive(t *testing.T) { t.Parallel() - doc := compDoc(useHolder("top", "left", "right"), useHolder("left", "bottom"), useHolder("right", "bottom"), useType("bottom")) + doc := irtest.DocOf(useHolder("top", "left", "right"), useHolder("left", "bottom"), useHolder("right", "bottom"), useType("bottom")) facts := irfacts.New(doc) for _, id := range []ir.TypeID{"top", "left", "right", "bottom"} { assert.False(t, facts.Recursive(id), id) @@ -224,7 +224,7 @@ func TestIndex_AnAcyclicDiamondIsNotRecursive(t *testing.T) { func TestIndex_ADanglingReferenceIsIgnored(t *testing.T) { t.Parallel() - facts := irfacts.New(compDoc(useHolder("a", "ghost"))) + facts := irfacts.New(irtest.DocOf(useHolder("a", "ghost"))) assert.False(t, facts.Recursive("a")) assert.Equal(t, []ir.TypeID{"a"}, facts.Component("a")) assert.False(t, facts.Recursive("ghost"), "the document declares no such type") @@ -234,7 +234,7 @@ func TestIndex_ADanglingReferenceIsIgnored(t *testing.T) { func TestIndex_ATypedNilEntryIsATypeHoldingNothing(t *testing.T) { t.Parallel() - doc := compDoc(useHolder("a", "nilm")) + doc := irtest.DocOf(useHolder("a", "nilm")) doc.Types["nilm"] = (*ir.Model)(nil) facts := irfacts.New(doc) assert.False(t, facts.Recursive("nilm")) @@ -245,7 +245,7 @@ func TestIndex_ComponentIsSortedAndTheCallersOwn(t *testing.T) { t.Parallel() // The search from "a" closes the ring with "c" ahead of "b", the reverse of // the order the component must be reported in. - doc := compDoc(useHolder("a", "c"), useHolder("c", "b"), useHolder("b", "a")) + doc := irtest.DocOf(useHolder("a", "c"), useHolder("c", "b"), useHolder("b", "a")) facts := irfacts.New(doc) got := facts.Component("b") @@ -259,13 +259,13 @@ func TestIndex_ComponentIsSortedAndTheCallersOwn(t *testing.T) { func factsLongContainment(n int) *ir.Document { defs := make([]ir.TypeDef, 0, n) for i := range n { - defs = append(defs, compModel(factsLongID(i), func(m *ir.Model) { + defs = append(defs, irtest.ModelOf(factsLongID(i), func(m *ir.Model) { if i+1 < n { m.Properties = []ir.Property{useProp(factsLongID(i + 1))} } })) } - return compDoc(defs...) + return irtest.DocOf(defs...) } func factsLongID(i int) ir.TypeID { return ir.TypeID(fmt.Sprintf("t%06d", i)) } @@ -294,7 +294,7 @@ func TestIndex_AVeryLongChainNeedsNoRecursion(t *testing.T) { func TestIndex_AVeryDeepHierarchyNeedsNoRecursion(t *testing.T) { t.Parallel() const n = 100_000 - doc := compDoc(factsChain(n, func(_ int, m *ir.Model) { m.Discriminator = &ir.Discriminator{PropertyName: "k"} })...) + doc := irtest.DocOf(factsChain(n, func(_ int, m *ir.Model) { m.Discriminator = &ir.Discriminator{PropertyName: "k"} })...) facts := irfacts.New(doc) assert.True(t, facts.IsSubtype(factsChainID(n), factsChainID(0))) assert.False(t, facts.IsSubtype(factsChainID(0), factsChainID(n))) @@ -336,7 +336,7 @@ func componentsRandom(rng *rand.Rand) (doc *ir.Document, ids []ir.TypeID, held, } defs = append(defs, m) } - return compDoc(defs...), ids, held, composed + return irtest.DocOf(defs...), ids, held, composed } // componentsOracle returns, for each model, the models whose shape its own diff --git a/ir/irfacts/exposure_test.go b/ir/irfacts/exposure_test.go index 214cd95d..5d8fff19 100644 --- a/ir/irfacts/exposure_test.go +++ b/ir/irfacts/exposure_test.go @@ -10,6 +10,7 @@ import ( "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irfacts" + "github.com/dexpace/morphic/ir/irtest" ) // exposureRandom builds a registry of up to thirty types: models declaring up @@ -30,11 +31,11 @@ func exposureRandom(rng *rand.Rand, forest bool) (*ir.Document, []ir.TypeID, []i types = append(types, "ghost", "nilm", "") pick := func() ir.TypeID { return types[rng.IntN(len(types))] } props := []ir.PropID{"p/ghost", ""} - doc := compDoc() + doc := irtest.DocOf() for _, id := range declared { switch r := rng.IntN(10); { case r < 6: - m := compModel(id, nil) + m := irtest.ModelOf(id, nil) for j := range rng.IntN(3) { prop := ir.PropID(fmt.Sprintf("p/%s/%d", id, j)) if !forest { @@ -46,7 +47,7 @@ func exposureRandom(rng *rand.Rand, forest bool) (*ir.Document, []ir.TypeID, []i exposureSources(rng, m, pick, forest) doc.Types[id] = m case r < 8: - doc.Types[id] = compScalar(id, pick()) + doc.Types[id] = irtest.ScalarOf(id, pick()) case r == 8: doc.Types[id] = &ir.Scalar{ID: id} default: @@ -55,7 +56,7 @@ func exposureRandom(rng *rand.Rand, forest bool) (*ir.Document, []ir.TypeID, []i } doc.Types["nilm"] = (*ir.Model)(nil) if rng.IntN(4) == 0 { - doc.Types[""] = compModel("", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/empty"}} }) + doc.Types[""] = irtest.ModelOf("", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/empty"}} }) props = append(props, "p/empty") } return doc, types, props @@ -69,9 +70,9 @@ func exposureSources(rng *rand.Rand, m *ir.Model, pick func() ir.TypeID, forest case 0: m.Base = &ir.TypeRef{Target: pick()} case 1: - m.Implements = refs(pick()) + m.Implements = irtest.RefsTo(pick()) case 2: - m.Mixins = refs(pick()) + m.Mixins = irtest.RefsTo(pick()) default: // A model of its own properties alone. } @@ -131,18 +132,18 @@ func TestIndex_ExposesAsTheReferenceWalkDoes(t *testing.T) { func TestIndex_ExposesWhatCompositionAndAliasesLeadTo(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("top", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/top"}} }), - compModel("mixin", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/mixin"}} }), - compScalar("topAlias", "top"), - compModel("mid", func(m *ir.Model) { + doc := irtest.DocOf( + irtest.ModelOf("top", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/top"}} }), + irtest.ModelOf("mixin", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/mixin"}} }), + irtest.ScalarOf("topAlias", "top"), + irtest.ModelOf("mid", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "topAlias"} - m.Mixins = refs("mixin") + m.Mixins = irtest.RefsTo("mixin") m.Properties = []ir.Property{{ID: "p/mid"}} }), - compModel("leaf", func(m *ir.Model) { m.Implements = refs("mid") }), - compScalar("leafAlias", "leaf"), - compModel("other", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/other"}} }), + irtest.ModelOf("leaf", func(m *ir.Model) { m.Implements = irtest.RefsTo("mid") }), + irtest.ScalarOf("leafAlias", "leaf"), + irtest.ModelOf("other", func(m *ir.Model) { m.Properties = []ir.Property{{ID: "p/other"}} }), ) facts := irfacts.New(doc) for _, root := range []ir.TypeID{"leaf", "leafAlias"} { @@ -159,7 +160,7 @@ func TestIndex_ExposesWhatCompositionAndAliasesLeadTo(t *testing.T) { func TestIndex_ExposesAChainsPropertiesWithoutWalking(t *testing.T) { t.Parallel() const n = 300 - doc := compDoc(factsChain(n, func(i int, m *ir.Model) { + doc := irtest.DocOf(factsChain(n, func(i int, m *ir.Model) { m.Properties = []ir.Property{{ID: factsPropID(i)}} m.Discriminator = &ir.Discriminator{Property: factsPropID(0)} })...) diff --git a/ir/irfacts/helpers_test.go b/ir/irfacts/helpers_test.go index 09051d31..af655d56 100644 --- a/ir/irfacts/helpers_test.go +++ b/ir/irfacts/helpers_test.go @@ -1,5 +1,6 @@ -// Package irfacts_test holds the tests of the irfacts package. The builders -// several files share live here. +// Package irfacts_test holds the tests of the irfacts package. The helpers +// several files share live here; the type builders they share with package ir's +// tests are irtest's. package irfacts_test import ( @@ -15,42 +16,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// compDoc builds a registry from definitions, keyed by each one's own ID. -func compDoc(defs ...ir.TypeDef) *ir.Document { - types := ir.TypeRegistry{} - for _, d := range defs { - types[d.Common().ID] = d - } - return &ir.Document{Types: types} -} - -// compModel builds the model id, shaped by mutate when it is not nil. -func compModel(id ir.TypeID, mutate func(*ir.Model)) *ir.Model { - m := &ir.Model{ID: id} - if mutate != nil { - mutate(m) - } - return m -} - -// compScalar builds the scalar id over base, or an opaque one when base is empty. -func compScalar(id, base ir.TypeID) *ir.Scalar { - s := &ir.Scalar{ID: id} - if base != "" { - s.Base = &ir.TypeRef{Target: base} - } - return s -} - -// refs builds a reference to each of ids. -func refs(ids ...ir.TypeID) []ir.TypeRef { - out := make([]ir.TypeRef, 0, len(ids)) - for _, id := range ids { - out = append(out, ir.TypeRef{Target: id}) - } - return out -} - // typeIDReflect is the reflect.Type of ir.TypeID. var typeIDReflect = reflect.TypeFor[ir.TypeID]() diff --git a/ir/irfacts/index_test.go b/ir/irfacts/index_test.go index 2b34f5b1..86e0c4b0 100644 --- a/ir/irfacts/index_test.go +++ b/ir/irfacts/index_test.go @@ -14,6 +14,7 @@ import ( "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irfacts" + "github.com/dexpace/morphic/ir/irtest" ) // factsRandomHierarchy builds a registry of up to thirty types: models whose @@ -36,7 +37,7 @@ func factsRandomHierarchy(rng *rand.Rand, forest bool) (*ir.Document, []ir.TypeI for _, id := range declared { switch r := rng.IntN(10); { case r < 6: - defs = append(defs, compModel(id, func(m *ir.Model) { + defs = append(defs, irtest.ModelOf(id, func(m *ir.Model) { if rng.IntN(2) == 0 { m.Base = &ir.TypeRef{Target: pick()} } @@ -46,18 +47,18 @@ func factsRandomHierarchy(rng *rand.Rand, forest bool) (*ir.Document, []ir.TypeI } } if rng.IntN(2) == 0 { - m.Mixins = refs(pick()) + m.Mixins = irtest.RefsTo(pick()) } })) case r < 8: - defs = append(defs, compScalar(id, pick())) + defs = append(defs, irtest.ScalarOf(id, pick())) case r == 8: - defs = append(defs, compScalar(id, "")) + defs = append(defs, irtest.ScalarOf(id, "")) default: defs = append(defs, &ir.Enum{ID: id}) } } - doc := compDoc(defs...) + doc := irtest.DocOf(defs...) doc.Types["nilm"] = (*ir.Model)(nil) return doc, universe } @@ -139,7 +140,7 @@ func factsRandomAliases(rng *rand.Rand) (*ir.Document, []ir.TypeID) { universe = append(universe, ir.TypeID(fmt.Sprintf("a%02d", i))) } pick := func() ir.TypeID { return universe[rng.IntN(len(universe))] } - doc := compDoc() + doc := irtest.DocOf() for _, id := range universe[2:] { switch r := rng.IntN(10); { case r < 7: @@ -149,7 +150,7 @@ func factsRandomAliases(rng *rand.Rand) (*ir.Document, []ir.TypeID) { case r == 8: doc.Types[id] = (*ir.Scalar)(nil) default: - doc.Types[id] = compModel(id, nil) + doc.Types[id] = irtest.ModelOf(id, nil) } } if rng.IntN(3) == 0 { @@ -163,7 +164,7 @@ func factsRandomAliases(rng *rand.Rand) (*ir.Document, []ir.TypeID) { // when that names no type. func aliasRead(doc *ir.Document, id ir.TypeID) ir.TypeID { const probe = "probe/model" - doc.Types[probe] = compModel(probe, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: id} }) + doc.Types[probe] = irtest.ModelOf(probe, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: id} }) defer delete(doc.Types, probe) if supers := ir.Supertypes(doc, probe); len(supers) == 1 { return supers[0] @@ -194,11 +195,11 @@ func TestIndex_AliasIndexAnswersAsTheOneQuestionReadingDoes(t *testing.T) { func TestIndex_SubtypesIsSortedFreshAndNilWhenEmpty(t *testing.T) { t.Parallel() - doc := compDoc( - compModel("root", nil), - compModel("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "root"} }), - compModel("a", func(m *ir.Model) { m.Implements = refs("root") }), - compModel("c", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"} }), + doc := irtest.DocOf( + irtest.ModelOf("root", nil), + irtest.ModelOf("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "root"} }), + irtest.ModelOf("a", func(m *ir.Model) { m.Implements = irtest.RefsTo("root") }), + irtest.ModelOf("c", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"} }), ) facts := irfacts.New(doc) @@ -237,7 +238,7 @@ func factsBaseID(k int) ir.TypeID { return ir.TypeID(fmt.Sprintf("m%06d", k)) } func factsChain(n int, mutate func(i int, m *ir.Model)) []ir.TypeDef { defs := make([]ir.TypeDef, 0, n+1) for i := 0; i <= n; i++ { - defs = append(defs, compModel(factsChainID(i), func(m *ir.Model) { + defs = append(defs, irtest.ModelOf(factsChainID(i), func(m *ir.Model) { if i > 0 { m.Base = &ir.TypeRef{Target: factsChainID(i - 1)} } @@ -290,9 +291,9 @@ func factsWorkShapes() map[string]factsWorkShape { doc: func(n int) *ir.Document { defs := factsChain(n, nil) for k := range n { - defs = append(defs, compModel(factsBaseID(k), nil)) + defs = append(defs, irtest.ModelOf(factsBaseID(k), nil)) } - return compDoc(defs...) + return irtest.DocOf(defs...) }, ask: func(f *irfacts.Index, ids factsIDs, n int) (yes int) { for k := range n { @@ -302,7 +303,7 @@ func factsWorkShapes() map[string]factsWorkShape { }, }, "each model of a chain routing to the next": { - doc: func(n int) *ir.Document { return compDoc(factsChain(n, nil)...) }, + doc: func(n int) *ir.Document { return irtest.DocOf(factsChain(n, nil)...) }, ask: func(f *irfacts.Index, ids factsIDs, n int) (yes int) { for i := range n { yes += factsCount(f.IsSubtype(ids.chain[i+1], ids.chain[i])) @@ -311,16 +312,16 @@ func factsWorkShapes() map[string]factsWorkShape { }, }, "the top of a chain routing to every model below it": { - doc: func(n int) *ir.Document { return compDoc(factsChain(n, nil)...) }, + doc: func(n int) *ir.Document { return irtest.DocOf(factsChain(n, nil)...) }, ask: factsAskTopOfChain, }, "the same beside an unrelated model naming two supertypes": { - doc: func(n int) *ir.Document { return compDoc(append(factsChain(n, nil), factsDiamond("x")...)...) }, + doc: func(n int) *ir.Document { return irtest.DocOf(append(factsChain(n, nil), factsDiamond("x")...)...) }, ask: factsAskTopOfChain, }, "the top's tag asked of every model of a chain": { doc: func(n int) *ir.Document { - return compDoc(factsChain(n, func(i int, m *ir.Model) { + return irtest.DocOf(factsChain(n, func(i int, m *ir.Model) { if i == 0 { m.Properties = []ir.Property{{ID: factsPropID(0)}} } @@ -335,7 +336,7 @@ func factsWorkShapes() map[string]factsWorkShape { }, "every model's property asked of the bottom of a chain": { doc: func(n int) *ir.Document { - return compDoc(factsChain(n, func(i int, m *ir.Model) { m.Properties = []ir.Property{{ID: factsPropID(i)}} })...) + return irtest.DocOf(factsChain(n, func(i int, m *ir.Model) { m.Properties = []ir.Property{{ID: factsPropID(i)}} })...) }, ask: func(f *irfacts.Index, ids factsIDs, n int) (yes int) { for i := range n { @@ -373,10 +374,10 @@ func factsAskTopOfChain(f *irfacts.Index, ids factsIDs, n int) (yes int) { func factsDiamond(prefix ir.TypeID) []ir.TypeDef { top, left, right, bottom := prefix+"/top", prefix+"/left", prefix+"/right", prefix+"/bottom" return []ir.TypeDef{ - compModel(top, nil), - compModel(left, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: top} }), - compModel(right, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: top} }), - compModel(bottom, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: left}; m.Implements = refs(right) }), + irtest.ModelOf(top, nil), + irtest.ModelOf(left, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: top} }), + irtest.ModelOf(right, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: top} }), + irtest.ModelOf(bottom, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: left}; m.Implements = irtest.RefsTo(right) }), } } @@ -454,31 +455,31 @@ func TestIndex_SettlesATargetReachedOnlyThroughItsBase(t *testing.T) { pairs [][2]ir.TypeID // target, then a base every path up from it passes }{ "a supertype named by Base and Implements": { - doc: compDoc(compModel("top", nil), compModel("both", func(m *ir.Model) { + doc: irtest.DocOf(irtest.ModelOf("top", nil), irtest.ModelOf("both", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "top"} - m.Implements = refs("top") + m.Implements = irtest.RefsTo("top") })), pairs: [][2]ir.TypeID{{"both", "top"}}, }, "a supertype named directly and through an alias": { - doc: compDoc(compModel("top", nil), compScalar("topAlias", "top"), compModel("both", func(m *ir.Model) { + doc: irtest.DocOf(irtest.ModelOf("top", nil), irtest.ScalarOf("topAlias", "top"), irtest.ModelOf("both", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "topAlias"} - m.Implements = refs("top") + m.Implements = irtest.RefsTo("top") })), pairs: [][2]ir.TypeID{{"both", "top"}}, }, "a supertype named by two members of one cycle": { - doc: compDoc(compModel("top", nil), - compModel("a", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "b"}; m.Implements = refs("top") }), - compModel("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"}; m.Implements = refs("top") })), + doc: irtest.DocOf(irtest.ModelOf("top", nil), + irtest.ModelOf("a", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "b"}; m.Implements = irtest.RefsTo("top") }), + irtest.ModelOf("b", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "a"}; m.Implements = irtest.RefsTo("top") })), pairs: [][2]ir.TypeID{{"a", "top"}, {"b", "top"}}, }, "a diamond, asked from its top": { - doc: compDoc(factsDiamond("x")...), + doc: irtest.DocOf(factsDiamond("x")...), pairs: [][2]ir.TypeID{{"x/left", "x/top"}, {"x/right", "x/top"}, {"x/bottom", "x/top"}}, }, "a chain beside an unrelated diamond": { - doc: compDoc(chain...), + doc: irtest.DocOf(chain...), pairs: [][2]ir.TypeID{{factsChainID(1), factsChainID(0)}, {factsChainID(5), factsChainID(0)}, {factsChainID(5), factsChainID(3)}}, }, } { @@ -576,19 +577,19 @@ func TestIndex_EveryMethodIsSafeForConcurrentUse(t *testing.T) { // which leaves some subtype and exposure question to the walk whichever side is // searched first; a cycle; an alias; and a type nothing reaches. func factsConcurrentDocument() (*ir.Document, []ir.TypeID) { - doc := compDoc( - compModel("base", func(m *ir.Model) { + doc := irtest.DocOf( + irtest.ModelOf("base", func(m *ir.Model) { m.Discriminator = &ir.Discriminator{} m.Properties = []ir.Property{{ID: "p/kind"}} }), - compModel("sub", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "alias"} }), - compScalar("alias", "base"), - compModel("mid", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "sub"} }), - compModel("side", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "sub"}; m.Properties = []ir.Property{{ID: "p/side"}} }), - compModel("deep", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "mid"}; m.Implements = refs("side") }), - compModel("loopA", func(m *ir.Model) { m.Properties = []ir.Property{{Type: ir.TypeRef{Target: "loopB"}}} }), - compModel("loopB", func(m *ir.Model) { m.Properties = []ir.Property{{Type: ir.TypeRef{Target: "loopA"}}} }), - compModel("orphan", nil), + irtest.ModelOf("sub", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "alias"} }), + irtest.ScalarOf("alias", "base"), + irtest.ModelOf("mid", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "sub"} }), + irtest.ModelOf("side", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "sub"}; m.Properties = []ir.Property{{ID: "p/side"}} }), + irtest.ModelOf("deep", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "mid"}; m.Implements = irtest.RefsTo("side") }), + irtest.ModelOf("loopA", func(m *ir.Model) { m.Properties = []ir.Property{{Type: ir.TypeRef{Target: "loopB"}}} }), + irtest.ModelOf("loopB", func(m *ir.Model) { m.Properties = []ir.Property{{Type: ir.TypeRef{Target: "loopA"}}} }), + irtest.ModelOf("orphan", nil), ) doc.Services = []ir.Service{{Groups: []ir.OperationGroup{{Operations: []ir.Operation{{ Params: []ir.Parameter{{Type: ir.TypeRef{Target: "loopA"}}}, diff --git a/ir/irfacts/index_walk_test.go b/ir/irfacts/index_walk_test.go index 27e9b180..4258cb23 100644 --- a/ir/irfacts/index_walk_test.go +++ b/ir/irfacts/index_walk_test.go @@ -8,6 +8,7 @@ import ( "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irfacts" + "github.com/dexpace/morphic/ir/irtest" ) // walkChain is a chain of n+1 models, each declaring one property, beside @@ -15,9 +16,9 @@ import ( func walkChain(n, extra int) *ir.Document { defs := factsChain(n, func(i int, m *ir.Model) { m.Properties = []ir.Property{{ID: factsPropID(i)}} }) for k := range extra { - defs = append(defs, compModel(factsBaseID(k), nil)) + defs = append(defs, irtest.ModelOf(factsBaseID(k), nil)) } - return compDoc(defs...) + return irtest.DocOf(defs...) } func TestIndex_AFewCheapQuestionsAreWalked(t *testing.T) { diff --git a/ir/irfacts/usage_test.go b/ir/irfacts/usage_test.go index 08370651..12d37265 100644 --- a/ir/irfacts/usage_test.go +++ b/ir/irfacts/usage_test.go @@ -9,11 +9,12 @@ import ( "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irfacts" + "github.com/dexpace/morphic/ir/irtest" ) // useDoc builds a document with one operation and the given types. func useDoc(op ir.Operation, defs ...ir.TypeDef) *ir.Document { - doc := compDoc(defs...) + doc := irtest.DocOf(defs...) doc.Services = []ir.Service{{Groups: []ir.OperationGroup{{Operations: []ir.Operation{op}}}}} return doc } @@ -32,7 +33,7 @@ func useProp(id ir.TypeID) ir.Property { return ir.Property{Type: useRef(id)} } // useHolder is a model with one property of each of ids. func useHolder(id ir.TypeID, ids ...ir.TypeID) *ir.Model { - return compModel(id, func(m *ir.Model) { + return irtest.ModelOf(id, func(m *ir.Model) { for _, p := range ids { m.Properties = append(m.Properties, useProp(p)) } @@ -73,7 +74,7 @@ type usageRoot struct { // usageDocWith builds a document declaring defs, and shapes it with mutate. func usageDocWith(mutate func(*ir.Document), defs ...ir.TypeDef) *ir.Document { - doc := compDoc(defs...) + doc := irtest.DocOf(defs...) mutate(doc) return doc } @@ -170,7 +171,7 @@ func usagePayloadCases() map[string]usageRoot { "response": {func(p *ir.Payload) *ir.Document { return useDoc(ir.Operation{Responses: []ir.Response{{Payload: p}}}) }, useOut}, "error": {func(p *ir.Payload) *ir.Document { return useDoc(ir.Operation{Errors: []ir.ErrorCase{{Payload: p}}}) }, useOut | useErr}, "common error": {func(p *ir.Payload) *ir.Document { - doc := compDoc() + doc := irtest.DocOf() doc.Services = []ir.Service{{CommonErrors: []ir.ErrorCase{{Payload: p}}}} return doc }, useOut | useErr}, @@ -246,28 +247,28 @@ func usageEdgeCases() map[string]usageEdge { return map[string]usageEdge{ "property": {build: func(root ir.TypeID) ir.TypeDef { return useHolder(root, "x") }}, "additional value": {build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { m.AdditionalProps = &ir.AdditionalProps{Value: useRef("x")} }) + return irtest.ModelOf(root, func(m *ir.Model) { m.AdditionalProps = &ir.AdditionalProps{Value: useRef("x")} }) }}, "additional key": {also: []ir.TypeID{"v"}, build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { + return irtest.ModelOf(root, func(m *ir.Model) { m.AdditionalProps = &ir.AdditionalProps{Value: useRef("v"), Key: &ir.TypeRef{Target: "x"}} }) }}, "additional pattern": {also: []ir.TypeID{"v"}, build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { + return irtest.ModelOf(root, func(m *ir.Model) { m.AdditionalProps = &ir.AdditionalProps{ Value: useRef("v"), Patterns: []ir.PatternProps{{Pattern: "^a", Value: useRef("x")}}, } }) }}, "base": {build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "x"} }) + return irtest.ModelOf(root, func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "x"} }) }}, "mixin": {build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { m.Mixins = refs("x") }) + return irtest.ModelOf(root, func(m *ir.Model) { m.Mixins = irtest.RefsTo("x") }) }}, "implements": {build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { m.Implements = refs("x") }) + return irtest.ModelOf(root, func(m *ir.Model) { m.Implements = irtest.RefsTo("x") }) }}, "union variant": {build: func(root ir.TypeID) ir.TypeDef { return &ir.Union{ID: root, Variants: []ir.Variant{{Type: useRef("x")}}} @@ -277,13 +278,13 @@ func usageEdgeCases() map[string]usageEdge { return &ir.MapT{ID: root, Key: &ir.TypeRef{Target: "x"}, Value: useRef("v")} }}, "map value": {build: func(root ir.TypeID) ir.TypeDef { return &ir.MapT{ID: root, Value: useRef("x")} }}, - "tuple element": {build: func(root ir.TypeID) ir.TypeDef { return &ir.Tuple{ID: root, Elems: refs("x")} }}, + "tuple element": {build: func(root ir.TypeID) ir.TypeDef { return &ir.Tuple{ID: root, Elems: irtest.RefsTo("x")} }}, "tuple rest": {build: func(root ir.TypeID) ir.TypeDef { return &ir.Tuple{ID: root, Rest: &ir.TypeRef{Target: "x"}} }}, - "scalar base": {build: func(root ir.TypeID) ir.TypeDef { return compScalar(root, "x") }}, + "scalar base": {build: func(root ir.TypeID) ir.TypeDef { return irtest.ScalarOf(root, "x") }}, "property encoding schema": {also: []ir.TypeID{"v"}, build: func(root ir.TypeID) ir.TypeDef { - return compModel(root, func(m *ir.Model) { + return irtest.ModelOf(root, func(m *ir.Model) { m.Properties = []ir.Property{{Type: useRef("v"), Encoding: &ir.Encoding{Schema: &ir.TypeRef{Target: "x"}}}} }) }}, @@ -320,7 +321,7 @@ func TestIndex_UsageSpreadsAlongEveryEdge(t *testing.T) { // below "cat", and "kitten" composing "cat" through the alias "catAlias". func usagePets(mutate func(id ir.TypeID, m *ir.Model)) []ir.TypeDef { model := func(id ir.TypeID, base ir.TypeID) *ir.Model { - return compModel(id, func(m *ir.Model) { + return irtest.ModelOf(id, func(m *ir.Model) { if base != "" { m.Base = &ir.TypeRef{Target: base} } @@ -332,7 +333,7 @@ func usagePets(mutate func(id ir.TypeID, m *ir.Model)) []ir.TypeDef { pet := model("pet", "") pet.Discriminator = &ir.Discriminator{PropertyName: "kind"} return []ir.TypeDef{pet, model("cat", "pet"), model("dog", "pet"), model("lion", "cat"), - compScalar("catAlias", "cat"), model("kitten", "catAlias")} + irtest.ScalarOf("catAlias", "cat"), model("kitten", "catAlias")} } func TestIndex_UsageReachesSubtypesOfAPolymorphicBaseOnlyWhereItIsAValue(t *testing.T) { @@ -348,27 +349,30 @@ func TestIndex_UsageReachesSubtypesOfAPolymorphicBaseOnlyWhereItIsAValue(t *test assert.Equal(t, map[ir.TypeID]irfacts.UsageFlags{"kitten": useOut, "catAlias": useOut, "cat": useOut, "pet": useOut}, respond("kitten", usagePets(nil)...), "composing through an alias reaches no sibling either") - viaAlias := append(usagePets(nil), compScalar("petAlias", "pet")) + viaAlias := append(usagePets(nil), irtest.ScalarOf("petAlias", "pet")) wantViaAlias := map[ir.TypeID]irfacts.UsageFlags{"petAlias": useOut} maps.Copy(wantViaAlias, all) assert.Equal(t, wantViaAlias, respond("petAlias", viaAlias...), "a reference through an alias is a value of the base") abstract := []ir.TypeDef{ - compModel("shape", func(m *ir.Model) { m.Abstract = true }), - compModel("circle", func(m *ir.Model) { m.Implements = refs("shape"); m.Properties = []ir.Property{useProp("radius")} }), + irtest.ModelOf("shape", func(m *ir.Model) { m.Abstract = true }), + irtest.ModelOf("circle", func(m *ir.Model) { + m.Implements = irtest.RefsTo("shape") + m.Properties = []ir.Property{useProp("radius")} + }), useType("radius"), } assert.Equal(t, map[ir.TypeID]irfacts.UsageFlags{"shape": useOut, "circle": useOut, "radius": useOut}, respond("shape", abstract...), "an abstract base reaches what implements it, and that type's own members") - plain := []ir.TypeDef{compModel("base", nil), compModel("sub", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "base"} })} + plain := []ir.TypeDef{irtest.ModelOf("base", nil), irtest.ModelOf("sub", func(m *ir.Model) { m.Base = &ir.TypeRef{Target: "base"} })} assert.Equal(t, map[ir.TypeID]irfacts.UsageFlags{"base": useOut}, respond("base", plain...), "a plain base names its own shape alone") mapped := []ir.TypeDef{ - compModel("base", func(m *ir.Model) { + irtest.ModelOf("base", func(m *ir.Model) { m.Discriminator = &ir.Discriminator{Mapping: map[string]ir.TypeID{"a": "mapped"}, Default: "fallback"} }), - compModel("mapped", nil), compModel("fallback", nil), + irtest.ModelOf("mapped", nil), irtest.ModelOf("fallback", nil), } assert.Equal(t, map[ir.TypeID]irfacts.UsageFlags{"base": useOut}, respond("base", mapped...), "a discriminator mapping routes to types; only a subtype is one a value of the base may be") @@ -377,7 +381,7 @@ func TestIndex_UsageReachesSubtypesOfAPolymorphicBaseOnlyWhereItIsAValue(t *test func TestIndex_UsageOfAFieldArgumentIsInput(t *testing.T) { t.Parallel() doc := useDoc(ir.Operation{Responses: []ir.Response{{Payload: useBody("application/json", "query")}}}, - compModel("query", func(m *ir.Model) { + irtest.ModelOf("query", func(m *ir.Model) { m.Properties = []ir.Property{{Type: useRef("result"), Args: []ir.Parameter{{Type: useRef("filter")}}}} }), useType("result"), useHolder("filter", "clause"), useType("clause")) @@ -425,7 +429,7 @@ func TestIndex_UsageSwapsSidesForAnOperationTheAPIStarts(t *testing.T) { }}}} callback := op callback.Bindings.HTTP = []ir.HTTPBinding{{Method: "POST", URITemplate: "{$request.body#/url}"}} - doc := compDoc(defs()...) + doc := irtest.DocOf(defs()...) doc.Services = []ir.Service{{Groups: []ir.OperationGroup{ {Operations: []ir.Operation{callback}}, {Groups: []ir.OperationGroup{{Operations: []ir.Operation{parent}}}}, @@ -470,7 +474,7 @@ func TestIndex_UsageMarksMultipartOnTheBodyTypeAndWhatItAliases(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - doc := useDoc(tc.op, useHolder("form", "part"), useType("part"), compScalar("outer", "inner"), compScalar("inner", "form")) + doc := useDoc(tc.op, useHolder("form", "part"), useType("part"), irtest.ScalarOf("outer", "inner"), irtest.ScalarOf("inner", "form")) assert.Equal(t, tc.want, usageOf(doc)) }) } @@ -480,7 +484,7 @@ func TestIndex_UsageMarksMultipartOnTheBodyTypeAndWhatItAliases(t *testing.T) { doc := useDoc(ir.Operation{Request: &ir.Payload{Contents: []ir.Content{ {MediaType: "multipart/form-data", Type: useRef("ghost")}, {MediaType: "multipart/form-data", Type: useRef("toGhost")}, - }}}, compScalar("toGhost", "elsewhere")) + }}}, irtest.ScalarOf("toGhost", "elsewhere")) facts := irfacts.New(doc) assert.Equal(t, useIn|useMulp, facts.Usage("toGhost")) assert.Zero(t, facts.Usage("ghost"), "an undeclared body type") @@ -489,7 +493,7 @@ func TestIndex_UsageMarksMultipartOnTheBodyTypeAndWhatItAliases(t *testing.T) { t.Run("an alias cycle ends", func(t *testing.T) { t.Parallel() - doc := useDoc(ir.Operation{Request: useBody("multipart/form-data", "a")}, compScalar("a", "b"), compScalar("b", "a")) + doc := useDoc(ir.Operation{Request: useBody("multipart/form-data", "a")}, irtest.ScalarOf("a", "b"), irtest.ScalarOf("b", "a")) assert.Equal(t, map[ir.TypeID]irfacts.UsageFlags{"a": useIn | useMulp, "b": useIn | useMulp}, usageOf(doc)) }) } @@ -526,7 +530,7 @@ func TestIndex_UsageTerminatesOnACycle(t *testing.T) { func TestIndex_UsageReadsEveryOperationOfEveryService(t *testing.T) { t.Parallel() - doc := compDoc(useType("first"), useType("nested"), useType("second")) + doc := irtest.DocOf(useType("first"), useType("nested"), useType("second")) doc.Services = []ir.Service{ {Groups: []ir.OperationGroup{{ Operations: []ir.Operation{{Request: useBody("application/json", "first")}}, @@ -576,7 +580,7 @@ func TestUsage_ADefaultTravelsAsOutputAndNoError(t *testing.T) { Headers: []ir.Property{useProp("h")}, }, } - defs := func() []ir.TypeDef { return []ir.TypeDef{compModel("t", nil), useType("h")} } + defs := func() []ir.TypeDef { return []ir.TypeDef{irtest.ModelOf("t", nil), useType("h")} } for name, tc := range map[string]struct { bindings []ir.HTTPBinding diff --git a/ir/irtest/diagnostics.go b/ir/irtest/diagnostics.go new file mode 100644 index 00000000..4037d557 --- /dev/null +++ b/ir/irtest/diagnostics.go @@ -0,0 +1,12 @@ +package irtest + +import "github.com/dexpace/morphic/ir" + +// DiagCodes lists the code of each of diags, in order. +func DiagCodes(diags []ir.Diagnostic) []string { + out := make([]string, 0, len(diags)) + for _, d := range diags { + out = append(out, d.Code) + } + return out +} diff --git a/ir/irtest/diagnostics_test.go b/ir/irtest/diagnostics_test.go new file mode 100644 index 00000000..afdcd32c --- /dev/null +++ b/ir/irtest/diagnostics_test.go @@ -0,0 +1,19 @@ +package irtest_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" +) + +// TestDiagCodes_ListsEachCodeInReportOrder pins that repeats are kept and the +// order is the report's, not a sorted one. +func TestDiagCodes_ListsEachCodeInReportOrder(t *testing.T) { + t.Parallel() + diags := []ir.Diagnostic{{Code: "b"}, {Code: "a"}, {Code: "b"}} + assert.Equal(t, []string{"b", "a", "b"}, irtest.DiagCodes(diags)) + assert.Empty(t, irtest.DiagCodes(nil)) +} diff --git a/ir/irtest/encode.go b/ir/irtest/encode.go new file mode 100644 index 00000000..d95a4ea7 --- /dev/null +++ b/ir/irtest/encode.go @@ -0,0 +1,37 @@ +package irtest + +import ( + "encoding/json/v2" + "strings" + "testing" + + "github.com/dexpace/morphic/ir" +) + +// Encoded returns doc's canonical JSON, which changes when anything doc +// reaches does, and fails the test when doc does not encode. +func Encoded(t *testing.T, doc *ir.Document) string { + t.Helper() + return encoded(t, doc) +} + +// encoded holds Encoded's logic against the minimal testingT surface, so its +// failure is testable. +func encoded(t testingT, doc *ir.Document) string { + t.Helper() + raw, err := json.Marshal(doc) + if err != nil { + t.Fatalf("encode the document: %v", err) + return "" + } + return string(raw) +} + +// RefusedAsCyclic reports whether the JSON encoder refuses doc for containing +// itself. The encoder's sentinel is not exported, so its message is all there +// is to read. That serves, since the question is what the encoder makes of doc, +// not what irverify or ir's own walk does. +func RefusedAsCyclic(doc *ir.Document) bool { + _, err := json.Marshal(doc) + return err != nil && strings.Contains(err.Error(), "cycle") +} diff --git a/ir/irtest/encode_test.go b/ir/irtest/encode_test.go new file mode 100644 index 00000000..db2ae667 --- /dev/null +++ b/ir/irtest/encode_test.go @@ -0,0 +1,56 @@ +package irtest_test + +import ( + "encoding/json/v2" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" +) + +// TestEncoded_IsTheDocumentsCanonicalJSON pins that Encoded is the document's +// own encoding, not a rendering of its own. +func TestEncoded_IsTheDocumentsCanonicalJSON(t *testing.T) { + t.Parallel() + doc := irtest.DocOf(irtest.ScalarOf("t/x/S", "")) + doc.IRVersion = ir.IRVersion + want, err := json.Marshal(doc) + require.NoError(t, err) + assert.Equal(t, string(want), irtest.Encoded(t, doc)) +} + +// TestEncoded_FailsTheTestOnADocumentThatDoesNotEncode drives the failure on +// its own goroutine, since the recorder's Fatalf unwinds the one it runs on. +func TestEncoded_FailsTheTestOnADocumentThatDoesNotEncode(t *testing.T) { + t.Parallel() + rec := &recordingT{} + done := make(chan struct{}) + go func() { + defer close(done) + irtest.EncodedOn(rec, badExtDoc()) + }() + <-done + assert.True(t, rec.aborted) + assert.Contains(t, rec.fatalf, "encode the document") +} + +// TestRefusedAsCyclic_ReadsOnlyTheCycleRefusal pins both halves: a value that +// contains itself is refused as a cycle, and neither a document that encodes +// nor one refused for another reason is. +func TestRefusedAsCyclic_ReadsOnlyTheCycleRefusal(t *testing.T) { + t.Parallel() + c := &ir.CtorValue{Scalar: "t/x/S", Name: "from"} + self := ir.Value{Kind: ir.ValueCtor, Ctor: c} + c.Args = []ir.Value{self} + cyclic := irtest.DocOf(irtest.ModelOf("t/x/M", func(m *ir.Model) { + m.Examples = []ir.Example{{Value: &self}} + })) + cyclic.IRVersion = ir.IRVersion + + assert.True(t, irtest.RefusedAsCyclic(cyclic)) + assert.False(t, irtest.RefusedAsCyclic(&ir.Document{IRVersion: ir.IRVersion})) + assert.False(t, irtest.RefusedAsCyclic(badExtDoc()), "refused, but not as a cycle") +} diff --git a/ir/irtest/export_test.go b/ir/irtest/export_test.go index 7dc1c4be..02e940a2 100644 --- a/ir/irtest/export_test.go +++ b/ir/irtest/export_test.go @@ -3,6 +3,9 @@ package irtest // CompareGoldenOn is compareGolden, for the external tests. var CompareGoldenOn = compareGolden +// EncodedOn is encoded, for the external tests. +var EncodedOn = encoded + // FillOn is fill, for the external tests. var FillOn = fill diff --git a/ir/irtest/golden.go b/ir/irtest/golden.go index 0aea04e9..8de2dd5a 100644 --- a/ir/irtest/golden.go +++ b/ir/irtest/golden.go @@ -1,6 +1,6 @@ -// Package irtest provides test helpers for IR documents: golden snapshots, the -// namespaces a document's IDs live in, values with every field filled, a -// document nested to a chosen depth, and the repository root fixtures sit in. +// Package irtest provides test helpers for IR documents: golden snapshots, +// fixtures built in one call, filled or nested to a chosen depth, a document's +// encoding and ID namespaces, diagnostics' codes, and the repository's root. // // A golden stores a document without its irVersion. The stamp names the shape // of the JSON, which a test in package ir pins against that shape, so storing diff --git a/ir/irtest/nesting.go b/ir/irtest/nesting.go index 7aa1229e..db996bc2 100644 --- a/ir/irtest/nesting.go +++ b/ir/irtest/nesting.go @@ -20,3 +20,13 @@ func NestedOverloadDoc(depth int) (*ir.Document, ir.OpID) { }, }}}, target } + +// NestedListValue returns a Value that is depth levels of single-element lists +// wrapping a number, the shape a deeply nested array default lowers to. +func NestedListValue(depth int) ir.Value { + v := ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("0")} + for range depth { + v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} + } + return v +} diff --git a/ir/irtest/nesting_test.go b/ir/irtest/nesting_test.go index af7d954f..da3e6330 100644 --- a/ir/irtest/nesting_test.go +++ b/ir/irtest/nesting_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irtest" ) @@ -29,3 +30,16 @@ func TestNestedOverloadDoc_NestsTheDeclarationAsDeepAsAsked(t *testing.T) { require.Len(t, g.Operations, 1) assert.Equal(t, target, g.Operations[0].ID) } + +// TestNestedListValue_WrapsANumberInDepthLists pins the depth: each level is a +// one-element list, and the number sits at the bottom. +func TestNestedListValue_WrapsANumberInDepthLists(t *testing.T) { + t.Parallel() + v := irtest.NestedListValue(3) + for range 3 { + require.Equal(t, ir.ValueList, v.Kind) + require.Len(t, v.List, 1) + v = v.List[0] + } + assert.Equal(t, ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("0")}, v) +} diff --git a/ir/irtest/typedefs.go b/ir/irtest/typedefs.go new file mode 100644 index 00000000..410397b5 --- /dev/null +++ b/ir/irtest/typedefs.go @@ -0,0 +1,39 @@ +package irtest + +import "github.com/dexpace/morphic/ir" + +// DocOf builds a document whose registry holds defs, each keyed by its own ID. +func DocOf(defs ...ir.TypeDef) *ir.Document { + types := ir.TypeRegistry{} + for _, d := range defs { + types[d.Common().ID] = d + } + return &ir.Document{Types: types} +} + +// ModelOf builds the model id, shaped by mutate when it is not nil. +func ModelOf(id ir.TypeID, mutate func(*ir.Model)) *ir.Model { + m := &ir.Model{ID: id} + if mutate != nil { + mutate(m) + } + return m +} + +// ScalarOf builds the scalar id over base, or an opaque one when base is empty. +func ScalarOf(id, base ir.TypeID) *ir.Scalar { + s := &ir.Scalar{ID: id} + if base != "" { + s.Base = &ir.TypeRef{Target: base} + } + return s +} + +// RefsTo builds a reference to each of ids. +func RefsTo(ids ...ir.TypeID) []ir.TypeRef { + out := make([]ir.TypeRef, 0, len(ids)) + for _, id := range ids { + out = append(out, ir.TypeRef{Target: id}) + } + return out +} diff --git a/ir/irtest/typedefs_test.go b/ir/irtest/typedefs_test.go new file mode 100644 index 00000000..cbe99611 --- /dev/null +++ b/ir/irtest/typedefs_test.go @@ -0,0 +1,50 @@ +package irtest_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" +) + +// TestDocOf_KeysEachDefinitionByItsOwnID pins the registry the builders feed: +// every definition, under the ID it carries. +func TestDocOf_KeysEachDefinitionByItsOwnID(t *testing.T) { + t.Parallel() + m, s := irtest.ModelOf("t/x/M", nil), irtest.ScalarOf("t/x/S", "") + assert.Equal(t, ir.TypeRegistry{"t/x/M": m, "t/x/S": s}, irtest.DocOf(m, s).Types) + assert.Empty(t, irtest.DocOf().Types) +} + +// TestModelOf_ShapesTheModelOnlyThroughMutate pins both arms: no mutate leaves +// the bare model, and a mutate sees the model already carrying its ID. +func TestModelOf_ShapesTheModelOnlyThroughMutate(t *testing.T) { + t.Parallel() + assert.Equal(t, &ir.Model{ID: "t/x/M"}, irtest.ModelOf("t/x/M", nil)) + + var seen ir.TypeID + got := irtest.ModelOf("t/x/M", func(m *ir.Model) { + seen = m.ID + m.Mixins = irtest.RefsTo("t/x/A") + }) + assert.Equal(t, ir.TypeID("t/x/M"), seen) + assert.Equal(t, &ir.Model{ID: "t/x/M", Mixins: []ir.TypeRef{{Target: "t/x/A"}}}, got) +} + +// TestScalarOf_IsOpaqueWithoutABase pins that an empty base leaves Base nil +// rather than a reference to the empty ID. +func TestScalarOf_IsOpaqueWithoutABase(t *testing.T) { + t.Parallel() + assert.Equal(t, &ir.Scalar{ID: "t/x/S"}, irtest.ScalarOf("t/x/S", "")) + assert.Equal(t, &ir.Scalar{ID: "t/x/S", Base: &ir.TypeRef{Target: "t/prim/string"}}, + irtest.ScalarOf("t/x/S", "t/prim/string")) +} + +// TestRefsTo_RefersToEachIDInOrder pins the order and the empty case. +func TestRefsTo_RefersToEachIDInOrder(t *testing.T) { + t.Parallel() + assert.Equal(t, []ir.TypeRef{{Target: "t/x/B"}, {Target: "t/x/A"}}, irtest.RefsTo("t/x/B", "t/x/A")) + assert.Empty(t, irtest.RefsTo()) +} diff --git a/ir/irverify/cycles_test.go b/ir/irverify/cycles_test.go index ec949fe3..eb0b671b 100644 --- a/ir/irverify/cycles_test.go +++ b/ir/irverify/cycles_test.go @@ -1,15 +1,14 @@ package irverify_test import ( - "encoding/json/v2" "strconv" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/ir/irverify" ) @@ -49,15 +48,6 @@ func cyclePaths(t *testing.T, doc *ir.Document) []string { return violationPaths(got, "ir/cyclic-structure") } -// refusedAsCyclic reports whether the JSON encoder refuses doc for containing -// itself. The encoder's sentinel is not exported, so the message is all there is -// to read; that is acceptable here because the test asks what the encoder -// thinks, not what Verify does, and must not borrow Verify's own reading. -func refusedAsCyclic(doc *ir.Document) bool { - _, err := json.Marshal(doc) - return err != nil && strings.Contains(err.Error(), "cycle") -} - // cyclicDocs are the self-containing shapes the ir package can hold: one for // every pointer and slice type that reaches itself, which // TestVerify_CyclicDocsCoverEverySelfReachableType holds the table to, and a @@ -188,7 +178,7 @@ func TestVerify_CyclicStructureIsAViolation(t *testing.T) { t.Parallel() doc := c.doc() assert.Equal(t, c.want, cyclePaths(t, doc)) - assert.True(t, refusedAsCyclic(doc), "the encoder must refuse what Verify calls a cycle") + assert.True(t, irtest.RefusedAsCyclic(doc), "the encoder must refuse what Verify calls a cycle") }) } } @@ -260,7 +250,7 @@ func TestVerify_SharedReferenceIsNotACycle(t *testing.T) { t.Parallel() doc := c.doc() assert.Empty(t, cyclePaths(t, doc)) - assert.False(t, refusedAsCyclic(doc), "the encoder must accept what Verify does not call a cycle") + assert.False(t, irtest.RefusedAsCyclic(doc), "the encoder must accept what Verify does not call a cycle") }) } } @@ -279,7 +269,7 @@ func TestVerify_RegistryKeysCannotMakeASharedPointerACycle(t *testing.T) { got := verifyDeclared(doc) assert.Empty(t, violationPaths(got, "ir/cyclic-structure")) assert.Contains(t, codesOf(got), "ir/type-id-mismatch", "the key that disagrees is still reported") - assert.False(t, refusedAsCyclic(doc)) + assert.False(t, irtest.RefusedAsCyclic(doc)) } // TestVerify_CyclicStructureIsReportedOnceAtEachWayIn pins what a list reachable @@ -295,7 +285,7 @@ func TestVerify_CyclicStructureIsReportedOnceAtEachWayIn(t *testing.T) { "doc.Types[t/x/M].Examples[0].Value.List[0].List", "doc.Types[t/x/M].Examples[1].Value.List[0].List", }, cyclePaths(t, doc)) - assert.True(t, refusedAsCyclic(doc)) + assert.True(t, irtest.RefusedAsCyclic(doc)) } // TestVerify_CyclicStructureInADeepDocumentIsStillFound guards the wiring rather diff --git a/ir/irverify/document_test.go b/ir/irverify/document_test.go index 7bd69f71..a08fd364 100644 --- a/ir/irverify/document_test.go +++ b/ir/irverify/document_test.go @@ -43,7 +43,7 @@ func TestVerify_DocumentWithoutServicesIsAViolation(t *testing.T) { got := irverify.Verify(doc) assert.Equal(t, []string{"ir/services-absent"}, codesOf(got)) - assert.Equal(t, []string{"doc.Services"}, pathsOf(got, "ir/services-absent")) + assert.Equal(t, []string{"doc.Services"}, violationPaths(got, "ir/services-absent")) }) } } @@ -65,7 +65,7 @@ func TestVerify_DocumentWithoutSourcesIsAViolation(t *testing.T) { got := irverify.Verify(doc) assert.Equal(t, []string{"ir/sources-absent"}, codesOf(got)) - assert.Equal(t, []string{"doc.Sources"}, pathsOf(got, "ir/sources-absent")) + assert.Equal(t, []string{"doc.Sources"}, violationPaths(got, "ir/sources-absent")) }) } } @@ -100,7 +100,7 @@ func TestVerify_SourceFormatMustBeANameAndAVersion(t *testing.T) { return } assert.Equal(t, []string{"ir/source-format-malformed"}, codesOf(got)) - assert.Equal(t, []string{"doc.Sources[0].Format"}, pathsOf(got, "ir/source-format-malformed")) + assert.Equal(t, []string{"doc.Sources[0].Format"}, violationPaths(got, "ir/source-format-malformed")) }) } } @@ -143,7 +143,7 @@ func TestVerify_SourceHashMustBeLowercaseSHA256Hex(t *testing.T) { return } assert.Equal(t, []string{"ir/source-hash-malformed"}, codesOf(got)) - assert.Equal(t, []string{"doc.Sources[0].Hash"}, pathsOf(got, "ir/source-hash-malformed")) + assert.Equal(t, []string{"doc.Sources[0].Hash"}, violationPaths(got, "ir/source-hash-malformed")) }) } } diff --git a/ir/irverify/helpers_test.go b/ir/irverify/helpers_test.go new file mode 100644 index 00000000..dad94d08 --- /dev/null +++ b/ir/irverify/helpers_test.go @@ -0,0 +1,27 @@ +// Package irverify_test holds the tests of the irverify package. The helpers +// several files share live here. +package irverify_test + +import "github.com/dexpace/morphic/ir/irverify" + +// violationPaths returns the paths of the violations of one code, in report +// order. +func violationPaths(vs []irverify.Violation, code string) []string { + var out []string + for _, v := range vs { + if v.Code == code { + out = append(out, v.Path) + } + } + return out +} + +// violationCodes returns the codes of vs, for set-membership assertions that do +// not depend on violation order or message wording. +func violationCodes(vs []irverify.Violation) []string { + out := make([]string, 0, len(vs)) + for _, v := range vs { + out = append(out, v.Code) + } + return out +} diff --git a/ir/irverify/ids_test.go b/ir/irverify/ids_test.go index f03013ac..3012508b 100644 --- a/ir/irverify/ids_test.go +++ b/ir/irverify/ids_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" - "github.com/dexpace/morphic/ir/irverify" ) // TestVerify_MalformedTypeIDIsAViolation plants IDs the grammar could not have @@ -327,18 +326,6 @@ func declaredIDClassNamed(t *testing.T, name string) declaredIDClass { return declaredIDClass{} } -// violationPaths returns the paths of the violations of one code, in report -// order. -func violationPaths(vs []irverify.Violation, code string) []string { - var out []string - for _, v := range vs { - if v.Code == code { - out = append(out, v.Path) - } - } - return out -} - // TestVerify_MalformedDeclaredIDIsAViolation holds the classes that have no // registry key to the grammar checkIDs holds a type to: an operation, a service, // a group or a property whose ID the grammar could not have produced is @@ -473,13 +460,3 @@ func TestVerify_ParameterIDMayDifferFromItsPointer(t *testing.T) { mounted := parameter.doc("param/openapi/paths/~1widgets/get/parameters/id/query", "/components/pathItems/Listing/parameters/0") assert.Empty(t, violationPaths(verifyDeclared(mounted), "ir/id-provenance-disagreement")) } - -// violationCodes returns the codes of vs, for set-membership assertions that do -// not depend on violation order or message wording. -func violationCodes(vs []irverify.Violation) []string { - out := make([]string, 0, len(vs)) - for _, v := range vs { - out = append(out, v.Code) - } - return out -} diff --git a/ir/irverify/irverify_walkdepth_test.go b/ir/irverify/irverify_walkdepth_test.go index 9e664795..f2ae0cc5 100644 --- a/ir/irverify/irverify_walkdepth_test.go +++ b/ir/irverify/irverify_walkdepth_test.go @@ -6,25 +6,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) -// nestedListValue returns a Value that is depth levels of single-element lists -// wrapping a number — the shape a deeply-nested array default lowers to. -func nestedListValue(depth int) ir.Value { - v := ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("0")} - for range depth { - v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} - } - return v -} - // TestVerify_DeepInBoundsDefaultIsNotTruncated builds a property default nested // far deeper than the former 256 reflection cap allowed but within the range a // compiler accepts, and asserts the verifier traverses it fully instead of // reporting ir/walk-truncated. Regression for the value-depth vs walk-depth // bound mismatch. func TestVerify_DeepInBoundsDefaultIsNotTruncated(t *testing.T) { - def := nestedListValue(200) + def := irtest.NestedListValue(200) m := &ir.Model{ ID: "t/x/M", Name: ir.Naming{Source: "M", Canonical: "m"}, diff --git a/ir/irverify/kinds_test.go b/ir/irverify/kinds_test.go index 8e6b9aed..fc13afd3 100644 --- a/ir/irverify/kinds_test.go +++ b/ir/irverify/kinds_test.go @@ -72,8 +72,8 @@ func TestVerify_NilTypeBesideAPrimitiveDoesNotPanic(t *testing.T) { var got []irverify.Violation require.NotPanics(t, func() { got = verifyDeclared(doc) }) - assert.Contains(t, codes(got), "ir/unknown-prim-kind", "the bad kind is still reported") - assert.Contains(t, codes(got), "ir/nil-type", "the nil entry is reported by its own check") + assert.Contains(t, violationCodes(got), "ir/unknown-prim-kind", "the bad kind is still reported") + assert.Contains(t, violationCodes(got), "ir/nil-type", "the nil entry is reported by its own check") }) } } diff --git a/ir/irverify/maps_test.go b/ir/irverify/maps_test.go index b9dc2b45..4c0dde88 100644 --- a/ir/irverify/maps_test.go +++ b/ir/irverify/maps_test.go @@ -35,22 +35,11 @@ func TestCheckMaps_ReportsAModelThatIsAMap(t *testing.T) { tc.td.Common().ID = "t/x" doc := &ir.Document{Types: ir.TypeRegistry{"t/x": tc.td}} - assert.Equal(t, tc.want, pathsOf(irverify.Verify(doc), "ir/map-as-model")) + assert.Equal(t, tc.want, violationPaths(irverify.Verify(doc), "ir/map-as-model")) }) } } -// pathsOf returns the Path of each violation in vs carrying code, in order. -func pathsOf(vs []irverify.Violation, code string) []string { - var out []string - for _, v := range vs { - if v.Code == code { - out = append(out, v.Path) - } - } - return out -} - // TestCheckMapKeys_ReportsAStringKeySpelledOut pins ir/map-key-string: a map // or catch-all naming the string primitive as its key is reported at that // key, since a nil Key already states string keys. A nil key, another key type @@ -82,7 +71,7 @@ func TestCheckMapKeys_ReportsAStringKeySpelledOut(t *testing.T) { } doc := &ir.Document{Types: ir.TypeRegistry{"t/x": tc.td}} - assert.Equal(t, tc.want, pathsOf(irverify.Verify(doc), "ir/map-key-string")) + assert.Equal(t, tc.want, violationPaths(irverify.Verify(doc), "ir/map-key-string")) }) } } diff --git a/ir/irverify/typerefs_test.go b/ir/irverify/typerefs_test.go index 6734f854..64658433 100644 --- a/ir/irverify/typerefs_test.go +++ b/ir/irverify/typerefs_test.go @@ -87,7 +87,7 @@ func TestVerify_UnionVariantWithNoTargetIsAViolation(t *testing.T) { }) all := verifyDeclared(doc) - assert.NotContains(t, codes(all), "ir/dangling-type-ref", "an empty target is not a dangling one") + assert.NotContains(t, violationCodes(all), "ir/dangling-type-ref", "an empty target is not a dangling one") got := typeRefViolations(t, doc) require.Len(t, got, 1, "the empty target is reported exactly once") diff --git a/ir/irverify/unions_test.go b/ir/irverify/unions_test.go index 16e94b01..0fe9db0c 100644 --- a/ir/irverify/unions_test.go +++ b/ir/irverify/unions_test.go @@ -103,21 +103,11 @@ func TestVerify_NilTypeBesideAUnionDoesNotPanic(t *testing.T) { var got []irverify.Violation require.NotPanics(t, func() { got = verifyDeclared(doc) }) assert.Len(t, unionViolations(t, doc, codeNoVariants), 1, "the empty union is still reported") - assert.Contains(t, codes(got), "ir/nil-type", "the nil entry is reported by its own check") + assert.Contains(t, violationCodes(got), "ir/nil-type", "the nil entry is reported by its own check") }) } } -// codes returns the violation codes in vs, for assertions about which checks -// fired rather than about their order. -func codes(vs []irverify.Violation) []string { - out := make([]string, 0, len(vs)) - for _, v := range vs { - out = append(out, v.Code) - } - return out -} - // TestVerify_UnionTagModes holds unions to the tag modes ir-design §4.4 defines: // a discriminator routes only a WireTagged union, whether it locates its tag by // property name or by tuple position. Each other combination of the two fields diff --git a/ir/walk_cycle_test.go b/ir/walk_cycle_test.go index 6056d08f..63f39c14 100644 --- a/ir/walk_cycle_test.go +++ b/ir/walk_cycle_test.go @@ -1,10 +1,8 @@ package ir_test import ( - "encoding/json/v2" "math/rand/v2" "reflect" - "strings" "testing" "github.com/google/go-cmp/cmp" @@ -12,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // walkLimit is how many values a test lets a walk reach before its visitor prunes @@ -217,7 +216,7 @@ func TestValueCycles_IsDeterministic(t *testing.T) { // walk does: a cycle past the depth cap is not found by a walk that stopped. func TestValueCycles_ReportsTruncation(t *testing.T) { t.Parallel() - _, truncated := ir.ValueCycles(nestedListValue(ir.MaxWalkDepth), "doc") + _, truncated := ir.ValueCycles(irtest.NestedListValue(ir.MaxWalkDepth), "doc") assert.True(t, truncated) } @@ -370,14 +369,6 @@ func countEdges(td ir.TypeDef, limit int) int { return n } -// encoderRefuses reports whether the JSON encoder refuses doc as containing -// itself. The sentinel is not exported, so the message is what there is to read; -// the question is the encoder's, not ours, and that is the point of asking it. -func encoderRefuses(doc *ir.Document) bool { - _, err := json.Marshal(doc) - return err != nil && strings.Contains(err.Error(), "cycle") -} - // TestValueCycles_AgreesWithTheEncoder holds the claim the verifier rests on: // a document is called cyclic exactly when the encoder refuses it. Graphs are // drawn at random and three judges asked about each: a search that never sees a @@ -402,7 +393,7 @@ func TestValueCycles_AgreesWithTheEncoder(t *testing.T) { cycles, truncated := ir.ValueCycles(doc, ir.DocumentPath) require.False(t, truncated, "%+v", nodes) require.Equal(t, want, len(cycles) > 0, "the walk disagrees with the graph drawn: %+v", nodes) - require.Equal(t, want, encoderRefuses(doc), "the encoder disagrees with the graph drawn: %+v", nodes) + require.Equal(t, want, irtest.RefusedAsCyclic(doc), "the encoder disagrees with the graph drawn: %+v", nodes) ctors := unfoldedCtors(nodes) require.Equal(t, ctors, countEdges(model, ctors), "TypeEdges does not end where the graph drawn does: %+v", nodes) diff --git a/ir/walk_test.go b/ir/walk_test.go index c9716238..102bce73 100644 --- a/ir/walk_test.go +++ b/ir/walk_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" ) // walkPaths returns every path ir.WalkValues reaches from root, in walk order, @@ -23,16 +24,6 @@ func walkPaths(root any) ([]string, bool) { return paths, truncated } -// nestedListValue returns a Value that is depth levels of single-element lists -// wrapping a number — the shape a deeply-nested array default lowers to. -func nestedListValue(depth int) ir.Value { - v := ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("0")} - for range depth { - v = ir.Value{Kind: ir.ValueList, List: []ir.Value{v}} - } - return v -} - // TestWalkValues_PathsSpellFieldsIndicesAndKeys pins the three path rules two // checkers report against: a field is joined by ".", a slice index and a map key // sit in brackets (a key carrying ir.MapKeySuffix so a caller can tell it from a @@ -94,10 +85,10 @@ func TestWalkValues_DeepValueTreeIsTruncated(t *testing.T) { }}} } - _, truncated := walkPaths(docWith(nestedListValue(ir.MaxWalkDepth))) + _, truncated := walkPaths(docWith(irtest.NestedListValue(ir.MaxWalkDepth))) assert.True(t, truncated, "a tree nested past the cap must report truncation") - _, truncated = walkPaths(docWith(nestedListValue(200))) + _, truncated = walkPaths(docWith(irtest.NestedListValue(200))) assert.False(t, truncated, "a tree a compiler can produce must be walked whole") } diff --git a/pass/validate_discriminators_test.go b/pass/validate_discriminators_test.go index 7ed74e07..3d97d0fc 100644 --- a/pass/validate_discriminators_test.go +++ b/pass/validate_discriminators_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -24,7 +25,7 @@ func TestValidate_DiscriminatorMissingVariant(t *testing.T) { } diags := pass.Validate(doc) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "pass/discriminator-missing-variant") + assert.Contains(t, irtest.DiagCodes(diags), "pass/discriminator-missing-variant") } // TestValidate_ModelDiscriminator drives checkModelDiscriminator over the ways @@ -100,7 +101,7 @@ func TestValidate_SubtypeThroughAliasChain(t *testing.T) { diags := pass.Validate(doc) assert.Equal(t, 1, countCode(t, diags, "pass/discriminator-missing-variant"), - "only the cyclic alias fails to reach the base: %v", codes(diags)) + "only the cyclic alias fails to reach the base: %v", irtest.DiagCodes(diags)) assert.Contains(t, messageForCode(t, diags, "pass/discriminator-missing-variant"), "t/looped") } diff --git a/pass/validate_graphql_test.go b/pass/validate_graphql_test.go index 4c052ce0..bbbf6503 100644 --- a/pass/validate_graphql_test.go +++ b/pass/validate_graphql_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -19,7 +20,7 @@ func TestValidate_ArgsOutsideGraphQL(t *testing.T) { } diags := pass.Validate(doc) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "pass/args-outside-graphql") + assert.Contains(t, irtest.DiagCodes(diags), "pass/args-outside-graphql") } // TestValidate_GraphQLReachableTypesAllowArgs drives graphqlReachableTypes: an @@ -50,7 +51,7 @@ func TestValidate_GraphQLReachableTypesAllowArgs(t *testing.T) { ID: "s", Groups: []ir.OperationGroup{{Operations: []ir.Operation{op}}}, }} - assert.NotContains(t, codes(pass.Validate(doc)), "pass/args-outside-graphql") + assert.NotContains(t, irtest.DiagCodes(pass.Validate(doc)), "pass/args-outside-graphql") } // argModel is a model whose one property carries a field argument, so the model @@ -120,13 +121,13 @@ func TestValidate_GraphQLReachabilityFollowsEveryReference(t *testing.T) { tc.bind(doc, &op) doc.Services = []ir.Service{{ID: "s", Groups: []ir.OperationGroup{{Operations: []ir.Operation{op}}}}} - assert.NotContains(t, codes(pass.Validate(doc)), "pass/args-outside-graphql") + assert.NotContains(t, irtest.DiagCodes(pass.Validate(doc)), "pass/args-outside-graphql") // The other half of the proof: the same document with the binding // removed must report, or the case would pass on a reachability walk // that reaches nothing at all. doc.Services[0].Groups[0].Operations[0].Bindings.GraphQL = nil - assert.Contains(t, codes(pass.Validate(doc)), "pass/args-outside-graphql") + assert.Contains(t, irtest.DiagCodes(pass.Validate(doc)), "pass/args-outside-graphql") }) } } @@ -153,11 +154,11 @@ func TestValidate_ArgsCheckFailsOpenWhenWalkTruncated(t *testing.T) { return doc } - within := codes(pass.Validate(withArgs(nestGroups(127, gqlOp())))) + within := irtest.DiagCodes(pass.Validate(withArgs(nestGroups(127, gqlOp())))) assert.NotContains(t, within, "pass/args-outside-graphql", "the binding is visible within the cap, so Args is legal") - past := codes(pass.Validate(withArgs(nestGroups(200, gqlOp())))) + past := irtest.DiagCodes(pass.Validate(withArgs(nestGroups(200, gqlOp())))) assert.NotContains(t, past, "pass/args-outside-graphql", "past the cap the binding went unseen; unreachable-by-truncation is not evidence of illegality") assert.Contains(t, past, "ir/walk-truncated", "and the truncation must be reported instead") diff --git a/pass/validate_groups_test.go b/pass/validate_groups_test.go index 11489b6e..73dd2ef5 100644 --- a/pass/validate_groups_test.go +++ b/pass/validate_groups_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -21,7 +22,7 @@ func TestValidate_GroupWalkTruncationIsReported(t *testing.T) { // actually skipped fires at exactly the depth where the last operation still // fits — visible only by testing ir.MaxGroupDepth itself. for _, depth := range []int{1, 127, 128} { - within := codes(pass.Validate(nestGroups(depth, oneway))) + within := irtest.DiagCodes(pass.Validate(nestGroups(depth, oneway))) assert.Contains(t, within, "pass/oneway-with-responses", "at depth %d the operation is still reached", depth) assert.NotContains(t, within, "ir/walk-truncated", @@ -29,7 +30,7 @@ func TestValidate_GroupWalkTruncationIsReported(t *testing.T) { } for _, depth := range []int{129, 200} { - past := codes(pass.Validate(nestGroups(depth, oneway))) + past := irtest.DiagCodes(pass.Validate(nestGroups(depth, oneway))) assert.Contains(t, past, "ir/walk-truncated", "at depth %d the walk stopped early and must say so", depth) assert.NotContains(t, past, "pass/oneway-with-responses", @@ -52,7 +53,7 @@ func TestValidate_ASelfContainingGroupIsReachedOnceAndIsNotATruncation(t *testin doc.Services = []ir.Service{{ID: "s", Groups: groups}} diags := pass.Validate(doc) - assert.NotContains(t, codes(diags), "ir/walk-truncated", "nothing was skipped") + assert.NotContains(t, irtest.DiagCodes(diags), "ir/walk-truncated", "nothing was skipped") assert.Equal(t, 1, countCode(t, diags, "pass/oneway-with-responses"), "the operation is reached once, not once per lap") } diff --git a/pass/validate_messages_test.go b/pass/validate_messages_test.go index 687df68e..3da317dd 100644 --- a/pass/validate_messages_test.go +++ b/pass/validate_messages_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -56,7 +57,7 @@ func TestValidate_MessagesCarriedByChannelAreClean(t *testing.T) { // the defect on its own. func TestValidate_MessageBindingWithoutAResolvableChannel(t *testing.T) { t.Parallel() - codes := codes(pass.Validate(messageBindingDoc("chan/ghost", + codes := irtest.DiagCodes(pass.Validate(messageBindingDoc("chan/ghost", []ir.MessageID{"msg/a"}, []ir.MessageID{"msg/b"}))) assert.Contains(t, codes, "ir/dangling-channel-ref") @@ -68,7 +69,7 @@ func TestValidate_MessageBindingWithoutAResolvableChannel(t *testing.T) { // to. func TestValidate_OperationWithoutAMessageBindingIsClean(t *testing.T) { t.Parallel() - assert.NotContains(t, codes(pass.Validate(docWithOperation(ir.Operation{ID: "op"}))), + assert.NotContains(t, irtest.DiagCodes(pass.Validate(docWithOperation(ir.Operation{ID: "op"}))), "pass/message-not-in-channel") } diff --git a/pass/validate_niltypes_test.go b/pass/validate_niltypes_test.go index ed1bdd8d..0d323dfe 100644 --- a/pass/validate_niltypes_test.go +++ b/pass/validate_niltypes_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -63,7 +64,7 @@ func TestValidate_TypedNilMappingTargetDoesNotPanic(t *testing.T) { var diags []ir.Diagnostic require.NotPanics(t, func() { diags = pass.Validate(doc) }) - got := codes(diags) + got := irtest.DiagCodes(diags) assert.Contains(t, got, "ir/nil-type") assert.Contains(t, got, "pass/discriminator-missing-variant") } @@ -84,7 +85,7 @@ func TestValidate_TypedNilAliasInSupertypeChainDoesNotPanic(t *testing.T) { var diags []ir.Diagnostic require.NotPanics(t, func() { diags = pass.Validate(doc) }) - got := codes(diags) + got := irtest.DiagCodes(diags) assert.Contains(t, got, "ir/nil-type") assert.Contains(t, got, "pass/discriminator-missing-variant") } diff --git a/pass/validate_params_test.go b/pass/validate_params_test.go index 2b669ec1..4f8f04a8 100644 --- a/pass/validate_params_test.go +++ b/pass/validate_params_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -82,7 +83,7 @@ func TestValidate_ParamBinding_SameNameDifferentLocation(t *testing.T) { ir.Parameter{ID: "param/x/get/id/path", Name: ir.Naming{Source: "id"}}, ) op.Bindings.HTTP[0].ParamBindings[1].Location = ir.HTTPLocationPath - assert.NotContains(t, codes(pass.Validate(docWithOperation(op))), "pass/param-binding-mismatch") + assert.NotContains(t, irtest.DiagCodes(pass.Validate(docWithOperation(op))), "pass/param-binding-mismatch") } // TestValidate_ParamBinding_OtherOperationsParamIsRejected pins that a binding @@ -124,7 +125,7 @@ func TestValidate_HostParamBoundMultipleTimesIsLegal(t *testing.T) { }, }}}, } - assert.NotContains(t, codes(pass.Validate(docWithOperation(op))), "pass/param-binding-mismatch") + assert.NotContains(t, irtest.DiagCodes(pass.Validate(docWithOperation(op))), "pass/param-binding-mismatch") } // paramRefOp returns an operation declaring one parameter and pointing its @@ -145,8 +146,8 @@ func TestValidate_ParamReferences_OwnOperationIsClean(t *testing.T) { t.Parallel() op := paramRefOp("op/x/put", "param/x/put/p", "param/x/put/p", "param/x/put/p", "param/x/put/p") diags := pass.Validate(docWithOperations(op)) - assert.NotContains(t, codes(diags), "pass/param-binding-mismatch") - assert.NotContains(t, codes(diags), "ir/dangling-param-ref") + assert.NotContains(t, irtest.DiagCodes(diags), "pass/param-binding-mismatch") + assert.NotContains(t, irtest.DiagCodes(diags), "ir/dangling-param-ref") } func TestValidate_ParamReferences_OtherOperationsParamIsRejected(t *testing.T) { diff --git a/pass/validate_payloads_test.go b/pass/validate_payloads_test.go index d31f023a..a9f7cceb 100644 --- a/pass/validate_payloads_test.go +++ b/pass/validate_payloads_test.go @@ -16,6 +16,7 @@ import ( "github.com/dexpace/morphic/internal/decls" "github.com/dexpace/morphic/internal/jsonshape" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -303,7 +304,7 @@ func TestValidate_EncodingKeyOnAnUndeclaredTypeIsReportedBesideTheDanglingRef(t c.Type = ir.TypeRef{Target: "t/missing"} c.Encoding = map[ir.PropID]ir.PartEncoding{"p/m/a": {Multi: true}} - got := codes(pass.Validate(doc)) + got := irtest.DiagCodes(pass.Validate(doc)) assert.Contains(t, got, "ir/dangling-type-ref") assert.Contains(t, got, "ir/encoding-key-unknown-property") } diff --git a/pass/validate_propids_test.go b/pass/validate_propids_test.go index 18f5c100..329782be 100644 --- a/pass/validate_propids_test.go +++ b/pass/validate_propids_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -33,11 +34,11 @@ func propPathOperation(path []ir.PropID) ir.Operation { func TestValidate_PropPathSegmentResolves(t *testing.T) { t.Parallel() ok := pass.Validate(docWithOperation(propPathOperation([]ir.PropID{"p/m/a"}))) - assert.NotContains(t, codes(ok), "ir/dangling-prop-ref", + assert.NotContains(t, irtest.DiagCodes(ok), "ir/dangling-prop-ref", "a segment naming a declared property resolves") bad := pass.Validate(docWithOperation(propPathOperation([]ir.PropID{"p/m/ghost"}))) - assert.Contains(t, codes(bad), "ir/dangling-prop-ref", + assert.Contains(t, irtest.DiagCodes(bad), "ir/dangling-prop-ref", "a segment naming no property is reported") } @@ -58,7 +59,7 @@ func TestValidate_ParamPathSegmentResolves(t *testing.T) { }}, }}}, } - assert.Contains(t, codes(pass.Validate(docWithOperation(op))), "ir/dangling-prop-ref") + assert.Contains(t, irtest.DiagCodes(pass.Validate(docWithOperation(op))), "ir/dangling-prop-ref") } // TestValidate_PropIDMapKeyIsLeftToTheEncodingCheck pins the one exclusion. A @@ -74,7 +75,7 @@ func TestValidate_PropIDMapKeyIsLeftToTheEncodingCheck(t *testing.T) { multipartContent(map[ir.PropID]ir.PartEncoding{"p/m/ghost": {Multi: true}}), }}, }) - got := codes(pass.Validate(doc)) + got := irtest.DiagCodes(pass.Validate(doc)) assert.Contains(t, got, "ir/encoding-key-unknown-property", "the tighter claim is made") assert.NotContains(t, got, "ir/dangling-prop-ref", "and it is the only one") } @@ -94,10 +95,10 @@ func discriminatedDoc(prop ir.PropID) *ir.Document { // emitter reads the tag off an instance of *this* model. func TestValidate_DiscriminatorPropertyBelongsToItsModel(t *testing.T) { t.Parallel() - assert.NotContains(t, codes(pass.Validate(discriminatedDoc("p/m/a"))), + assert.NotContains(t, irtest.DiagCodes(pass.Validate(discriminatedDoc("p/m/a"))), "pass/discriminator-unknown-property", "the model declares the tag property") - assert.NotContains(t, codes(pass.Validate(discriminatedDoc(""))), + assert.NotContains(t, irtest.DiagCodes(pass.Validate(discriminatedDoc(""))), "pass/discriminator-unknown-property", "a union-style discriminator tags no property by identity") @@ -105,7 +106,7 @@ func TestValidate_DiscriminatorPropertyBelongsToItsModel(t *testing.T) { doc.Types["t/other"] = &ir.Model{ID: "t/other", Properties: []ir.Property{ {ID: "p/other/a", WireName: "a", Type: ir.TypeRef{Target: "t/prim/string"}}, }} - got := codes(pass.Validate(doc)) + got := irtest.DiagCodes(pass.Validate(doc)) assert.Contains(t, got, "pass/discriminator-unknown-property", "a property of an unrelated model is not this model's tag") assert.NotContains(t, got, "ir/dangling-prop-ref", @@ -123,6 +124,6 @@ func TestValidate_DiscriminatorPropertyThroughComposition(t *testing.T) { Base: &ir.TypeRef{Target: "t/m"}, Discriminator: &ir.Discriminator{Property: "p/m/a"}, } - assert.NotContains(t, codes(pass.Validate(doc)), "pass/discriminator-unknown-property", + assert.NotContains(t, irtest.DiagCodes(pass.Validate(doc)), "pass/discriminator-unknown-property", "a tag property reached through Base is one the model exposes") } diff --git a/pass/validate_refs_test.go b/pass/validate_refs_test.go index 3ce8fba1..7039df78 100644 --- a/pass/validate_refs_test.go +++ b/pass/validate_refs_test.go @@ -354,7 +354,7 @@ func TestValidate_DanglingTypeRef(t *testing.T) { m.Properties[0].Type.Target = "t/nowhere" diags := pass.Validate(doc) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "ir/dangling-type-ref") + assert.Contains(t, irtest.DiagCodes(diags), "ir/dangling-type-ref") assert.Equal(t, ir.SeverityError, diags[0].Severity) } @@ -374,7 +374,7 @@ func TestValidate_DanglingRefInMessagePayload(t *testing.T) { } diags := pass.Validate(doc) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "ir/dangling-type-ref") + assert.Contains(t, irtest.DiagCodes(diags), "ir/dangling-type-ref") } // TestValidate_DanglingRefsAcrossContainerKinds gives every reference a @@ -811,7 +811,7 @@ func TestValidate_DanglingAuthRef(t *testing.T) { }} diags := pass.Validate(doc) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "ir/dangling-auth-ref") + assert.Contains(t, irtest.DiagCodes(diags), "ir/dangling-auth-ref") } // TestValidate_PerOperationAuthOverride covers a per-operation auth override, @@ -827,7 +827,7 @@ func TestValidate_PerOperationAuthOverride(t *testing.T) { } diags := pass.Validate(docWithOperation(op)) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "ir/dangling-auth-ref") + assert.Contains(t, irtest.DiagCodes(diags), "ir/dangling-auth-ref") } // TestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations holds the one direction diff --git a/pass/validate_responses_test.go b/pass/validate_responses_test.go index 8cc3d638..7f1c2899 100644 --- a/pass/validate_responses_test.go +++ b/pass/validate_responses_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -15,7 +16,7 @@ func TestValidate_OneWayWithResponses(t *testing.T) { op := ir.Operation{ID: "op", OneWay: true, Responses: []ir.Response{{}}} diags := pass.Validate(docWithOperation(op)) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "pass/oneway-with-responses") + assert.Contains(t, irtest.DiagCodes(diags), "pass/oneway-with-responses") } // catchAll is the one condition an operation's Default may hold. diff --git a/pass/validate_test.go b/pass/validate_test.go index 788d4620..959d7f47 100644 --- a/pass/validate_test.go +++ b/pass/validate_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -55,14 +56,6 @@ func nestGroups(depth int, op ir.Operation) *ir.Document { return doc } -func codes(diags []ir.Diagnostic) []string { - out := make([]string, 0, len(diags)) - for _, d := range diags { - out = append(out, d.Code) - } - return out -} - // countCode returns how many of diags carry the given diagnostic code. func countCode(t *testing.T, diags []ir.Diagnostic, code string) int { t.Helper() @@ -123,7 +116,7 @@ func TestValidator_RunHandsBackItsInputWithValidatesFindings(t *testing.T) { {ID: "p/m/a", WireName: "a", Type: ir.TypeRef{Target: "t/missing"}}, }} want := pass.Validate(doc) - require.Contains(t, codes(want), "ir/dangling-type-ref") + require.Contains(t, irtest.DiagCodes(want), "ir/dangling-type-ref") before, err := json.Marshal(doc) require.NoError(t, err) diff --git a/pass/validate_wirenames_test.go b/pass/validate_wirenames_test.go index 4e6761dc..6b24b7a2 100644 --- a/pass/validate_wirenames_test.go +++ b/pass/validate_wirenames_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irtest" "github.com/dexpace/morphic/pass" ) @@ -19,7 +20,7 @@ func TestValidate_DuplicateWireName(t *testing.T) { }) diags := pass.Validate(doc) require.NotEmpty(t, diags) - assert.Contains(t, codes(diags), "pass/duplicate-wire-name") + assert.Contains(t, irtest.DiagCodes(diags), "pass/duplicate-wire-name") } // TestValidate_EmptyEffectiveWireNameIsSkipped covers effectiveWireName's @@ -39,7 +40,7 @@ func TestValidate_EmptyEffectiveWireNameIsSkipped(t *testing.T) { } // Two blank-named properties must NOT collide (both skipped), and the // source-named one is unique, so no duplicate-wire-name diagnostic fires. - assert.NotContains(t, codes(pass.Validate(doc)), "pass/duplicate-wire-name") + assert.NotContains(t, irtest.DiagCodes(pass.Validate(doc)), "pass/duplicate-wire-name") } // aliasProperty returns a string property of model t/m named source and From 98153a46227e13dfe71245d626b73d2e4a145e68 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 05:53:07 +0300 Subject: [PATCH 12/18] test: move out two more tests that need no internals resolve's locate tests and reader's disk_resolve tests need nothing an alias cannot carry. They stayed inside their packages only because they share fixtures with tests that must stay there. Each shared fixture is now declared once where both sides reach it: resolve's WholeModelAt and reader's VanishWorkingDirectory in export_test.go. locate reaches the walk through the At alias export_test.go already holds, and resolve's indexedDoc, which only resolve_internal_test.go uses, moves there. Each package's tests and subtests are unchanged, compared with go test -json, and each package keeps full statement coverage. --- .../openapi/internal/resolve/export_test.go | 17 +++++++- ...locate_internal_test.go => locate_test.go} | 40 +++---------------- .../internal/resolve/resolve_internal_test.go | 23 ++++++++++- ..._internal_test.go => disk_resolve_test.go} | 13 +++--- engine/reader/export_test.go | 33 +++++++++++++++ engine/reader/location_internal_test.go | 27 +------------ 6 files changed, 84 insertions(+), 69 deletions(-) rename compilers/openapi/internal/resolve/{locate_internal_test.go => locate_test.go} (62%) rename engine/reader/{disk_resolve_internal_test.go => disk_resolve_test.go} (86%) diff --git a/compilers/openapi/internal/resolve/export_test.go b/compilers/openapi/internal/resolve/export_test.go index 40d543e9..3f3d6efa 100644 --- a/compilers/openapi/internal/resolve/export_test.go +++ b/compilers/openapi/internal/resolve/export_test.go @@ -1,6 +1,11 @@ package resolve -import "encoding/json/jsontext" +import ( + "encoding/json/jsontext" + + "github.com/speakeasy-api/openapi/jsonpointer" + oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" +) // MaxRefChain exposes the package's hop bound to its external tests. // @@ -19,3 +24,13 @@ func Found[S any](obj *S) any { return found(obj) } // At exposes atPointer, the scope reached reads a position of the source's // model in, to this package's external tests. func At(s Scope, pointer jsontext.Pointer) Scope { return atPointer(s, pointer) } + +// WholeModelAt reads the whole pointer in one library call, as the library's +// resolver reads it: the schema found there, or nil. +func WholeModelAt(doc any, pointer jsontext.Pointer) *oas3.JSONSchema[oas3.Referenceable] { + target, err := jsonpointer.GetTarget(doc, jsonpointer.JSONPointer(pointer), jsonpointer.WithStructTags("key")) + if js, ok := target.(*oas3.JSONSchema[oas3.Referenceable]); ok && err == nil && js != nil { + return js + } + return nil +} diff --git a/compilers/openapi/internal/resolve/locate_internal_test.go b/compilers/openapi/internal/resolve/locate_test.go similarity index 62% rename from compilers/openapi/internal/resolve/locate_internal_test.go rename to compilers/openapi/internal/resolve/locate_test.go index f5838d2a..2ee97388 100644 --- a/compilers/openapi/internal/resolve/locate_internal_test.go +++ b/compilers/openapi/internal/resolve/locate_test.go @@ -1,12 +1,10 @@ -package resolve +package resolve_test import ( "encoding/json/jsontext" - "errors" "strings" "testing" - "github.com/speakeasy-api/openapi/jsonpointer" oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" @@ -14,6 +12,7 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/navigation" + "github.com/dexpace/morphic/compilers/openapi/internal/resolve" ) // rawMappingDoc is a document whose pointers leave the model for raw YAML: @@ -28,16 +27,6 @@ x-lib: a: {b: {type: object}} ` -// wholeModelAt reads the whole pointer in one library call, as the library's -// resolver reads it: the schema found there, or nil. -func wholeModelAt(doc any, pointer jsontext.Pointer) *oas3.JSONSchema[oas3.Referenceable] { - target, err := jsonpointer.GetTarget(doc, jsonpointer.JSONPointer(pointer), jsonpointer.WithStructTags("key")) - if js, ok := target.(*oas3.JSONSchema[oas3.Referenceable]); ok && err == nil && js != nil { - return js - } - return nil -} - // TestScope_ReadsNoRawMappingAPointerPasses pins GitHub #778 for the walk // reached takes: it scans no mapping where a pointer leaves the model, the // object's own, nor any raw mapping below. Each such mapping is given a first @@ -56,10 +45,10 @@ func TestScope_ReadsNoRawMappingAPointerPasses(t *testing.T) { mapping := builtAt(t, doc, at.mapping) mapping.Content = append([]*yaml.Node{nil, {Kind: yaml.ScalarNode}}, mapping.Content...) - assert.Panics(t, func() { wholeModelAt(doc, at.pointer) }, "the whole read scans %q", at.mapping) - sc := Scope{Doc: doc, Ends: ReferenceEnd} + assert.Panics(t, func() { resolve.WholeModelAt(doc, at.pointer) }, "the whole read scans %q", at.mapping) + sc := resolve.Scope{Doc: doc, Ends: resolve.ReferenceEnd} assert.NotPanics(t, func() { - assert.False(t, atPointer(sc, at.pointer).Foreign) + assert.False(t, resolve.At(sc, at.pointer).Foreign) }, "%q", at.pointer) } } @@ -81,25 +70,6 @@ func builtAt(t *testing.T, doc *soa.OpenAPI, pointer jsontext.Pointer) *yaml.Nod } } -// indexedDoc is a document that is no model and that the library reads by -// index as well as by the mapping it was built from: that mapping's 0 holds j, -// and its index 0 holds the schema under k. -type indexedDoc struct { - built *yaml.Node - schema *oas3.JSONSchema[oas3.Referenceable] -} - -// GetRootNode returns the mapping the document was built from. -func (d *indexedDoc) GetRootNode() *yaml.Node { return d.built } - -// NavigateWithIndex answers index 0 with a map holding the schema under k. -func (d *indexedDoc) NavigateWithIndex(i int) (any, error) { - if i != 0 { - return nil, errors.New("no such index") - } - return map[string]any{"k": d.schema}, nil -} - // walkTo returns what the model holds at pointer, a step at a time. func walkTo(doc *soa.OpenAPI, pointer jsontext.Pointer) any { var node any = doc diff --git a/compilers/openapi/internal/resolve/resolve_internal_test.go b/compilers/openapi/internal/resolve/resolve_internal_test.go index 6c5a758d..a1341c6e 100644 --- a/compilers/openapi/internal/resolve/resolve_internal_test.go +++ b/compilers/openapi/internal/resolve/resolve_internal_test.go @@ -2,11 +2,13 @@ package resolve import ( "encoding/json/jsontext" + "errors" "path/filepath" "strings" "testing" "github.com/speakeasy-api/openapi/jsonpointer" + oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -150,7 +152,7 @@ func TestScopeReached_StepsPastAnIndexTheLibraryRetries(t *testing.T) { doc := &indexedDoc{built: built.Content[0], schema: js} for _, ends := range []func(any) (End, bool){nil, elsewhere} { sc := Scope{Doc: doc, Ends: ends} - assert.Same(t, js, wholeModelAt(doc, "/0/k"), "sanity: the index answers k, which the mapping's 0 does not hold") + assert.Same(t, js, WholeModelAt(doc, "/0/k"), "sanity: the index answers k, which the mapping's 0 does not hold") assert.Equal(t, ends != nil, atPointer(sc, "/0/k").Foreign, "the walk noted the document it stepped past") } } @@ -234,3 +236,22 @@ func TestScopeReached_WithoutEndsPassesNothing(t *testing.T) { // elsewhere is an Ends that reads every node it is asked about as a reference // ending in another document, so any node a walk steps past shows. func elsewhere(any) (End, bool) { return End{Document: "elsewhere", Path: "x.yaml"}, true } + +// indexedDoc is a document that is no model and that the library reads by +// index as well as by the mapping it was built from: that mapping's 0 holds j, +// and its index 0 holds the schema under k. +type indexedDoc struct { + built *yaml.Node + schema *oas3.JSONSchema[oas3.Referenceable] +} + +// GetRootNode returns the mapping the document was built from. +func (d *indexedDoc) GetRootNode() *yaml.Node { return d.built } + +// NavigateWithIndex answers index 0 with a map holding the schema under k. +func (d *indexedDoc) NavigateWithIndex(i int) (any, error) { + if i != 0 { + return nil, errors.New("no such index") + } + return map[string]any{"k": d.schema}, nil +} diff --git a/engine/reader/disk_resolve_internal_test.go b/engine/reader/disk_resolve_test.go similarity index 86% rename from engine/reader/disk_resolve_internal_test.go rename to engine/reader/disk_resolve_test.go index 42d5e745..f8f11acb 100644 --- a/engine/reader/disk_resolve_internal_test.go +++ b/engine/reader/disk_resolve_test.go @@ -1,4 +1,4 @@ -package reader +package reader_test import ( "os" @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/engine/reader" ) // TestReadRooted_RefusesALinkSwappedInAfterTheCheck pins what the os.Root open @@ -28,7 +29,7 @@ func TestReadRooted_RefusesALinkSwappedInAfterTheCheck(t *testing.T) { path := filepath.Join(in, "a.yaml") require.NoError(t, os.WriteFile(path, []byte("inside"), 0o644)) - dir, rel, err := resolveWithin(in, path) + dir, rel, err := reader.ResolveWithin(in, path) require.NoError(t, err, "the check places the file inside") require.NoError(t, os.Remove(path)) require.NoError(t, os.Symlink(filepath.Join("..", "secret.yaml"), path)) @@ -36,7 +37,7 @@ func TestReadRooted_RefusesALinkSwappedInAfterTheCheck(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, root.Close()) }) - got, err := readRooted(root, rel, path, 0) + got, err := reader.ReadRooted(root, rel, path, 0) require.Error(t, err) assert.Nil(t, got, "the link's target is never read") @@ -47,12 +48,12 @@ func TestReadRooted_RefusesALinkSwappedInAfterTheCheck(t *testing.T) { // directory cannot be read: the two cannot be put alike, so the file is // outside, as relativeTo answers for the same pair. func TestResolveWithin_RefusesAMixedPairWithNoWorkingDirectory(t *testing.T) { - root := ResolvedDir(t) + root := reader.ResolvedDir(t) t.Chdir(root) abs := filepath.Join(root, "d", "a.yaml") - vanishWorkingDirectory(t) + reader.VanishWorkingDirectory(t) - _, _, err := resolveWithin("d", abs) + _, _, err := reader.ResolveWithin("d", abs) require.ErrorIs(t, err, compilers.ErrOutsideWithin) } diff --git a/engine/reader/export_test.go b/engine/reader/export_test.go index a8e7d6bb..48da61ea 100644 --- a/engine/reader/export_test.go +++ b/engine/reader/export_test.go @@ -1,9 +1,13 @@ package reader import ( + "os" "path/filepath" + "runtime" + "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,6 +22,12 @@ var FilePathOf = filePathOf // ReadOpened is readOpened, for the external tests. var ReadOpened = readOpened +// ReadRooted is readRooted, for the external tests. +var ReadRooted = readRooted + +// ResolveWithin is resolveWithin, for the external tests. +var ResolveWithin = resolveWithin + // ResolvedDir returns a fresh temporary directory with its links resolved, so // a path under it spells the directory the working directory reports where the // system's temporary directory is itself a link. @@ -27,3 +37,26 @@ func ResolvedDir(t *testing.T) string { require.NoError(t, err) return dir } + +// VanishWorkingDirectory leaves the working directory something the system +// cannot name: a path deeper than any buffer getcwd fills, whose parent +// refuses a read, so the walk up that os.Getwd falls back to fails. Deleting +// the directory would not do: darwin names a deleted one. It skips where +// permissions do not bind, or the system names the directory regardless. +func VanishWorkingDirectory(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permissions do not deny a read here") + } + segment := strings.Repeat("a", 200) + const levels = 25 // 5000 bytes, past Linux's 4096 and darwin's 1024 + for range levels { + require.NoError(t, os.Mkdir(segment, 0o700)) + t.Chdir(segment) + } + require.NoError(t, os.Chmod("..", 0o100)) + t.Cleanup(func() { assert.NoError(t, os.Chmod("..", 0o700)) }) + if _, err := os.Getwd(); err == nil { + t.Skip("this system names the working directory whatever its parents allow") + } +} diff --git a/engine/reader/location_internal_test.go b/engine/reader/location_internal_test.go index e5f0b810..7ee17db3 100644 --- a/engine/reader/location_internal_test.go +++ b/engine/reader/location_internal_test.go @@ -4,9 +4,7 @@ import ( "errors" "fmt" "math" - "os" "path/filepath" - "runtime" "strings" "testing" "testing/iotest" @@ -171,7 +169,7 @@ func TestRelativeTo_ReadsTheWorkingDirectoryOnlyForAMixedPair(t *testing.T) { _, ok = relativeTo("d", filepath.Join(root, "e", "a.yaml")) assert.False(t, ok, "an absolute path outside the relative directory") - vanishWorkingDirectory(t) + VanishWorkingDirectory(t) rel, ok = relativeTo("d", filepath.Join("d", "a.yaml")) assert.True(t, ok, "two relative paths need no working directory") @@ -184,29 +182,6 @@ func TestRelativeTo_ReadsTheWorkingDirectoryOnlyForAMixedPair(t *testing.T) { assert.False(t, ok, "an absolute directory against a relative path, no working directory") } -// vanishWorkingDirectory leaves the working directory something the system -// cannot name: a path deeper than any buffer getcwd fills, whose parent -// refuses a read, so the walk up that os.Getwd falls back to fails. Deleting -// the directory would not do: darwin names a deleted one. It skips where -// permissions do not bind, or the system names the directory regardless. -func vanishWorkingDirectory(t *testing.T) { - t.Helper() - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("directory permissions do not deny a read here") - } - segment := strings.Repeat("a", 200) - const levels = 25 // 5000 bytes, past Linux's 4096 and darwin's 1024 - for range levels { - require.NoError(t, os.Mkdir(segment, 0o700)) - t.Chdir(segment) - } - require.NoError(t, os.Chmod("..", 0o100)) - t.Cleanup(func() { assert.NoError(t, os.Chmod("..", 0o700)) }) - if _, err := os.Getwd(); err == nil { - t.Skip("this system names the working directory whatever its parents allow") - } -} - // TestReadLimited_HoldsTheLimit pins the size a document may have: exactly // the limit passes, one byte past fails with ErrDocumentTooLarge naming the // limit, a limit of zero or less reads any size, and math.MaxInt, where the From 7e484e56fafa182d8eceb44aed8f3b84c3eedd52 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 05:54:26 +0300 Subject: [PATCH 13/18] docs: state where in-package tests stop and helpers are shared The rules under "Where a test goes" now say what the tree does: - a package's tests stay inside it when moving them out would take more than ten aliases, and a cmd program's tests are all inside it, under plain names, since a main package cannot be imported; - a function a table runs as a case's body leaves t.Helper out, as the canaries do as well as the conformance assertions; - a helper is written once, sitting in one of the test files that share it or, when new, in the package's helpers file, with openapitest and irtest holding what several packages share. A helper neither can hold is written in each package that needs it and says why, as compileNullable's copies now do too. --- CONTRIBUTING.md | 25 +++++++++++-------- .../openapi/e2e/nullable_widening_test.go | 3 ++- .../openapi/internal/schema/nulls_test.go | 3 ++- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9525761b..405b6f17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,23 +49,28 @@ Read the documents for your layer, and skip the rest until you need them. package's internals through the package's `export_test.go`, which names each one once, under its exported spelling or, where that is taken, a name that says what it adds, as `irtest.CompareGoldenOn` does. A test that needs an unexported field or method, a generic, or a - variable it assigns stays inside the package, in `x_internal_test.go` beside `x_test.go`, and so - do the tests of a package that reach so many internals the aliases would be a second API, as - `schema`'s and `operation`'s do. A fixture both sides use is declared once, exported, in - `export_test.go`, as `schema.Lowerer` is. + variable it assigns stays inside the package, in `x_internal_test.go`, and so do a package's + tests when moving them out would take more than ten aliases, enough to make a second API, as + `schema`'s and `operation`'s would. A fixture both sides use is declared once, exported, in + `export_test.go`, as `schema.Lowerer` is. A `cmd` program's tests are all inside it, under plain + names, since a `main` package cannot be imported. *Fails if missed:* nothing. A test written inside the package that could be external compiles and runs. - **A test is named for what it tests.** A `TestFunc_Scenario` name is a function's or a type's, and the file follows from it as above. A test that pins a construct or a lowering rule may take the construct's name instead, as the `TestPathItemRefSiblings` tests do, and then sits in the file of the construct or of the entry point it runs through. A function that asserts on a test's behalf - calls `t.Helper`, except a conformance case's assertion, which is the case's own body and so - reports the line that fails. + calls `t.Helper`, except one a table runs as a case's body, as the conformance corpus's assertions + and the canaries are: it is the case, so its failure reports the line in it that fails. *Fails if missed:* nothing checks a test's name or its `t.Helper`. -- **A helper several test files share has one home:** its package's `helpers_test.go`, or - `helpers_internal_test.go` inside the package; and a helper several packages share is in - `compilers/openapi/internal/openapitest` or `ir/irtest`, as `openapitest.SchemaFromYAML` and - `irtest.RepoRoot` are, when all of them can import it. +- **A helper is written once.** One that several test files of a package share sits in one of + them or, when it is new, in the package's `helpers_test.go`, or `helpers_internal_test.go` inside + the package. One that several packages share is in `compilers/openapi/internal/openapitest` or + `ir/irtest`, as `openapitest.SchemaFromYAML` and `irtest.RepoRoot` are, when it builds or reads + what those packages serve, a spec and its diagnostics or the IR, and every package needing it + can import it. A helper neither can hold is written in each package that needs it, and says why, + as `parseFull`'s copies do; so is a general one, such as a file write, between packages that + share no test-helper package. *Fails if missed:* nothing. A copy compiles beside the original. - **An end-to-end test of the OpenAPI compiler goes in `compilers/openapi/e2e`,** in a file named for the construct or the property it checks: a test of what the compiler does as a whole, which no diff --git a/compilers/openapi/e2e/nullable_widening_test.go b/compilers/openapi/e2e/nullable_widening_test.go index 6d6ab372..8d7aaac3 100644 --- a/compilers/openapi/e2e/nullable_widening_test.go +++ b/compilers/openapi/e2e/nullable_widening_test.go @@ -165,7 +165,8 @@ func TestSchemaRefSiblings_LowerAsIn31(t *testing.T) { } // compileNullable compiles src, a whole document, with the nullable-widening -// policy on or off. +// policy on or off. That reach through openapi is why openapitest cannot hold +// it — see that package's doc comment. func compileNullable(t *testing.T, src string, off bool) (*ir.Document, []ir.Diagnostic) { t.Helper() opts := compilers.Options{FormatOptions: openapi.Options{NullableWidening: openapi.NullableWidening{Disabled: off}}} diff --git a/compilers/openapi/internal/schema/nulls_test.go b/compilers/openapi/internal/schema/nulls_test.go index 37d39211..e07099ae 100644 --- a/compilers/openapi/internal/schema/nulls_test.go +++ b/compilers/openapi/internal/schema/nulls_test.go @@ -27,7 +27,8 @@ func inferred(target ir.TypeID) ir.TypeRef { func prim(k ir.PrimKind) ir.TypeID { return ir.PrimTypeID(k) } // compileNullable compiles src, a whole document, with the nullable-widening -// policy on or off. +// policy on or off. That reach through openapi is why openapitest cannot hold +// it — see that package's doc comment. func compileNullable(t *testing.T, src string, off bool) (*ir.Document, []ir.Diagnostic) { t.Helper() opts := compilers.Options{FormatOptions: openapi.Options{NullableWidening: openapi.NullableWidening{Disabled: off}}} From 02ae058d028f3fe0d52967be99648a2756c83484 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 14:10:44 +0300 Subject: [PATCH 14/18] test: make the shared helpers' tests catch what they claim Planting a fault in each shared helper this change adds found three that its tests let through: - DiagCodes' test listed b, a, b, which reads the same reversed, so it could not tell report order from its reverse; it now lists c, a, b, a; - SchemaFromYAML's check that the library reports no validation error could be deleted unnoticed, since no fixture parses and then fails to validate; a required written as a scalar now does; - encoded and rootFrom could stop marking themselves helpers unnoticed; their failure tests now count the Helper calls, as compareGolden's test already does. --- compilers/openapi/internal/openapitest/spec_test.go | 10 ++++++++++ ir/irtest/diagnostics_test.go | 4 ++-- ir/irtest/encode_test.go | 1 + ir/irtest/repo_test.go | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/compilers/openapi/internal/openapitest/spec_test.go b/compilers/openapi/internal/openapitest/spec_test.go index bbc9c5e6..e084333f 100644 --- a/compilers/openapi/internal/openapitest/spec_test.go +++ b/compilers/openapi/internal/openapitest/spec_test.go @@ -129,6 +129,16 @@ func TestSchemaFromYAML_FailsOnAFixtureThatDoesNotParse(t *testing.T) { assert.True(t, rec.failed(), "an unreadable fixture is reported") } +// TestSchemaFromYAML_FailsOnAFixtureThatDoesNotValidate pins the second check: +// a fixture that parses but that the library reports invalid, here a required +// written as a scalar, fails the test rather than lowering a half-read schema. +func TestSchemaFromYAML_FailsOnAFixtureThatDoesNotValidate(t *testing.T) { + t.Parallel() + rec := &recorder{} + openapitest.SchemaFromYAML(t.Context(), rec, "required: x") + assert.True(t, rec.failed(), "a fixture the library refuses is reported") +} + // TestYAMLNode_ReturnsTheRootValueNode checks the document node is unwrapped, // which is what makes the result comparable to what a parsed schema field // exposes. diff --git a/ir/irtest/diagnostics_test.go b/ir/irtest/diagnostics_test.go index afdcd32c..8c085a42 100644 --- a/ir/irtest/diagnostics_test.go +++ b/ir/irtest/diagnostics_test.go @@ -13,7 +13,7 @@ import ( // order is the report's, not a sorted one. func TestDiagCodes_ListsEachCodeInReportOrder(t *testing.T) { t.Parallel() - diags := []ir.Diagnostic{{Code: "b"}, {Code: "a"}, {Code: "b"}} - assert.Equal(t, []string{"b", "a", "b"}, irtest.DiagCodes(diags)) + diags := []ir.Diagnostic{{Code: "c"}, {Code: "a"}, {Code: "b"}, {Code: "a"}} + assert.Equal(t, []string{"c", "a", "b", "a"}, irtest.DiagCodes(diags)) assert.Empty(t, irtest.DiagCodes(nil)) } diff --git a/ir/irtest/encode_test.go b/ir/irtest/encode_test.go index db2ae667..de9913dc 100644 --- a/ir/irtest/encode_test.go +++ b/ir/irtest/encode_test.go @@ -35,6 +35,7 @@ func TestEncoded_FailsTheTestOnADocumentThatDoesNotEncode(t *testing.T) { <-done assert.True(t, rec.aborted) assert.Contains(t, rec.fatalf, "encode the document") + assert.GreaterOrEqual(t, rec.helperCalls, 1, "encoded marks itself a helper") } // TestRefusedAsCyclic_ReadsOnlyTheCycleRefusal pins both halves: a value that diff --git a/ir/irtest/repo_test.go b/ir/irtest/repo_test.go index ac0ce962..300558e7 100644 --- a/ir/irtest/repo_test.go +++ b/ir/irtest/repo_test.go @@ -47,6 +47,7 @@ func TestRootFrom_RefusesAWorkingDirectoryItCannotRead(t *testing.T) { assert.True(t, rec.aborted) assert.Contains(t, rec.fatalf, "reading the working directory: gone") assert.Empty(t, got) + assert.GreaterOrEqual(t, rec.helperCalls, 1, "rootFrom marks itself a helper") } // TestRootFrom_RefusesADirectoryOutsideAnyModule pins the second: a walk that @@ -58,4 +59,5 @@ func TestRootFrom_RefusesADirectoryOutsideAnyModule(t *testing.T) { assert.True(t, rec.aborted) assert.Contains(t, rec.fatalf, "no go.mod in or above the working directory") assert.Empty(t, got) + assert.GreaterOrEqual(t, rec.helperCalls, 1, "rootFrom marks itself a helper") } From 096eae3a92050e48d6d60a48f65dc5df8608f92b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 14:13:35 +0300 Subject: [PATCH 15/18] test: share the helpers copied under other local names Comparing helpers' bodies up to the names of their parameters and locals, not only as written, finds copies the exact comparison missed: - annotation's parsedMapping, bind's tree and oas's parse each wrote openapitest.YAMLDocument again, and now call it; - e2e's and schema's preservationClaims become openapitest.PreservationClaims; - canary's releaseAnchorNames and sourceindex's clearAnchors, the load phase's anchor clearing restated for tests, become openapitest.ClearAnchors; - irverify's bigVal, a fourth helper taking a BigVal's address, becomes new(ir.BigVal(v)) as the other three did. Each package's tests and subtests are unchanged except for the tests of the two new helpers, and every package keeps full statement coverage. --- compilers/openapi/e2e/unpreservable_test.go | 20 ++------------ .../annotation/unknown_internal_test.go | 25 +++++++---------- compilers/openapi/internal/bind/table_test.go | 13 +++------ .../internal/bind/table_through_test.go | 3 ++- .../internal/canary/validation_oracle_test.go | 15 ++--------- .../internal/oas/engine_internal_test.go | 22 +++++---------- .../openapi/internal/openapitest/result.go | 15 +++++++++++ .../internal/openapitest/result_test.go | 14 ++++++++++ .../openapi/internal/openapitest/spec.go | 12 +++++++++ .../openapi/internal/openapitest/spec_test.go | 27 +++++++++++++++++++ .../internal/schema/allof_residue_test.go | 2 +- .../openapi/internal/schema/census_test.go | 2 +- .../openapi/internal/schema/schema_test.go | 16 +---------- .../internal/schema/union_combinators_test.go | 2 +- .../openapi/internal/schema/union_test.go | 2 +- .../internal/sourceindex/sourceindex_test.go | 13 +-------- ir/irverify/bigval_test.go | 17 ++++-------- 17 files changed, 104 insertions(+), 116 deletions(-) diff --git a/compilers/openapi/e2e/unpreservable_test.go b/compilers/openapi/e2e/unpreservable_test.go index 156035bb..84e4cfd3 100644 --- a/compilers/openapi/e2e/unpreservable_test.go +++ b/compilers/openapi/e2e/unpreservable_test.go @@ -2,7 +2,6 @@ package e2e_test import ( "encoding/json/jsontext" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -74,7 +73,7 @@ func TestUnpreservable_AnnouncementNeverOutrunsTheEntry(t *testing.T) { assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnpreservableConstruct, tc.at), "the case must reach the site it is named for: %+v", diags) - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "nothing was written under Unmodeled, so nothing may announce that it was") }) } @@ -126,7 +125,7 @@ func TestUnpreservable_SchemaKeywordSites(t *testing.T) { _, diags := parseFull(t, openapitest.ComponentSpec(" S:\n"+tc.body)) assert.True(t, openapitest.HasDiagCodeAt(diags, diag.UnpreservableConstruct, tc.at), "the case must reach the site it is named for: %+v", diags) - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "nothing was written under Unmodeled, so nothing may announce that it was") }) } @@ -181,21 +180,6 @@ func TestUnpreservable_CompositeFailsWhole(t *testing.T) { } } -// preservationClaims returns every diagnostic message asserting that something -// was kept under Unmodeled. Matching on the claim rather than on a code is -// deliberate: the claim is what the entry has to back, and it is spread across -// several codes and phrasings. -func preservationClaims(diags []ir.Diagnostic) []string { - var out []string - for _, d := range diags { - if strings.Contains(d.Message, "under Unmodeled") && - d.Code != diag.UnpreservableConstruct { - out = append(out, d.Message) - } - } - return out -} - // typeUnmodeled returns the Unmodeled map of one interned type. func typeUnmodeled(t *testing.T, doc *ir.Document, id ir.TypeID) ir.Unmodeled { t.Helper() diff --git a/compilers/openapi/internal/annotation/unknown_internal_test.go b/compilers/openapi/internal/annotation/unknown_internal_test.go index d138c8bb..fedd7827 100644 --- a/compilers/openapi/internal/annotation/unknown_internal_test.go +++ b/compilers/openapi/internal/annotation/unknown_internal_test.go @@ -14,6 +14,7 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" ) @@ -138,7 +139,7 @@ func TestUnknownKeywordsIn_UnpreservableValueIsReportedNotKept(t *testing.T) { func TestUnknownKeysUnder_KeysBeneathTheScopeAndSorted(t *testing.T) { t.Parallel() reported := []string{"zeta", "alpha"} - obj := fakeObject{core: &fakeCore{keys: reported}, root: parsedMapping(t, "zeta: 1\nalpha: 2\n")} + obj := fakeObject{core: &fakeCore{keys: reported}, root: openapitest.YAMLDocument(t, "zeta: 1\nalpha: 2\n")} var got ir.Unmodeled diags := UnknownKeysUnder(&got, obj, sourced(1), "/info/contact", "info/contact") @@ -162,7 +163,7 @@ func TestUnknownKeysUnder_KeysBeneathTheScopeAndSorted(t *testing.T) { // and the diagnostic announcing it. func TestUnknownKeysIn_AttributesEachKeyAtItsOwnPointer(t *testing.T) { t.Parallel() - obj := fakeObject{core: &fakeCore{keys: []string{"alpha", "zeta"}}, root: parsedMapping(t, "alpha: 1\nzeta: 2\n")} + obj := fakeObject{core: &fakeCore{keys: []string{"alpha", "zeta"}}, root: openapitest.YAMLDocument(t, "alpha: 1\nzeta: 2\n")} var got ir.Unmodeled diags := UnknownKeysIn(&got, obj, overlaid("/x/zeta"), "/x") @@ -195,7 +196,7 @@ func TestUnknownKeysIn_BudgetBoundsWhatOneObjectContributes(t *testing.T) { keys = append(keys, key) body.WriteString(key + ": " + strconv.Itoa(i) + "\n") } - obj := fakeObject{core: &fakeCore{keys: keys}, root: parsedMapping(t, body.String())} + obj := fakeObject{core: &fakeCore{keys: keys}, root: openapitest.YAMLDocument(t, body.String())} var got ir.Unmodeled diags := UnknownKeysIn(&got, obj, sourced(0), "/x") @@ -219,7 +220,7 @@ func TestUnknownKeysIn_BudgetBoundsWhatOneObjectContributes(t *testing.T) { // would be the silent loss this census exists to end (GitHub #395). func TestUnknownKeysIn_KeyWithNoSourceNodeIsReported(t *testing.T) { t.Parallel() - obj := fakeObject{core: &fakeCore{keys: []string{"absent"}}, root: parsedMapping(t, "present: 1\n")} + obj := fakeObject{core: &fakeCore{keys: []string{"absent"}}, root: openapitest.YAMLDocument(t, "present: 1\n")} var got ir.Unmodeled diags := UnknownKeysIn(&got, obj, sourced(2), "/x") @@ -240,11 +241,11 @@ func TestUnknownKeysUnder_KeyHoldingASeparatorIsItsOwnEntry(t *testing.T) { t.Parallel() root := fakeObject{ core: &fakeCore{keys: []string{"info/contact/slack"}}, - root: parsedMapping(t, "info/contact/slack: fromRoot\n"), + root: openapitest.YAMLDocument(t, "info/contact/slack: fromRoot\n"), } contact := fakeObject{ core: &fakeCore{keys: []string{"slack"}}, - root: parsedMapping(t, "slack: fromContact\n"), + root: openapitest.YAMLDocument(t, "slack: fromContact\n"), } var got ir.Unmodeled @@ -269,7 +270,7 @@ func TestUnknownKeysUnder_KeyHoldingASeparatorIsItsOwnEntry(t *testing.T) { // (GitHub #396). func TestUnknownKeysIn_EntryHeldByAnotherConstructIsReported(t *testing.T) { t.Parallel() - obj := fakeObject{core: &fakeCore{keys: []string{"same", "other"}}, root: parsedMapping(t, "same: 1\nother: 2\n")} + obj := fakeObject{core: &fakeCore{keys: []string{"same", "other"}}, root: openapitest.YAMLDocument(t, "same: 1\nother: 2\n")} got := ir.Unmodeled{ "openapi:same": {Reason: ir.ReasonValidationOnly, Value: ir.RawValue("9"), Provenance: ir.Provenance{Pointer: "/x/same"}}, @@ -322,7 +323,7 @@ func TestUnknownKeysIn_ModelWithNoCensusRecordsNothing(t *testing.T) { // not define and this reader grades them exactly as the modelled path does. func TestUnknownKeysNamed_DelegatesTheSameGrading(t *testing.T) { t.Parallel() - root := parsedMapping(t, "get: {}\nnotAKey: 3\n") + root := openapitest.YAMLDocument(t, "get: {}\nnotAKey: 3\n") var got ir.Unmodeled diags := UnknownKeysNamed(&got, []string{"notAKey"}, root, sourced(0), "/paths/~1p", "") @@ -358,14 +359,6 @@ type fakeCore struct{ keys []string } func (c *fakeCore) GetUnknownProperties() []string { return c.keys } -// parsedMapping parses body into the mapping node the census reads values from. -func parsedMapping(t *testing.T, body string) *yaml.Node { - t.Helper() - var doc yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(body), &doc)) - return &doc -} - // TestTakeString_UnkeepableValueIsReportedAndRead pins the one value TakeString // can neither place nor keep: a mapping that writes one key twice, which no JSON // rendering can hold. It is reported as lost, and still read, so the census does diff --git a/compilers/openapi/internal/bind/table_test.go b/compilers/openapi/internal/bind/table_test.go index 68f6f33a..237d8801 100644 --- a/compilers/openapi/internal/bind/table_test.go +++ b/compilers/openapi/internal/bind/table_test.go @@ -12,18 +12,11 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/bind" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) const head = "openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\n" -// tree decodes src. -func tree(t *testing.T, src string) *yaml.Node { - t.Helper() - var n yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(src), &n)) - return &n -} - // self names the source "spec.yaml". var self = bind.Config{Self: "spec.yaml"} @@ -42,7 +35,7 @@ var resolving = bind.Config{Self: "/spec.yaml", Locate: func(uri, base string) ( // bound binds src under cfg. func bound(t *testing.T, src string, cfg bind.Config) *bind.Table { t.Helper() - return bind.Bind(tree(t, src), cfg) + return bind.Bind(openapitest.YAMLDocument(t, src), cfg) } // entryAt returns the entry the table holds at the source pointer site. @@ -506,7 +499,7 @@ func TestBind_RefuseJudgesEachReferenceByItsSpelling(t *testing.T) { func TestIsEmpty_HoldsNoNode(t *testing.T) { t.Parallel() for src, want := range map[string]bool{"": true, "\n": true, "# a comment\n": true, "---\n": false, "~\n": false, "{}\n": false} { - assert.Equal(t, want, bind.IsEmpty(tree(t, src)), "%q", src) + assert.Equal(t, want, bind.IsEmpty(openapitest.YAMLDocument(t, src)), "%q", src) } assert.True(t, bind.IsEmpty(nil), "no tree") } diff --git a/compilers/openapi/internal/bind/table_through_test.go b/compilers/openapi/internal/bind/table_through_test.go index 8219620f..76542cce 100644 --- a/compilers/openapi/internal/bind/table_through_test.go +++ b/compilers/openapi/internal/bind/table_through_test.go @@ -11,6 +11,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/bind" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) // throughSpec writes a schema $ref whose pointer passes through the response @@ -296,7 +297,7 @@ x-params: q: {name: q, in: query, schema: {$ref: '#/components/schemas/Int'}} r: {name: r, in: query} ` - src := tree(t, spec) + src := openapitest.YAMLDocument(t, spec) view := nodeview.New() nodeAt := func(pointer jsontext.Pointer) *yaml.Node { path, ok := view.DocumentPath(nodeview.DocumentRoot(src), pointer) diff --git a/compilers/openapi/internal/canary/validation_oracle_test.go b/compilers/openapi/internal/canary/validation_oracle_test.go index 653c5f72..bac3e438 100644 --- a/compilers/openapi/internal/canary/validation_oracle_test.go +++ b/compilers/openapi/internal/canary/validation_oracle_test.go @@ -32,6 +32,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/load" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" "github.com/dexpace/morphic/compilers/openapi/internal/oas" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" ) @@ -321,7 +322,7 @@ func libraryFindings(t *testing.T, data []byte, root *yaml.Node) (out []finding, out, err = nil, fmt.Errorf("the library panicked: %v", r) } }() - releaseAnchorNames(root) + openapitest.ClearAnchors(root) var model soa.OpenAPI model.InitCache() model.GetCore().SetConfig(yml.GetConfigFromDoc(data, root)) @@ -359,18 +360,6 @@ func findingAt(rule string, n *yaml.Node) finding { return finding{class: rule, line: n.Line, column: n.Column} } -// releaseAnchorNames clears the anchor names of the tree, as the load phase -// does before it hands the tree to the library. -func releaseAnchorNames(root *yaml.Node) { - stack := []*yaml.Node{root} - for len(stack) > 0 { - n := stack[len(stack)-1] - stack = stack[:len(stack)-1] - n.Anchor = "" - stack = append(stack, n.Content...) - } -} - // onlyOneSide returns a row for each finding of one side that the other lacks, // compared as multisets: a finding the library makes twice and the table once // leaves one row. diff --git a/compilers/openapi/internal/oas/engine_internal_test.go b/compilers/openapi/internal/oas/engine_internal_test.go index 4f43e050..cbd8b336 100644 --- a/compilers/openapi/internal/oas/engine_internal_test.go +++ b/compilers/openapi/internal/oas/engine_internal_test.go @@ -7,20 +7,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" ) -// parse reads doc into a node. -func parse(t *testing.T, doc string) *yaml.Node { - t.Helper() - var n yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(doc), &n)) - return &n -} - // TestCheck_AnUnknownCodeRuleIsATableError pins that a Code row the engine // does not implement is reported, never skipped. func TestCheck_AnUnknownCodeRuleIsATableError(t *testing.T) { @@ -28,7 +20,7 @@ func TestCheck_AnUnknownCodeRuleIsATableError(t *testing.T) { obj := spec.must(Info) obj.Rules = append(obj.Rules, code(Always(), "no-such-predicate", "", Cite{Minor: V30, Section: "Info Object", Sentence: "x"})) spec.objects[spec.index[Info]] = obj - diags := Checker{spec: spec}.Check(parse(t, `{title: t, version: v}`), "3.1.0", Options{Start: Info}) + diags := Checker{spec: spec}.Check(openapitest.YAMLDocument(t, `{title: t, version: v}`), "3.1.0", Options{Start: Info}) require.Len(t, diags, 1) assert.Equal(t, diag.InternalInvariant, diags[0].Code) assert.Contains(t, diags[0].Message, "no-such-predicate") @@ -51,7 +43,7 @@ func TestCheck_WorkIsBounded(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - diags := NewChecker().Check(parse(t, tc.doc), "3.1.0", Options{Start: Schema, Budget: tc.budget}) + diags := NewChecker().Check(openapitest.YAMLDocument(t, tc.doc), "3.1.0", Options{Start: Schema, Budget: tc.budget}) require.Len(t, diags, 1) assert.Equal(t, diag.BudgetExceeded, diags[0].Code) assert.Equal(t, ir.SeverityError, diags[0].Severity) @@ -62,7 +54,7 @@ func TestCheck_WorkIsBounded(t *testing.T) { // TestCheck_DefaultsFillTheUnsetOptions pins the options a caller may leave out. func TestCheck_DefaultsFillTheUnsetOptions(t *testing.T) { c := NewChecker() - doc := parse(t, "openapi: 3.1.0\ninfo: {title: t}\n") + doc := openapitest.YAMLDocument(t, "openapi: 3.1.0\ninfo: {title: t}\n") got := c.Check(doc, "3.1.0", Options{}) require.NotEmpty(t, got, "the document is read from its root") for _, d := range got { @@ -91,8 +83,8 @@ components: A: &a {type: object} B: *a ` - assert.Empty(t, NewChecker().Check(parse(t, doc), "3.1.0", Options{})) - merged := parse(t, "{<<: {version: v}, title: t, license: {}}") + assert.Empty(t, NewChecker().Check(openapitest.YAMLDocument(t, doc), "3.1.0", Options{})) + merged := openapitest.YAMLDocument(t, "{<<: {version: v}, title: t, license: {}}") diags := NewChecker().Check(merged, "3.1.0", Options{Start: Info}) require.Len(t, diags, 1, "the merged version is read, the license is not named") assert.Equal(t, diag.Validation+"/validation-required-field", diags[0].Code) @@ -100,7 +92,7 @@ components: // TestCheck_IsDeterministic pins that one document draws one report. func TestCheck_IsDeterministic(t *testing.T) { - doc := parse(t, `{title: [a], version: 1, license: {identifier: i, url: u}, contact: {email: x}}`) + doc := openapitest.YAMLDocument(t, `{title: [a], version: 1, license: {identifier: i, url: u}, contact: {email: x}}`) first := NewChecker().Check(doc, "3.1.0", Options{Start: Info}) again := NewChecker().Check(doc, "3.1.0", Options{Start: Info}) assert.NotEmpty(t, first) diff --git a/compilers/openapi/internal/openapitest/result.go b/compilers/openapi/internal/openapitest/result.go index ea07573f..baa5f9d4 100644 --- a/compilers/openapi/internal/openapitest/result.go +++ b/compilers/openapi/internal/openapitest/result.go @@ -3,6 +3,7 @@ package openapitest import ( "encoding/json/jsontext" "slices" + "strings" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -150,6 +151,20 @@ func PointersOf(diags []ir.Diagnostic, code string) []jsontext.Pointer { return out } +// PreservationClaims returns the messages announcing that something was kept +// under Unmodeled. The unpreservable report is excluded by code rather than by +// wording: it says a construct "could not be kept verbatim under Unmodeled", +// which contains the phrase and is the opposite of a claim. +func PreservationClaims(diags []ir.Diagnostic) []string { + var out []string + for _, d := range diags { + if strings.Contains(d.Message, "under Unmodeled") && d.Code != diag.UnpreservableConstruct { + out = append(out, d.Message) + } + } + return out +} + // DiagMessageAt returns the message of the single diagnostic matching code, // severity and provenance pointer. Tests that only compare a diagnostic's code // cannot tell two lowerings apart when both report the same code with different diff --git a/compilers/openapi/internal/openapitest/result_test.go b/compilers/openapi/internal/openapitest/result_test.go index 08707cf0..63c39464 100644 --- a/compilers/openapi/internal/openapitest/result_test.go +++ b/compilers/openapi/internal/openapitest/result_test.go @@ -215,6 +215,20 @@ func TestPointersOf_SortsThePointersOfOneCode(t *testing.T) { assert.Empty(t, openapitest.PointersOf(diags, "c")) } +// TestPreservationClaims_ExcludesTheUnpreservableReport pins both halves: a +// message claiming a keep is listed whatever its code, and the report that a +// construct could not be kept is not, though its wording holds the phrase. +func TestPreservationClaims_ExcludesTheUnpreservableReport(t *testing.T) { + t.Parallel() + diags := []ir.Diagnostic{ + {Code: "a", Message: "x kept under Unmodeled"}, + {Code: diag.UnpreservableConstruct, Message: "y could not be kept verbatim under Unmodeled"}, + {Code: "b", Message: "z reported"}, + {Code: "c", Message: "w kept under Unmodeled"}, + } + assert.Equal(t, []string{"x kept under Unmodeled", "w kept under Unmodeled"}, openapitest.PreservationClaims(diags)) +} + // TestDiagMessageAt_ReturnsTheSingleMatchingMessage pins that the message is // reachable by code, severity and pointer together. func TestDiagMessageAt_ReturnsTheSingleMatchingMessage(t *testing.T) { diff --git a/compilers/openapi/internal/openapitest/spec.go b/compilers/openapi/internal/openapitest/spec.go index 8162602c..dc0064c5 100644 --- a/compilers/openapi/internal/openapitest/spec.go +++ b/compilers/openapi/internal/openapitest/spec.go @@ -148,6 +148,18 @@ func YAMLDocument(t TB, src string) *yaml.Node { return &root } +// ClearAnchors empties the anchor name of every node under root, as the load +// phase does before it hands a tree to the parser library. +func ClearAnchors(root *yaml.Node) { + stack := []*yaml.Node{root} + for len(stack) > 0 { + n := stack[len(stack)-1] + stack = stack[:len(stack)-1] + n.Anchor = "" + stack = append(stack, n.Content...) + } +} + // SchemaFromYAML unmarshals body as a JSONSchema, keeping the reference and // boolean forms a bare *oas3.Schema cannot represent. func SchemaFromYAML(ctx context.Context, t TB, body string) *oas3.JSONSchema[oas3.Referenceable] { diff --git a/compilers/openapi/internal/openapitest/spec_test.go b/compilers/openapi/internal/openapitest/spec_test.go index e084333f..d557784c 100644 --- a/compilers/openapi/internal/openapitest/spec_test.go +++ b/compilers/openapi/internal/openapitest/spec_test.go @@ -167,6 +167,33 @@ func TestYAMLDocument_ReturnsTheDocumentNode(t *testing.T) { assert.True(t, rec.failed(), "a fixture that does not parse is reported") } +// TestClearAnchors_EmptiesEveryAnchorBeneathTheRoot pins the reach: an anchor +// nested under another is cleared too, and an alias still names its target. +func TestClearAnchors_EmptiesEveryAnchorBeneathTheRoot(t *testing.T) { + t.Parallel() + doc := openapitest.YAMLDocument(t, "a: &x {b: &y 1}\nc: *x\nd: *y\n") + anchors := func() []string { + var out []string + stack := []*yaml.Node{doc} + for len(stack) > 0 { + n := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if n.Anchor != "" { + out = append(out, n.Anchor) + } + stack = append(stack, n.Content...) + } + return out + } + require.ElementsMatch(t, []string{"x", "y"}, anchors(), "the fixture writes two anchors") + + openapitest.ClearAnchors(doc) + assert.Empty(t, anchors()) + alias := doc.Content[0].Content[3] + require.Equal(t, yaml.AliasNode, alias.Kind) + assert.Same(t, doc.Content[0].Content[1], alias.Alias) +} + // TestScalarNode_BuildsABareScalar covers both builders, StrNode being the // string-tagged case of ScalarNode. func TestScalarNode_BuildsABareScalar(t *testing.T) { diff --git a/compilers/openapi/internal/schema/allof_residue_test.go b/compilers/openapi/internal/schema/allof_residue_test.go index fffbf6c5..f36ac4f8 100644 --- a/compilers/openapi/internal/schema/allof_residue_test.go +++ b/compilers/openapi/internal/schema/allof_residue_test.go @@ -289,6 +289,6 @@ func TestAllOf_UnpreservableBranchIsNotAnnounced(t *testing.T) { require.NotNil(t, td) assert.NotContains(t, td.Common().Unmodeled, "openapi:allOf/0", "the branch did not convert") assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct)) - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "no degradation is announced for a branch that was not kept") } diff --git a/compilers/openapi/internal/schema/census_test.go b/compilers/openapi/internal/schema/census_test.go index 89b5ceae..6bb92eaa 100644 --- a/compilers/openapi/internal/schema/census_test.go +++ b/compilers/openapi/internal/schema/census_test.go @@ -72,7 +72,7 @@ func TestUnhomedApplicator_UnpreservableKeywordIsNotAnnounced(t *testing.T) { require.True(t, ok) assert.NotContains(t, m.Unmodeled, "openapi:items", "the conversion failed, so nothing was kept") assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct), "the failure itself is reported") - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "nothing was written under Unmodeled, so nothing may announce that it was") } diff --git a/compilers/openapi/internal/schema/schema_test.go b/compilers/openapi/internal/schema/schema_test.go index ff0b5e30..e79d057e 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -361,20 +361,6 @@ func compileVocabIR(t *testing.T, schemas string) string { // the announcement tests need. const unpreservableValue = ".nan" -// preservationClaims returns the messages announcing that something was kept -// under Unmodeled. The unpreservable report is excluded by code rather than by -// wording: it says a construct "could not be kept verbatim under Unmodeled", -// which contains the phrase and is the opposite of a claim. -func preservationClaims(diags []ir.Diagnostic) []string { - var out []string - for _, d := range diags { - if strings.Contains(d.Message, "under Unmodeled") && d.Code != diag.UnpreservableConstruct { - out = append(out, d.Message) - } - } - return out -} - // TestCoDeclaredFamily_PassedOverKeywordIsKept covers the families lower()'s // first-match dispatch used to drop (GitHub #35). // @@ -473,7 +459,7 @@ func TestCoDeclaredFamily_UnpreservableIsNotAnnounced(t *testing.T) { require.NotNil(t, td) assert.NotContains(t, td.Common().Unmodeled, "openapi:allOf", "the conversion failed") assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct), "the failure itself is reported") - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "nothing was written under Unmodeled, so nothing may announce that it was") } diff --git a/compilers/openapi/internal/schema/union_combinators_test.go b/compilers/openapi/internal/schema/union_combinators_test.go index bd18dec7..06c66c6c 100644 --- a/compilers/openapi/internal/schema/union_combinators_test.go +++ b/compilers/openapi/internal/schema/union_combinators_test.go @@ -151,7 +151,7 @@ func TestUnionCombinators_UnpreservableIsNotAnnounced(t *testing.T) { require.True(t, ok) assert.NotContains(t, u.Unmodeled, "openapi:anyOf", "the conversion failed, so nothing was kept") assert.True(t, openapitest.HasDiag(diags, diag.UnpreservableConstruct), "the failure itself is reported") - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "nothing was written under Unmodeled, so nothing may announce that it was") } diff --git a/compilers/openapi/internal/schema/union_test.go b/compilers/openapi/internal/schema/union_test.go index a1e73c6f..47b2a7d6 100644 --- a/compilers/openapi/internal/schema/union_test.go +++ b/compilers/openapi/internal/schema/union_test.go @@ -729,7 +729,7 @@ func TestUnionSiblings_UnpreservableIsReportedNotClaimed(t *testing.T) { } assert.Contains(t, at, "/components/schemas/S/oneOf", "the union siblings are reported where they are written: %+v", diags) - assert.Empty(t, preservationClaims(diags), + assert.Empty(t, openapitest.PreservationClaims(diags), "nothing converted, so nothing may say it was kept") } diff --git a/compilers/openapi/internal/sourceindex/sourceindex_test.go b/compilers/openapi/internal/sourceindex/sourceindex_test.go index 67f722e2..6dc0940b 100644 --- a/compilers/openapi/internal/sourceindex/sourceindex_test.go +++ b/compilers/openapi/internal/sourceindex/sourceindex_test.go @@ -435,7 +435,7 @@ func TestNesting_DoesNotReadAnchorNames(t *testing.T) { t.Parallel() root := openapitest.YAMLDocument(t, "a: &x {b: {c: 1}}\nd: {e: *x}\n") named := sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nesting() - clearAnchors(root) + openapitest.ClearAnchors(root) assert.Equal(t, named, sourceindex.Build(root, sourceindex.MaxIndexedNodes).Nesting(), "the same tree without its anchor names") @@ -444,17 +444,6 @@ func TestNesting_DoesNotReadAnchorNames(t *testing.T) { assert.Equal(t, int64(4), sourceindex.Build(built, sourceindex.MaxIndexedNodes).Nesting(), "a hand-built alias to an unnamed node") } -// clearAnchors empties the anchor name of every node under n. -func clearAnchors(n *yaml.Node) { - stack := []*yaml.Node{n} - for len(stack) > 0 { - at := stack[len(stack)-1] - stack = stack[:len(stack)-1] - at.Anchor = "" - stack = append(stack, at.Content...) - } -} - // TestNesting_AnAnchorCycleCountsOneTurn pins that a recursive anchor, which // the caller refuses on AnchorCycles, leaves a finite answer behind rather // than a walk that never ends. diff --git a/ir/irverify/bigval_test.go b/ir/irverify/bigval_test.go index 737f4ae0..e15df41b 100644 --- a/ir/irverify/bigval_test.go +++ b/ir/irverify/bigval_test.go @@ -9,13 +9,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// bigVal returns a pointer to the literal as written, which is how the three -// bound fields carry one and the only way to write one past ir.NewBigVal. -func bigVal(literal string) *ir.BigVal { - v := ir.BigVal(literal) - return &v -} - // numericValue is a number-kinded ir.Value carrying the literal as written. func numericValue(literal string) ir.Value { return ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal(literal)} @@ -57,12 +50,12 @@ func bigValCarriers(literal string) map[string]*ir.Document { withDefault, withBound := prop, prop def := numericValue(literal) withDefault.Default = &def - withBound.Constraints = &ir.Constraints{Max: bigVal(literal)} + withBound.Constraints = &ir.Constraints{Max: new(ir.BigVal(literal))} return map[string]*ir.Document{ - "doc.Types[t/x/S].Constraints.Min": scalar(&ir.Constraints{Min: bigVal(literal)}), - "doc.Types[t/x/S].Constraints.Max": scalar(&ir.Constraints{Max: bigVal(literal)}), - "doc.Types[t/x/S].Constraints.MultipleOf": scalar(&ir.Constraints{MultipleOf: bigVal(literal)}), + "doc.Types[t/x/S].Constraints.Min": scalar(&ir.Constraints{Min: new(ir.BigVal(literal))}), + "doc.Types[t/x/S].Constraints.Max": scalar(&ir.Constraints{Max: new(ir.BigVal(literal))}), + "doc.Types[t/x/S].Constraints.MultipleOf": scalar(&ir.Constraints{MultipleOf: new(ir.BigVal(literal))}), "doc.Types[t/x/L].Value.Num": valued(numericValue(literal)), "doc.Types[t/x/M].Properties[0].Default.Num": property(withDefault), "doc.Types[t/x/M].Properties[0].Constraints.Max": property(withBound), @@ -153,7 +146,7 @@ func TestVerify_UnusedNumIsNotABound(t *testing.T) { ID: "t/x/S", Name: named("s"), Provenance: prov, - Constraints: &ir.Constraints{Min: bigVal("")}, + Constraints: &ir.Constraints{Min: new(ir.BigVal(""))}, }}} got := verifyDeclared(empty) require.Len(t, got, 1) From df65ba3ee6ec0c9ab041f1a767bb60a1371b9624 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 14:13:35 +0300 Subject: [PATCH 16/18] docs: say which helpers stay written in more than one package The helper rule now names what a shared home does not take: a helper that belongs in openapitest or irtest but cannot live there, which is written in each package that needs it with each copy saying why; one outside what either serves, such as a file read or a stub compiler; and one whose body is a single expression, which holds no behaviour for a copy to drift from. Those are the copies a name-blind comparison of every test helper still finds. --- CONTRIBUTING.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 405b6f17..ca8bf59b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,10 +67,11 @@ Read the documents for your layer, and skip the rest until you need them. them or, when it is new, in the package's `helpers_test.go`, or `helpers_internal_test.go` inside the package. One that several packages share is in `compilers/openapi/internal/openapitest` or `ir/irtest`, as `openapitest.SchemaFromYAML` and `irtest.RepoRoot` are, when it builds or reads - what those packages serve, a spec and its diagnostics or the IR, and every package needing it - can import it. A helper neither can hold is written in each package that needs it, and says why, - as `parseFull`'s copies do; so is a general one, such as a file write, between packages that - share no test-helper package. + what those packages serve, a spec and its diagnostics or the IR. One that belongs there but + cannot live there, as `parseFull`, which compiles through `openapi.New`, is written in each + package that needs it, and each copy says why. A helper outside what either serves, such as a + file read or a stub compiler, is written where it is needed, and so is one whose body is a single + expression, such as `ir.TypeRef{Target: id}`, which holds no behaviour for a copy to drift from. *Fails if missed:* nothing. A copy compiles beside the original. - **An end-to-end test of the OpenAPI compiler goes in `compilers/openapi/e2e`,** in a file named for the construct or the property it checks: a test of what the compiler does as a whole, which no From 5f0ed0dfd5bce373d91f44d1ca66a825f068961e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 14:30:25 +0300 Subject: [PATCH 17/18] test: derive the bounds six tests restated Three external tests spelled an unexported bound's value instead of reading it, two of them with a comment that the bound was unexported: schema's discriminator ancestor depth (256) and $ref position hops (64), and operation's multipart $ref hops (16). Each is now aliased in its package's export_test.go, as resolve's MaxRefChain already is, and the tests read the alias. The multipart tests also spelled what follows from the bounds: the chain lengths, the component one past the cut and the warnings' text, for both the hop bound and schema.MaxAllOfFlattenDepth. They derive those too, so a changed bound leaves them testing its new boundary rather than failing on, or quietly passing beside, the old one. The re-entry test kept a 16-link chain that only stayed within the bound while the bound was 16. Each package's tests and subtests are unchanged. With a bound's value planted at another number the tests still pass, and with its comparison planted one off they fail. --- .../openapi/internal/operation/export_test.go | 4 ++ .../internal/operation/multipart_test.go | 48 +++++++++++-------- .../schema/discriminator_allof_test.go | 5 +- .../schema/discriminator_mapping_test.go | 8 ++-- .../openapi/internal/schema/export_test.go | 7 +++ 5 files changed, 46 insertions(+), 26 deletions(-) create mode 100644 compilers/openapi/internal/operation/export_test.go diff --git a/compilers/openapi/internal/operation/export_test.go b/compilers/openapi/internal/operation/export_test.go new file mode 100644 index 00000000..b73130c5 --- /dev/null +++ b/compilers/openapi/internal/operation/export_test.go @@ -0,0 +1,4 @@ +package operation + +// MaxPartRefHops is maxPartRefHops, for the external tests. +const MaxPartRefHops = maxPartRefHops diff --git a/compilers/openapi/internal/operation/multipart_test.go b/compilers/openapi/internal/operation/multipart_test.go index 48426aef..ea6909da 100644 --- a/compilers/openapi/internal/operation/multipart_test.go +++ b/compilers/openapi/internal/operation/multipart_test.go @@ -2,6 +2,7 @@ package operation_test import ( "encoding/json/jsontext" + "fmt" "strconv" "strings" "testing" @@ -13,6 +14,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/ids" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/operation" "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" ) @@ -571,8 +573,9 @@ func TestPartEncodings_ReadsTheFlattenedComposition(t *testing.T) { got[i] = openapitest.FirstOp(t, svc).Request.Contents[0].Encoding if tc.capped { msg := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, string(bodyPtr)) - assert.Equal(t, "form body composition is nested past the flattening depth of 32, "+ - "so a part reached only below it has no encoding entry", msg, "a walk cut short by the bound says so") + assert.Equal(t, fmt.Sprintf("form body composition is nested past the flattening depth of %d, "+ + "so a part reached only below it has no encoding entry", schema.MaxAllOfFlattenDepth), msg, + "a walk cut short by the bound says so") if tc.shape == shapeBesideNote { note := ir.PropID("p/openapi" + bodyPtr + ids.Ptr("properties", "note")) assert.Contains(t, got[i], note, "the part the body declares is keyed beside the cut") @@ -596,7 +599,7 @@ func TestPartEncodings_ReadsTheFlattenedComposition(t *testing.T) { func TestPartEncodings_RefHopBoundIsReported(t *testing.T) { t.Parallel() var comps strings.Builder - const links = 20 + const links = operation.MaxPartRefHops + 4 for i := range links { next := "{type: object, properties: {file: {type: string, format: binary}}}" if i < links-1 { @@ -621,26 +624,30 @@ components: openapitest.RequireNoErrorDiags(t, diags) msg := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, "/paths/~1u/post/requestBody/content/multipart~1form-data/schema") - assert.Equal(t, "form body composition follows $refs past 16 hops, "+ - "so a part reached only beyond them has no encoding entry", msg, "the bound is named at the body schema") + assert.Equal(t, fmt.Sprintf("form body composition follows $refs past %d hops, "+ + "so a part reached only beyond them has no encoding entry", operation.MaxPartRefHops), msg, + "the bound is named at the body schema") assert.Empty(t, openapitest.FirstOp(t, svc).Request.Contents[0].Encoding, "the part beyond it has no entry") } -// TestPartEncodings_ShorterPathReentersTarget spells a 16-link allOf chain the -// body reaches twice: from its head and from its last link. Whichever the body -// names first, the target at the end is two hops from the body by the short -// path, so both parts are keyed and no bound is reported. +// TestPartEncodings_ShorterPathReentersTarget spells an allOf chain as long as +// the hop bound, which the body reaches twice: from its head and from its last +// link. Whichever the body names first, the target at the end is two hops from +// the body by the short path, so both parts are keyed and no bound is reported. func TestPartEncodings_ShorterPathReentersTarget(t *testing.T) { t.Parallel() + head, last := "L1", "L"+strconv.Itoa(operation.MaxPartRefHops) + target := "L" + strconv.Itoa(operation.MaxPartRefHops+1) + ref := func(name string) string { return "{$ref: '#/components/schemas/" + name + "'}" } cases := []struct{ name, allOf string }{ - {"L1, L16", "[{$ref: '#/components/schemas/L1'}, {$ref: '#/components/schemas/L16'}]"}, - {"L16, L1", "[{$ref: '#/components/schemas/L16'}, {$ref: '#/components/schemas/L1'}]"}, + {head + ", " + last, "[" + ref(head) + ", " + ref(last) + "]"}, + {last + ", " + head, "[" + ref(last) + ", " + ref(head) + "]"}, } var comps strings.Builder - for i := 1; i <= 16; i++ { + for i := 1; i <= operation.MaxPartRefHops; i++ { comps.WriteString(" L" + strconv.Itoa(i) + ": {allOf: [{$ref: '#/components/schemas/L" + strconv.Itoa(i+1) + "'}]}\n") } - comps.WriteString(" L17: {type: object, properties: {file: {type: string, format: binary}, meta: {type: string}}}\n") + comps.WriteString(" " + target + ": {type: object, properties: {file: {type: string, format: binary}, meta: {type: string}}}\n") for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -662,7 +669,7 @@ components: openapitest.RequireNoErrorDiags(t, diags) got := openapitest.FirstOp(t, svc).Request.Contents[0].Encoding for _, part := range []string{"file", "meta"} { - id := ir.PropID("p/openapi/components/schemas/L17" + ids.Ptr("properties", part)) + id := ir.PropID("p/openapi/components/schemas/" + target + string(ids.Ptr("properties", part))) assert.Contains(t, got, id, "the %s part is keyed at the target both paths reach", part) } assert.NotContains(t, diagMessages(diags), "follows $refs past", @@ -738,7 +745,7 @@ components: // loss only for a part reached through the cut alone. func TestPartEncodings_CutPastReachedPart(t *testing.T) { t.Parallel() - const partRefHops = 16 + const partRefHops = operation.MaxPartRefHops obj := "{type: object, properties: {file: {type: string, format: binary}, meta: {type: string}}}" var chain strings.Builder for i := range partRefHops + 1 { @@ -751,11 +758,14 @@ func TestPartEncodings_CutPastReachedPart(t *testing.T) { deep = "{allOf: [" + deep + "]}" } leaf := "Deep" + strings.Repeat("/allOf/0", n) + target := "L" + strconv.Itoa(partRefHops+1) + pastHops := fmt.Sprintf("past %d hops, so a part reached only beyond them", partRefHops) + pastDepth := fmt.Sprintf("depth of %d, so a part reached only below it", schema.MaxAllOfFlattenDepth) cases := []struct{ name, first, second, comps, keyedAt, bound string }{ - {"hops, chain first", "L0", "L17", chain.String(), "L17", "past 16 hops, so a part reached only beyond them"}, - {"hops, target first", "L17", "L0", chain.String(), "L17", "past 16 hops, so a part reached only beyond them"}, - {"depth, wrappers first", "Deep", leaf, " Deep: " + deep + "\n", leaf, "depth of 32, so a part reached only below it"}, - {"depth, pointer first", leaf, "Deep", " Deep: " + deep + "\n", leaf, "depth of 32, so a part reached only below it"}, + {"hops, chain first", "L0", target, chain.String(), target, pastHops}, + {"hops, target first", target, "L0", chain.String(), target, pastHops}, + {"depth, wrappers first", "Deep", leaf, " Deep: " + deep + "\n", leaf, pastDepth}, + {"depth, pointer first", leaf, "Deep", " Deep: " + deep + "\n", leaf, pastDepth}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/compilers/openapi/internal/schema/discriminator_allof_test.go b/compilers/openapi/internal/schema/discriminator_allof_test.go index 4f00e9a6..dbd7272d 100644 --- a/compilers/openapi/internal/schema/discriminator_allof_test.go +++ b/compilers/openapi/internal/schema/discriminator_allof_test.go @@ -350,9 +350,8 @@ func TestAllOf_DiscriminatorValueSelfAncestorInCycle(t *testing.T) { // not on the construction. func TestAllOf_DiscriminatorValueChainDeeperThanCap(t *testing.T) { t.Parallel() - // maxDiscriminatorAncestorDepth is unexported; 256 is its committed value, and - // the two cases below straddle it. - const ancestorCap = 256 + // The two cases below straddle the cap. + const ancestorCap = schema.MaxDiscriminatorAncestorDepth for _, tc := range []struct { name string links int diff --git a/compilers/openapi/internal/schema/discriminator_mapping_test.go b/compilers/openapi/internal/schema/discriminator_mapping_test.go index 72dd4f0b..e114ad44 100644 --- a/compilers/openapi/internal/schema/discriminator_mapping_test.go +++ b/compilers/openapi/internal/schema/discriminator_mapping_test.go @@ -11,6 +11,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/pass" ) @@ -239,16 +240,15 @@ func TestDiscriminatorMapping_ToAPureRefNamesItsTarget(t *testing.T) { // chain exactly the cap long is the control: it reads through to the component. func TestDiscriminatorMapping_ChainOfPositionsDeeperThanCap(t *testing.T) { t.Parallel() - // maxTypePositionHops is unexported; 64 is its committed value, and the two - // cases below straddle it. - const hopCap = 64 + // The two cases below straddle the cap. + const hopCap = schema.MaxTypePositionHops for _, tc := range []struct { name string links int want ir.TypeID }{ {"as long as the cap", hopCap, componentID("Dog")}, - {"beyond the cap", hopCap + 1, "t/anon/components/schemas/Kennel/properties/a64"}, + {"beyond the cap", hopCap + 1, ir.TypeID(fmt.Sprintf("t/anon/components/schemas/Kennel/properties/a%d", hopCap))}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/compilers/openapi/internal/schema/export_test.go b/compilers/openapi/internal/schema/export_test.go index a6db52a9..7bb8d97c 100644 --- a/compilers/openapi/internal/schema/export_test.go +++ b/compilers/openapi/internal/schema/export_test.go @@ -14,6 +14,13 @@ import ( "github.com/dexpace/morphic/ir" ) +// MaxDiscriminatorAncestorDepth is maxDiscriminatorAncestorDepth, for the +// external tests. +const MaxDiscriminatorAncestorDepth = maxDiscriminatorAncestorDepth + +// MaxTypePositionHops is maxTypePositionHops, for the external tests. +const MaxTypePositionHops = maxTypePositionHops + // Lowerer is test scaffolding, not a compiler type. The compiler has no such // struct: every lowering is a function of the context and the registry, and // the caller owns the document being built (#177). It is declared here, once, From 49fb350bf42a899c595b077cdc59bb748c475c42 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 24 Oct 2026 14:30:25 +0300 Subject: [PATCH 18/18] test: correct the querystring control's comment The control for the forbidden querystring keywords called itself the control for "both cases above" and spoke of "neither keyword", but three cases precede it, style, explode and allowReserved, and it checks all three. --- .../internal/operation/params_querystring_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/compilers/openapi/internal/operation/params_querystring_test.go b/compilers/openapi/internal/operation/params_querystring_test.go index 0642735f..f9622962 100644 --- a/compilers/openapi/internal/operation/params_querystring_test.go +++ b/compilers/openapi/internal/operation/params_querystring_test.go @@ -159,11 +159,11 @@ func TestParams_QueryStringDeclaredAllowReservedIsKeptAndReported(t *testing.T) "/paths/~1q/get/parameters/0/allowReserved")) } -// TestParams_QueryStringUndeclaredKeywordsAreNotReported is the control for -// both cases above: a querystring binding that declares neither keyword raises -// diag.InvalidLocationKeyword at neither pointer, so the diagnostic tracks the -// keyword's presence in the document rather than firing for every parameter at -// this location. +// TestParams_QueryStringUndeclaredKeywordsAreNotReported is the control for the +// three cases above: a querystring binding that declares none of style, explode +// and allowReserved raises diag.InvalidLocationKeyword nowhere, so the +// diagnostic tracks each keyword's presence in the document rather than firing +// for every parameter at this location. func TestParams_QueryStringUndeclaredKeywordsAreNotReported(t *testing.T) { t.Parallel() doc, diags := parseFull(t, openapitest.PathsSpecVer("3.2.0", ` /q: