From 25bfb1fb9cbc205cdb81445a8b15d1d0b078259d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Fri, 23 Oct 2026 21:44:32 +0300 Subject: [PATCH 01/11] test(compilers/openapi): move end-to-end tests into their own package The root package held its four source files among 53 test files, most of which compile a whole spec through openapi.New and read the IR, the conformance corpus, goldens, fuzz targets and benchmarks among them. They cover nothing under internal/, since coverage counts a package's own tests, and they buried the unit tests of the root's own code. They now live in compilers/openapi/e2e, a package of test files only. The files move whole, so history follows them; the edits are the package clause, one more ../ in each repository path, the openapi. qualifier on the seven files that were in-package, and the FuzzCycleDetector seed corpus moving beside its target. What the root keeps is the tests of its own files. Three of them borrowed fixtures from files that moved: sourcetable_test.go now declares its own document and overlays, and openapi_test.go and budgets_test.go their own paths. Two exported defaults were covered only by end-to-end tests and get a unit test each. defs_test.go's copy of propByWire, which existed only because it could not reach the conformance one, is gone. The lowering-spec checks name each package by its repository path, and the docs and commands that named the old location point at the new one. --- CLAUDE.md | 11 +- CONTRIBUTING.md | 12 +- README.md | 6 +- compilers/openapi/budgets_test.go | 2 + .../{ => e2e}/allof_visibility_test.go | 2 +- .../openapi/{ => e2e}/amplification_test.go | 9 +- compilers/openapi/{ => e2e}/anchors_test.go | 19 +-- .../{ => e2e}/annotations_carrier_test.go | 2 +- .../openapi/{ => e2e}/annotations_test.go | 2 +- .../openapi/{ => e2e}/boolean_ref_test.go | 2 +- .../openapi/{ => e2e}/compile_bench_test.go | 2 +- .../openapi/{ => e2e}/confinement_test.go | 2 +- .../openapi/{ => e2e}/conformance_test.go | 4 +- .../{ => e2e}/conformance_unmodeled_test.go | 2 +- .../openapi/{ => e2e}/constraints_test.go | 2 +- compilers/openapi/{ => e2e}/cycles_test.go | 37 +++--- .../openapi/{ => e2e}/danglingcheck_test.go | 6 +- compilers/openapi/{ => e2e}/defs_test.go | 11 +- compilers/openapi/e2e/doc_test.go | 9 ++ .../{ => e2e}/external_content_test.go | 2 +- .../openapi/{ => e2e}/external_nested_test.go | 4 +- .../openapi/{ => e2e}/external_order_test.go | 2 +- compilers/openapi/{ => e2e}/fuzz_test.go | 6 +- compilers/openapi/{ => e2e}/golden_test.go | 10 +- compilers/openapi/e2e/helpers_test.go | 43 +++++++ .../openapi/{ => e2e}/later_version_test.go | 2 +- compilers/openapi/{ => e2e}/load_test.go | 47 ++++---- .../{ => e2e}/lowering_spec_checks_test.go | 4 +- .../openapi/{ => e2e}/lowering_spec_test.go | 31 +++-- compilers/openapi/{ => e2e}/main_test.go | 2 +- .../openapi/{ => e2e}/mapping_lowered_test.go | 2 +- .../{ => e2e}/mapping_spellings_test.go | 2 +- .../openapi/{ => e2e}/namespaces_test.go | 4 +- compilers/openapi/{ => e2e}/nesting_test.go | 2 +- .../openapi/{ => e2e}/null_schema_test.go | 2 +- .../{ => e2e}/nullable_widening_test.go | 2 +- compilers/openapi/{ => e2e}/overlay_test.go | 2 +- compilers/openapi/{ => e2e}/ownership_test.go | 2 +- compilers/openapi/{ => e2e}/plus_refs_test.go | 11 +- compilers/openapi/e2e/pointer_tokens_test.go | 69 +++++++++++ .../{ => e2e}/promotion_carriers_test.go | 2 +- compilers/openapi/{ => e2e}/promotion_test.go | 2 +- compilers/openapi/{ => e2e}/purity_test.go | 2 +- .../openapi/{ => e2e}/reference_ends_test.go | 2 +- .../openapi/{ => e2e}/refusal_order_test.go | 2 +- .../openapi/{ => e2e}/reuse_corpus_test.go | 4 +- compilers/openapi/{ => e2e}/streaming_test.go | 2 +- .../fuzz/FuzzCycleDetector/7d5c7e6c57176224 | 0 .../fuzz/FuzzCycleDetector/a897d2fc8d321096 | 0 .../openapi/{ => e2e}/unknownkeys_test.go | 4 +- compilers/openapi/openapi_test.go | 13 +- compilers/openapi/options_test.go | 16 +++ compilers/openapi/resolve_internal_test.go | 58 --------- compilers/openapi/sourcetable_test.go | 35 ++++-- docs/compilers/openapi.md | 114 +++++++++--------- 55 files changed, 375 insertions(+), 274 deletions(-) rename compilers/openapi/{ => e2e}/allof_visibility_test.go (99%) rename compilers/openapi/{ => e2e}/amplification_test.go (92%) rename compilers/openapi/{ => e2e}/anchors_test.go (94%) rename compilers/openapi/{ => e2e}/annotations_carrier_test.go (99%) rename compilers/openapi/{ => e2e}/annotations_test.go (99%) rename compilers/openapi/{ => e2e}/boolean_ref_test.go (99%) rename compilers/openapi/{ => e2e}/compile_bench_test.go (97%) rename compilers/openapi/{ => e2e}/confinement_test.go (99%) rename compilers/openapi/{ => e2e}/conformance_test.go (99%) rename compilers/openapi/{ => e2e}/conformance_unmodeled_test.go (99%) rename compilers/openapi/{ => e2e}/constraints_test.go (99%) rename compilers/openapi/{ => e2e}/cycles_test.go (96%) rename compilers/openapi/{ => e2e}/danglingcheck_test.go (97%) rename compilers/openapi/{ => e2e}/defs_test.go (97%) create mode 100644 compilers/openapi/e2e/doc_test.go rename compilers/openapi/{ => e2e}/external_content_test.go (99%) rename compilers/openapi/{ => e2e}/external_nested_test.go (98%) rename compilers/openapi/{ => e2e}/external_order_test.go (98%) rename compilers/openapi/{ => e2e}/fuzz_test.go (97%) rename compilers/openapi/{ => e2e}/golden_test.go (84%) create mode 100644 compilers/openapi/e2e/helpers_test.go rename compilers/openapi/{ => e2e}/later_version_test.go (99%) rename compilers/openapi/{ => e2e}/load_test.go (94%) rename compilers/openapi/{ => e2e}/lowering_spec_checks_test.go (98%) rename compilers/openapi/{ => e2e}/lowering_spec_test.go (94%) rename compilers/openapi/{ => e2e}/main_test.go (96%) rename compilers/openapi/{ => e2e}/mapping_lowered_test.go (99%) rename compilers/openapi/{ => e2e}/mapping_spellings_test.go (98%) rename compilers/openapi/{ => e2e}/namespaces_test.go (96%) rename compilers/openapi/{ => e2e}/nesting_test.go (98%) rename compilers/openapi/{ => e2e}/null_schema_test.go (99%) rename compilers/openapi/{ => e2e}/nullable_widening_test.go (99%) rename compilers/openapi/{ => e2e}/overlay_test.go (99%) rename compilers/openapi/{ => e2e}/ownership_test.go (99%) rename compilers/openapi/{ => e2e}/plus_refs_test.go (97%) create mode 100644 compilers/openapi/e2e/pointer_tokens_test.go rename compilers/openapi/{ => e2e}/promotion_carriers_test.go (98%) rename compilers/openapi/{ => e2e}/promotion_test.go (99%) rename compilers/openapi/{ => e2e}/purity_test.go (99%) rename compilers/openapi/{ => e2e}/reference_ends_test.go (99%) rename compilers/openapi/{ => e2e}/refusal_order_test.go (99%) rename compilers/openapi/{ => e2e}/reuse_corpus_test.go (96%) rename compilers/openapi/{ => e2e}/streaming_test.go (98%) rename compilers/openapi/{ => e2e}/testdata/fuzz/FuzzCycleDetector/7d5c7e6c57176224 (100%) rename compilers/openapi/{ => e2e}/testdata/fuzz/FuzzCycleDetector/a897d2fc8d321096 (100%) rename compilers/openapi/{ => e2e}/unknownkeys_test.go (99%) diff --git a/CLAUDE.md b/CLAUDE.md index 90c39030..c5329ca1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,11 +205,12 @@ These all exist already — extend them rather than building a parallel mechanis `internal/jsonshape` by `internal/census`; `go test ./cmd/morphic-census` holds the file to a census of `testdata/`. A case that sets a fact takes its line out, so its change runs `-update`. - **Lowering specification**: `docs/compilers/openapi.md` is machine-read like the matrix. Each rule - names its evidence, and the `TestLoweringSpec` tests in `compilers/openapi` hold the names to the - code both ways, each listed code to the rule's text, and every `docs/compilers/openapi.md#` - citation to a rule. It, `docs/parser-library.md` and each format's conformance `matrix.md` are - read through `internal/keydoc`, the one reader of the repository's keyed documents, and a test a - Markdown document names must exist (`internal/archtest`). + names its evidence, and the `TestLoweringSpec` tests in `compilers/openapi/e2e` hold the names to + the code both ways, each listed code to the rule's text, and every + `docs/compilers/openapi.md#` citation to a rule. It, `docs/parser-library.md` and each + format's conformance `matrix.md` are read through `internal/keydoc`, the one reader of the + repository's keyed documents, and a test a Markdown document names must exist + (`internal/archtest`). - **Oracles**: `internal/harness` drives a spec through no-panic → no error diagnostic → `irverify` invariants → JSON round-trip → determinism → order-invariance, stopping at the first one that fires. `harness.Check` is the list — read it there rather than trusting this sentence; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26fd470f..de3c6e5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ A lowering rule says how one OpenAPI construct reaches the IR: the field it fill `TestDocComments_StayUnderTheCap`, `TestSuppressions_NoDirectiveStandsInTheSource`. 4. **Add a conformance case and a unit test.** Write a minimal spec at `testdata/conformance/openapi/.yaml` that exercises the rule, add an entry to - `conformanceCases()` in `compilers/openapi/conformance_test.go` naming its assertion, list it + `conformanceCases()` in `compilers/openapi/e2e/conformance_test.go` naming its assertion, list it under each [`ir-spec-matrix.md`](docs/ir-spec-matrix.md) row it witnesses in `testdata/conformance/openapi/matrix.md` (none, for a case that pins only a diagnostic), write the assertion, and name the case in the rule's Cases field. Then write its golden alone, since @@ -82,12 +82,12 @@ A lowering rule says how one OpenAPI construct reaches the IR: the field it fill the golden exists: ```bash - go test ./compilers/openapi -run '^TestConformance$/^$' -update + go test ./compilers/openapi/e2e -run '^TestConformance$/^$' -update ``` - Coverage is counted per package, and the case runs in `compilers/openapi`, so it covers nothing - under `internal/`: the code you wrote there also needs a test in its own package, which the - rule's Tests field then names. + Coverage is counted per package, and the case runs in `compilers/openapi/e2e`, which holds no + code of its own, so it covers nothing: the code you wrote also needs a test in its own package, + which the rule's Tests field then names. *Fails if missed:* `TestConformance_TableNamesEveryCorpusSpec` for a spec with no entry, `TestConformance/` for a golden that does not match, `TestLoweringSpec_CasesAreTheCorpus` for a case no rule names, `TestConformance_EveryRegisteredFormatIsHeldToItsColumn` for a name in @@ -97,7 +97,7 @@ A lowering rule says how one OpenAPI construct reaches the IR: the field it fill 5. **Regenerate what the change moves, and read the diff.** Other goldens may change too: ```bash - go test ./compilers/openapi -run 'TestGolden|TestConformance' -update + go test ./compilers/openapi/e2e -run 'TestGolden|TestConformance' -update git status --short testdata/ ``` diff --git a/README.md b/README.md index b031dc23..89d0574e 100644 --- a/README.md +++ b/README.md @@ -316,13 +316,13 @@ Run a single test with `go test ./ir -run TestName`. Beyond that: and review the diff: ```bash - go test ./compilers/openapi -run 'TestGolden|TestConformance' -update + go test ./compilers/openapi/e2e -run 'TestGolden|TestConformance' -update ``` - **Conformance corpus.** Each case in `testdata/conformance/openapi/` is a minimal spec that witnesses one or more rows of the [spec capability matrix](docs/ir-spec-matrix.md). To add one, write `.yaml`, add an entry to `conformanceCases()` in - `compilers/openapi/conformance_test.go` naming its assertion, list it under the matrix rows it + `compilers/openapi/e2e/conformance_test.go` naming its assertion, list it under the matrix rows it witnesses in `testdata/conformance/openapi/matrix.md`, then run the command above to write `.golden.json`, and name the case in the rule of the [OpenAPI lowering specification](docs/compilers/openapi.md) it pins. The suite fails on a spec @@ -342,7 +342,7 @@ Run a single test with `go test ./ir -run TestName`. Beyond that: it reads the IR's declarations through the go command. - **Fuzzing.** `make fuzz` runs every fuzz target for 10s each (`make fuzz FUZZTIME=5m` for a longer search). One target runs on its own with, for example, - `go test ./compilers/openapi -run '^$' -fuzz '^FuzzCompile$' -fuzztime 1m`. + `go test ./compilers/openapi/e2e -run '^$' -fuzz '^FuzzCompile$' -fuzztime 1m`. ## Design docs diff --git a/compilers/openapi/budgets_test.go b/compilers/openapi/budgets_test.go index f8ba0afe..1ecd5e66 100644 --- a/compilers/openapi/budgets_test.go +++ b/compilers/openapi/budgets_test.go @@ -25,6 +25,8 @@ import ( "github.com/dexpace/morphic/ir" ) +const amplificationBombFixture = "../../testdata/openapi/amplification_alias_bomb.yaml" + // enumSpec is a minimal document whose one component declares a string enum of // n members. The specs these tests need differ only in how large they are, and // building them here is what keeps a multi-megabyte fixture out of the corpus. diff --git a/compilers/openapi/allof_visibility_test.go b/compilers/openapi/e2e/allof_visibility_test.go similarity index 99% rename from compilers/openapi/allof_visibility_test.go rename to compilers/openapi/e2e/allof_visibility_test.go index 520ffa15..9232fb4a 100644 --- a/compilers/openapi/allof_visibility_test.go +++ b/compilers/openapi/e2e/allof_visibility_test.go @@ -2,7 +2,7 @@ // redeclared property detail (required, docs, default, constraints, ...) but // never Visibility, so a readOnly/writeOnly declared on the allOf branch that // redeclares a shared field was silently lost. -package openapi_test +package e2e_test import ( "testing" diff --git a/compilers/openapi/amplification_test.go b/compilers/openapi/e2e/amplification_test.go similarity index 92% rename from compilers/openapi/amplification_test.go rename to compilers/openapi/e2e/amplification_test.go index 3e25fb4c..8d11e6b0 100644 --- a/compilers/openapi/amplification_test.go +++ b/compilers/openapi/e2e/amplification_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "fmt" @@ -11,11 +11,12 @@ import ( "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/ir" ) -const amplificationBombFixture = "../../testdata/openapi/amplification_alias_bomb.yaml" +const amplificationBombFixture = "../../../testdata/openapi/amplification_alias_bomb.yaml" func TestCompile_AliasBombDoesNotExhaustMemory(t *testing.T) { t.Parallel() @@ -29,7 +30,7 @@ func TestCompile_AliasBombDoesNotExhaustMemory(t *testing.T) { } done := make(chan result, 1) go func() { - doc, diags, cerr := New().Compile(t.Context(), + doc, diags, cerr := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "amplification_alias_bomb.yaml", Data: data}}, compilers.Options{}) done <- result{doc, diags, cerr} }() @@ -75,7 +76,7 @@ func TestCompile_SharedCompositionAncestorsDoNotAmplify(t *testing.T) { } done := make(chan result, 1) go func() { - doc, diags, cerr := New().Compile(t.Context(), + doc, diags, cerr := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "shared_ancestors.yaml", Data: []byte(b.String())}}, compilers.Options{}) done <- result{doc, diags, cerr} diff --git a/compilers/openapi/anchors_test.go b/compilers/openapi/e2e/anchors_test.go similarity index 94% rename from compilers/openapi/anchors_test.go rename to compilers/openapi/e2e/anchors_test.go index 23bdaece..cabc1c26 100644 --- a/compilers/openapi/anchors_test.go +++ b/compilers/openapi/e2e/anchors_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "encoding/json/jsontext" @@ -18,6 +18,7 @@ import ( "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" @@ -179,9 +180,9 @@ func compileServed(t *testing.T, first, second string) (*ir.Document, *ir.Docume func compileReferring(t *testing.T, path, uri string) *ir.Document { t.Helper() src := anchorFixtureHead + "paths:\n /x: {$ref: \"" + uri + "#/paths/~1x\"}\n" - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: []byte(src)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) require.NotNil(t, doc) openapitest.RequireNoErrorDiags(t, diags) @@ -200,9 +201,9 @@ func compileBesideExpectingErrors(t *testing.T, ext, root string) []ir.Diagnosti t.Helper() dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(ext), 0o600)) - _, diags, err := New().Compile(t.Context(), + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err, "a refused external document is a diagnostic, not a Go error") return diags } @@ -268,8 +269,8 @@ func TestCompile_ARefusedExternalDocumentIsFetchedOnce(t *testing.T) { src := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\ncomponents:\n schemas:\n" + " R1: " + ref + "\n R2: " + ref + "\n R3: " + ref + "\n" - _, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: "root.yaml", Data: []byte(src)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true, Limits: Limits{MaxSourceBytes: budget}}}) + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "root.yaml", Data: []byte(src)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true, Limits: openapi.Limits{MaxSourceBytes: budget}}}) require.NoError(t, err) var refusedAt []jsontext.Pointer @@ -312,8 +313,8 @@ func TestCompile_AnExternalDocumentIsParsedAsItWasJudged(t *testing.T) { " /a: {$ref: \"" + srv.URL + "/doc.yaml#/paths/~1missing\"}\n" + " /b: {$ref: \"" + respelled + "/doc.yaml#/paths/~1x\"}\n" - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: "root.yaml", Data: []byte(src)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true, Limits: Limits{MaxSourceNodes: budget}}}) + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "root.yaml", Data: []byte(src)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true, Limits: openapi.Limits{MaxSourceNodes: budget}}}) require.NoError(t, err) require.NotNil(t, doc) diff --git a/compilers/openapi/annotations_carrier_test.go b/compilers/openapi/e2e/annotations_carrier_test.go similarity index 99% rename from compilers/openapi/annotations_carrier_test.go rename to compilers/openapi/e2e/annotations_carrier_test.go index af942d84..731e6b61 100644 --- a/compilers/openapi/annotations_carrier_test.go +++ b/compilers/openapi/e2e/annotations_carrier_test.go @@ -4,7 +4,7 @@ // not express a cell there at all — so two dropped-annotation defects at a // parameter lived in a position the matrix was structurally silent about, while // looking complete (GitHub #142). -package openapi_test +package e2e_test import ( "testing" diff --git a/compilers/openapi/annotations_test.go b/compilers/openapi/e2e/annotations_test.go similarity index 99% rename from compilers/openapi/annotations_test.go rename to compilers/openapi/e2e/annotations_test.go index 58a45632..625ae9a5 100644 --- a/compilers/openapi/annotations_test.go +++ b/compilers/openapi/e2e/annotations_test.go @@ -2,7 +2,7 @@ // annotation per kind of position, plus an assertion that no combination is // silently uncovered. It complements conformance_test.go, which covers // capability rows rather than annotation positions. -package openapi_test +package e2e_test import ( "encoding/json/v2" diff --git a/compilers/openapi/boolean_ref_test.go b/compilers/openapi/e2e/boolean_ref_test.go similarity index 99% rename from compilers/openapi/boolean_ref_test.go rename to compilers/openapi/e2e/boolean_ref_test.go index ac82be79..25272029 100644 --- a/compilers/openapi/boolean_ref_test.go +++ b/compilers/openapi/e2e/boolean_ref_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "strings" diff --git a/compilers/openapi/compile_bench_test.go b/compilers/openapi/e2e/compile_bench_test.go similarity index 97% rename from compilers/openapi/compile_bench_test.go rename to compilers/openapi/e2e/compile_bench_test.go index b7d8f494..09ae591e 100644 --- a/compilers/openapi/compile_bench_test.go +++ b/compilers/openapi/e2e/compile_bench_test.go @@ -1,4 +1,4 @@ -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "encoding/json/v2" diff --git a/compilers/openapi/confinement_test.go b/compilers/openapi/e2e/confinement_test.go similarity index 99% rename from compilers/openapi/confinement_test.go rename to compilers/openapi/e2e/confinement_test.go index 93e28f41..208451d7 100644 --- a/compilers/openapi/confinement_test.go +++ b/compilers/openapi/e2e/confinement_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it pins the // confinement a compile gives the reader it was handed, end to end through the // public entry point, where the load phase's own tests hold its parts. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "context" diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/e2e/conformance_test.go similarity index 99% rename from compilers/openapi/conformance_test.go rename to compilers/openapi/e2e/conformance_test.go index 1f710a89..30ee54f1 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/e2e/conformance_test.go @@ -2,7 +2,7 @@ // capability-conformance corpus (one minimal spec per ir-spec-matrix.md row) // through the whole compiler to keep "lossless by default" honest, so it has no // single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "encoding/json/jsontext" @@ -30,7 +30,7 @@ import ( // conformanceDir is the corpus of one minimal spec per capability row of // ir-spec-matrix.md, addressed relative to this test file. -const conformanceDir = "../../testdata/conformance/openapi" +const conformanceDir = "../../../testdata/conformance/openapi" // witnessDoc is the file in the corpus directory that names the matrix rows each // spec witnesses. It is no spec, and internal/conformance reads it. diff --git a/compilers/openapi/conformance_unmodeled_test.go b/compilers/openapi/e2e/conformance_unmodeled_test.go similarity index 99% rename from compilers/openapi/conformance_unmodeled_test.go rename to compilers/openapi/e2e/conformance_unmodeled_test.go index a0676ad5..03c3bb33 100644 --- a/compilers/openapi/conformance_unmodeled_test.go +++ b/compilers/openapi/e2e/conformance_unmodeled_test.go @@ -4,7 +4,7 @@ // and be announced by a diagnostic. It shares TestConformance's table in // conformance_test.go; the split is by subject, so the structural cases stay // readable beside each other there. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "encoding/json/jsontext" diff --git a/compilers/openapi/constraints_test.go b/compilers/openapi/e2e/constraints_test.go similarity index 99% rename from compilers/openapi/constraints_test.go rename to compilers/openapi/e2e/constraints_test.go index 1400338d..e070649a 100644 --- a/compilers/openapi/constraints_test.go +++ b/compilers/openapi/e2e/constraints_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "testing" diff --git a/compilers/openapi/cycles_test.go b/compilers/openapi/e2e/cycles_test.go similarity index 96% rename from compilers/openapi/cycles_test.go rename to compilers/openapi/e2e/cycles_test.go index 63fdf37e..8e2dc28a 100644 --- a/compilers/openapi/cycles_test.go +++ b/compilers/openapi/e2e/cycles_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "encoding/json/v2" @@ -14,6 +14,7 @@ import ( yaml "gopkg.in/yaml.v3" "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" @@ -158,7 +159,7 @@ func TestCompile_AliasCycleIsRefused(t *testing.T) { for _, tc := range aliasCycleReproducers { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []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") @@ -177,7 +178,7 @@ func TestCompile_SchemaRefCycleIsReportedAndStillCompiles(t *testing.T) { for _, tc := range schemaRefCycles { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: tc.file + ".yaml", Data: readReproducer(t, tc.file)}}, compilers.Options{}) require.NoError(t, err, "a cyclic spec is a spec problem, not a Go error") assert.NotNil(t, doc, "a loop of schema $refs does not refuse the document") @@ -197,7 +198,7 @@ func TestCompile_SchemaRefNamingNothingIsUnresolvedNotCyclic(t *testing.T) { for _, tc := range unreadSchemaRefs { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: tc.file + ".yaml", Data: readReproducer(t, tc.file)}}, compilers.Options{}) require.NoError(t, err) assert.NotNil(t, doc) @@ -247,7 +248,7 @@ func TestCompile_CycleIsReportedAtItsLeastMemberInEitherOrder(t *testing.T) { slices.Reverse(members) } - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "loop.yaml", Data: []byte(head + strings.Join(members, ""))}}, compilers.Options{}) @@ -313,9 +314,9 @@ func TestCompile_ALoopThroughAnotherDocumentIsReportedOnce(t *testing.T) { []byte("components:\n schemas:\n"+strings.Join(other, "")), 0o600)) root := "openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\ncomponents:\n schemas:\n" + strings.Join(source, "") - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err, tc.name) require.NotNil(t, doc, "%s: the rest of the document lowers", tc.name) var sites []ir.Provenance @@ -346,7 +347,7 @@ func TestCompile_ReferenceObjectCycleIsReportedUnresolved(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() data := readReproducer(t, tc.file) - _, diags, err := New().Compile(t.Context(), + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: tc.file + ".yaml", Data: data}}, compilers.Options{}) require.NoError(t, err, "a failing reference is a spec problem, not a Go error") for _, d := range diags { @@ -376,7 +377,7 @@ components: A: $ref: '#x-s' ` - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "anchor.yaml", Data: []byte(src)}}, compilers.Options{}) require.NoError(t, err, "an unresolved anchor reference is a spec problem, not a Go error") require.NotNil(t, doc, "the document is no longer refused as a cycle") @@ -417,7 +418,7 @@ components: schemas: Form: {type: object} ` - doc, _, err := New().Compile(t.Context(), + doc, _, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "multipart.yaml", Data: []byte(src)}}, compilers.Options{}) require.NoError(t, err) require.NotNil(t, doc) @@ -480,10 +481,10 @@ func TestDetectCycles_ComponentOnlyCyclesLeftToResolver(t *testing.T) { for _, tc := range componentOnlyCycles { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Empty(t, scan.Aliases(scan.InSource(0), scanIndex(t, tc.data), DefaultMaxAliasSurplus), + assert.Empty(t, scan.Aliases(scan.InSource(0), scanIndex(t, tc.data), openapi.DefaultMaxAliasSurplus), "a components-only cycle is the resolver's to report") - _, diags, err := New().Compile(t.Context(), + _, 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) @@ -586,7 +587,7 @@ func TestCompile_RefShapedDataNotRefused(t *testing.T) { for _, tc := range refShapedDataSpecs { t.Run(tc.name, func(t *testing.T) { t.Parallel() - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: tc.name + ".yaml", Data: []byte(tc.data)}}, compilers.Options{}) require.NoError(t, err) assert.NotNil(t, doc, "a legal ref-shaped-data spec must still lower") @@ -619,7 +620,7 @@ components: schemas: A: {$ref: '#/components/schemas/A/'} ` - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "schema_empty_segment.yaml", Data: []byte(src)}}, compilers.Options{}) require.NoError(t, err) assert.NotNil(t, doc) @@ -633,7 +634,7 @@ components: // can end up looking somewhere the others are not — which matters most for the // seed loader, since it ignores a read error and a wrong path there would cost // the fuzzer its seeds in silence. -const reproducerDir = "../../testdata/openapi" +const reproducerDir = "../../../testdata/openapi" func readReproducer(t *testing.T, file string) []byte { t.Helper() @@ -649,7 +650,7 @@ func readReproducer(t *testing.T, file string) []byte { // source or reports an incomplete scan. func TestCompile_MergeChainPastBoundStillCompiles(t *testing.T) { t.Parallel() - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "deep-merge.yaml", Data: []byte(ynode.MergeChainSpec(200))}}, compilers.Options{}) require.NoError(t, err) @@ -682,7 +683,7 @@ func FuzzCycleDetector(f *testing.F) { f.Add([]byte("\t\x00: [")) // undecodable: reported as a parse error, not a fault f.Fuzz(func(t *testing.T, data []byte) { - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "fuzz.yaml", Data: data}}, compilers.Options{}) if err != nil { assert.Nil(t, doc, "a Go error returns no document") @@ -709,7 +710,7 @@ func TestCompile_ReentrantPrefixReportsAlikeInBothDeclarationOrders(t *testing.T for _, tc := range orders { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, diags, err := New().Compile(t.Context(), + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "order.yaml", Data: []byte(head + tc.body)}}, compilers.Options{}) require.NoError(t, err, "a failing reference is a spec problem, not a Go error") diff --git a/compilers/openapi/danglingcheck_test.go b/compilers/openapi/e2e/danglingcheck_test.go similarity index 97% rename from compilers/openapi/danglingcheck_test.go rename to compilers/openapi/e2e/danglingcheck_test.go index d805cfb7..2e9af72f 100644 --- a/compilers/openapi/danglingcheck_test.go +++ b/compilers/openapi/e2e/danglingcheck_test.go @@ -2,7 +2,7 @@ // issue-#14 dangling-ref reproducer corpus through the whole compiler, so it has // no single source file to pair with. This is deliberate, not a leftover from the // test-file consolidation — do not fold it into another file. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "fmt" @@ -25,7 +25,7 @@ import ( // danglingDir holds the issue-#14 reproducers, copied out of triage so the tests // are self-contained. danglingReproducers is the enumeration, and // TestDanglingRefs_EveryReproducerIsExercised holds it to the directory. -const danglingDir = "../../testdata/dangling/openapi" +const danglingDir = "../../../testdata/dangling/openapi" // danglingRefs returns a sorted, human-readable list of every dangling reference // in doc — a TypeRef, discriminator, or value ref whose TypeID is absent from @@ -217,7 +217,7 @@ func TestDanglingRefs_Corpus(t *testing.T) { specs, err := filepath.Glob(filepath.Join(conformanceDir, "*.yaml")) require.NoError(t, err) require.NotEmpty(t, specs) - specs = append(specs, "../../testdata/golden/openapi/petstore.yaml") + specs = append(specs, "../../../testdata/golden/openapi/petstore.yaml") for _, spec := range specs { t.Run(filepath.Base(spec), func(t *testing.T) { t.Parallel() diff --git a/compilers/openapi/defs_test.go b/compilers/openapi/e2e/defs_test.go similarity index 97% rename from compilers/openapi/defs_test.go rename to compilers/openapi/e2e/defs_test.go index f35caae7..78fcb436 100644 --- a/compilers/openapi/defs_test.go +++ b/compilers/openapi/e2e/defs_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "encoding/json/jsontext" @@ -14,15 +14,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// propByWire looks up m's property by wire name. This file is package openapi, -// for the helpers it shares with the other internal tests, so it cannot reach -// conformance_test.go's helper of the same name in package openapi_test; it -// puts openapitest.PropsByWire's map in the same two-value form. -func propByWire(m *ir.Model, wire string) (ir.Property, bool) { - p, ok := openapitest.PropsByWire(m.Properties)[wire] - return p, ok -} - // twoReversedSpec is testdata/openapi/two.yaml (GitHub #557's repro) with A and // B's declaration order swapped. It is not itself committed to the corpus: the // harness's own order-invariance oracle already reverses every committed diff --git a/compilers/openapi/e2e/doc_test.go b/compilers/openapi/e2e/doc_test.go new file mode 100644 index 00000000..d77c8e5c --- /dev/null +++ b/compilers/openapi/e2e/doc_test.go @@ -0,0 +1,9 @@ +// Package e2e_test holds the OpenAPI compiler's end-to-end tests: the +// conformance corpus, the goldens, fuzzing, benchmarks, and scenarios several +// internal packages decide together, each compiling a spec through openapi.New +// and reading what comes out. +// +// It is a package of its own because coverage counts only a package's own +// tests. Nothing here is covered by them, so the code they reach still needs +// the unit tests beside it, and a case here never stands in for one. +package e2e_test diff --git a/compilers/openapi/external_content_test.go b/compilers/openapi/e2e/external_content_test.go similarity index 99% rename from compilers/openapi/external_content_test.go rename to compilers/openapi/e2e/external_content_test.go index 146dc244..e2fc872b 100644 --- a/compilers/openapi/external_content_test.go +++ b/compilers/openapi/e2e/external_content_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it pins how // the lowering reads what references bring in from another document, across // every kind of object the operation walk follows. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "fmt" diff --git a/compilers/openapi/external_nested_test.go b/compilers/openapi/e2e/external_nested_test.go similarity index 98% rename from compilers/openapi/external_nested_test.go rename to compilers/openapi/e2e/external_nested_test.go index 2d8e056f..7a4d17af 100644 --- a/compilers/openapi/external_nested_test.go +++ b/compilers/openapi/e2e/external_nested_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite: it pins how the compile reads the // reference objects another document writes in what a reference brings in // from it (GitHub #751). -package openapi_test +package e2e_test import ( "os" @@ -27,7 +27,7 @@ import ( // passes a.yaml's own parameter reference reads on through it. func TestExternalContent_AReferenceItWritesIsResolvedWhereItIsDeclared(t *testing.T) { t.Parallel() - path := filepath.Join("..", "..", "testdata", "openapi", "nested_refs", "root.yaml") + path := filepath.Join("..", "..", "..", "testdata", "openapi", "nested_refs", "root.yaml") data, err := os.ReadFile(path) require.NoError(t, err) diff --git a/compilers/openapi/external_order_test.go b/compilers/openapi/e2e/external_order_test.go similarity index 98% rename from compilers/openapi/external_order_test.go rename to compilers/openapi/e2e/external_order_test.go index e520d720..fb6d344f 100644 --- a/compilers/openapi/external_order_test.go +++ b/compilers/openapi/e2e/external_order_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it sweeps the // whole committed corpus through the compiler as another document's content, // so it has no single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "encoding/json/v2" diff --git a/compilers/openapi/fuzz_test.go b/compilers/openapi/e2e/fuzz_test.go similarity index 97% rename from compilers/openapi/fuzz_test.go rename to compilers/openapi/e2e/fuzz_test.go index 476937da..60a32a5e 100644 --- a/compilers/openapi/fuzz_test.go +++ b/compilers/openapi/e2e/fuzz_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it fuzzes the // whole compile pipeline (corpus-seeded and mutation-based) rather than any one // source file's logic, so it has no single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "bytes" @@ -25,11 +25,11 @@ import ( // goldenPetstore is the larger real-ish spec seeded alongside the conformance // corpus, addressed relative to this test file. -const goldenPetstore = "../../testdata/golden/openapi/petstore.yaml" +const goldenPetstore = "../../../testdata/golden/openapi/petstore.yaml" // cycleFixtureDir holds the cycle-detector reproducer and pointer-resolution // fixtures, addressed relative to this test file. -const cycleFixtureDir = "../../testdata/openapi" +const cycleFixtureDir = "../../../testdata/openapi" // FuzzCompile hammers the OpenAPI compiler with mutated spec bytes and asserts the // structural oracles (irverify passes, JSON round-trips byte-identical, recompile diff --git a/compilers/openapi/golden_test.go b/compilers/openapi/e2e/golden_test.go similarity index 84% rename from compilers/openapi/golden_test.go rename to compilers/openapi/e2e/golden_test.go index 3f8f3f00..03a80040 100644 --- a/compilers/openapi/golden_test.go +++ b/compilers/openapi/e2e/golden_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it snapshot- // compares the whole compiler's IR output against a byte-exact golden file, so // it has no single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "os" @@ -22,7 +22,7 @@ import ( // `go test ./compilers/openapi -run TestGolden -update`. func TestGolden(t *testing.T) { t.Parallel() - data, err := os.ReadFile("../../testdata/golden/openapi/petstore.yaml") + data, err := os.ReadFile("../../../testdata/golden/openapi/petstore.yaml") require.NoError(t, err) doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "petstore.yaml", Data: data}}, compilers.Options{}) @@ -31,7 +31,7 @@ func TestGolden(t *testing.T) { for _, d := range diags { assert.NotEqual(t, ir.SeverityError, d.Severity, "unexpected error diagnostic: %+v", d) } - irtest.CompareGolden(t, "../../testdata/golden/openapi/petstore.golden.json", doc) + irtest.CompareGolden(t, "../../../testdata/golden/openapi/petstore.golden.json", doc) } // TestGolden_SplitSpec pins the IR of a spec split across four files, read @@ -45,7 +45,7 @@ func TestGolden_SplitSpec(t *testing.T) { t.Parallel() sources := make([]compilers.Source, 0, 4) for _, name := range []string{"root.yaml", "common.yaml", "paths.yaml", "schemas/pet.yaml"} { - data, err := os.ReadFile("../../testdata/golden/openapi/split/" + name) + data, err := os.ReadFile("../../../testdata/golden/openapi/split/" + name) require.NoError(t, err) sources = append(sources, compilers.Source{Path: "split/" + name, Data: data}) } @@ -57,5 +57,5 @@ func TestGolden_SplitSpec(t *testing.T) { for _, d := range diags { assert.NotEqual(t, ir.SeverityError, d.Severity, "unexpected error diagnostic: %+v", d) } - irtest.CompareGolden(t, "../../testdata/golden/openapi/split/split.golden.json", doc) + irtest.CompareGolden(t, "../../../testdata/golden/openapi/split/split.golden.json", doc) } diff --git a/compilers/openapi/e2e/helpers_test.go b/compilers/openapi/e2e/helpers_test.go new file mode 100644 index 00000000..59e25fdd --- /dev/null +++ b/compilers/openapi/e2e/helpers_test.go @@ -0,0 +1,43 @@ +package e2e_test + +import ( + "testing" + + "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" +) + +// parseFull runs the whole public compiler pipeline over src. That reach back +// through openapi is also why openapitest cannot hold it — see that package's +// doc comment. +func parseFull(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { + t.Helper() + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{openapitest.SourceOf(src)}, compilers.Options{}) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, diags +} + +// 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)] +} + +// 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/later_version_test.go b/compilers/openapi/e2e/later_version_test.go similarity index 99% rename from compilers/openapi/later_version_test.go rename to compilers/openapi/e2e/later_version_test.go index 107accff..e37058e8 100644 --- a/compilers/openapi/later_version_test.go +++ b/compilers/openapi/e2e/later_version_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "encoding/json/v2" diff --git a/compilers/openapi/load_test.go b/compilers/openapi/e2e/load_test.go similarity index 94% rename from compilers/openapi/load_test.go rename to compilers/openapi/e2e/load_test.go index b01055ab..96121dbb 100644 --- a/compilers/openapi/load_test.go +++ b/compilers/openapi/e2e/load_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "encoding/json/jsontext" @@ -16,6 +16,7 @@ import ( "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" @@ -34,7 +35,7 @@ const resolverPanicSpec = "openapi: 3.0\ncomponents:\n responses:\n 000: {$ref: // as a spec problem rather than reported as a Go error or crashing the process. func TestCompile_ResolverPanicIsADiagnostic(t *testing.T) { t.Parallel() - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: "resolver-panic.yaml", Data: []byte(resolverPanicSpec)}}, compilers.Options{}) require.NoError(t, err, "a malformed spec is a spec problem, not a Go error") @@ -89,9 +90,9 @@ func TestCompile_AResolutionFailureIsReportedOnceAtItsRef(t *testing.T) { root := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n" + "components:\n schemas:\n S: {$ref: 'other.yaml#/components/schemas/X'}\n" - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: rootPath, Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) require.NotNil(t, doc) @@ -107,9 +108,9 @@ func TestCompile_AResolutionFailureIsReportedOnceAtItsRef(t *testing.T) { root := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n" + "components:\n schemas:\n A: {$ref: 'root.yaml#/x-a'}\nx-a: {$ref: '#/components/schemas/A'}\n" - _, diags, err := New().Compile(t.Context(), + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(t.TempDir(), "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) var at []string @@ -259,9 +260,9 @@ func TestCompile_AReferenceObjectToANonObjectIsReportedOnce(t *testing.T) { root := openapitest.PathsSpec(" /x:\n get:\n parameters: [{$ref: 'mid.yaml#/P'}]\n" + " responses: {\"200\": {description: ok}}\n") - _, diags, err := New().Compile(t.Context(), + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) want := []ir.Diagnostic{{Severity: ir.SeverityError, Code: diag.UnresolvedRef, @@ -311,7 +312,7 @@ func TestCompile_AFindingInASchemaOnlyAnotherDocumentReachesIsPlacedThere(t *tes " parameters: [{$ref: './other.yaml#/components/parameters/P'}]\n" + " responses: {\"200\": {description: ok}}\n" - _, diags, err := New().Compile(t.Context(), + _, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, compilers.Options{Reader: reader.Disk{}}) require.NoError(t, err) @@ -340,9 +341,9 @@ func TestCompile_AFindingInAnExternalDocumentKeepsItsRule(t *testing.T) { root := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + "paths:\n /x: {$ref: 'other.yaml#/paths/~1y'}\n" - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: rootPath, Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) require.NotNil(t, doc) @@ -390,8 +391,8 @@ func TestCompile_OneFileUnderTwoNamesIsOneDocument(t *testing.T) { t.Chdir(dir) compileAt := func(path string) (*ir.Document, []ir.Diagnostic) { t.Helper() - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: path, Data: root}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: root}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) require.NotNil(t, doc) return doc, diags @@ -440,7 +441,7 @@ func TestCompile_ByDefaultOnlyARelativeReferenceIsFollowed(t *testing.T) { compileFrom := func(cwd, path string) (ir.TypeRegistry, []string) { t.Helper() t.Chdir(cwd) - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: path, Data: root}}, + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: root}}, compilers.Options{Reader: reader.Disk{}}) require.NoError(t, err) require.NotNil(t, doc) @@ -490,8 +491,8 @@ func TestCompile_AnUnusableDocumentIsReportedAlikeOnEitherRoute(t *testing.T) { "components:\n schemas:\n S: {$ref: 'u.yaml#/B'}\n T: {$ref: 'u.yaml'}\n") for _, allow := range []bool{false, true} { - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: root}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: allow}}) + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: root}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: allow}}) require.NoError(t, err) require.NotNil(t, doc) @@ -540,8 +541,8 @@ func TestCompile_AURLNoReaderReadsIsRefusedUnread(t *testing.T) { } { t.Chdir(run.cwd) for _, allow := range []bool{false, true} { - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: run.spec, Data: root}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: allow}}) + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: run.spec, Data: root}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: allow}}) require.NoError(t, err) require.NotNil(t, doc) @@ -573,8 +574,8 @@ func TestCompile_ARefAnIDAnswersIsNeverRefusedUnread(t *testing.T) { path := filepath.Join(t.TempDir(), "root.yaml") component := func(name string) ir.TypeID { return ids.Document{}.ForPointer(ids.Ptr("components", "schemas", name)) } for _, allow := range []bool{false, true} { - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: path, Data: root}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: allow}}) + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: root}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: allow}}) require.NoError(t, err) require.NotNil(t, doc) @@ -614,8 +615,8 @@ func TestCompile_ARelativeRefUnderAnIDReadsWhereTheIDPlacesIt(t *testing.T) { " Pet: {$id: 'schemas/pet.json', type: object, properties: {tag: {$ref: 'tag.json#/T'}}}\n" + " S: {$id: '" + srv.URL + "/x/s.json', type: object, properties: {b: {$ref: 'b.yaml#/B'}}}\n") for _, allow := range []bool{false, true} { - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: root}}, - compilers.Options{Reader: reader.Disk{Client: srv.Client()}, FormatOptions: Options{AllowExternalRefs: allow}}) + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: root}}, + compilers.Options{Reader: reader.Disk{Client: srv.Client()}, FormatOptions: openapi.Options{AllowExternalRefs: allow}}) require.NoError(t, err) require.NotNil(t, doc) @@ -671,7 +672,7 @@ func TestCompile_ByDefaultEachReferenceIsJudgedByItsOwnSpelling(t *testing.T) { for _, order := range [][2]string{{"C", "D"}, {"D", "C"}} { types := make([]ir.TypeRegistry, 0, 2) for _, path := range []string{"root.yaml", self} { - doc, diags, err := New().Compile(t.Context(), []compilers.Source{{Path: path, Data: spec(order[0], order[1])}}, + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: spec(order[0], order[1])}}, compilers.Options{Reader: reader.Disk{}}) require.NoError(t, err) require.NotNil(t, doc) diff --git a/compilers/openapi/lowering_spec_checks_test.go b/compilers/openapi/e2e/lowering_spec_checks_test.go similarity index 98% rename from compilers/openapi/lowering_spec_checks_test.go rename to compilers/openapi/e2e/lowering_spec_checks_test.go index c7c1e688..95637e3e 100644 --- a/compilers/openapi/lowering_spec_checks_test.go +++ b/compilers/openapi/e2e/lowering_spec_checks_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "errors" @@ -120,7 +120,7 @@ func TestSpecChecks_ReportEachPlantedDefect(t *testing.T) { {"a test named without its package", replace("`load.TestB`", "`TestB`"), "is not package.TestName"}, {"a test named with a subtest", replace("`load.TestB`", "`load.TestB/sub`"), "is not package.TestName"}, {"a test the package lacks", func(_ *testing.T, _ *string, env *specEnv) { delete(env.funcs["load"], "TestB") }, - `names "load.TestB", which internal/load does not declare`}, + `names "load.TestB", which compilers/openapi/internal/load does not declare`}, {"a package with no tests", func(_ *testing.T, _ *string, env *specEnv) { delete(env.funcs, "load") }, `names "load.TestB": holds no test file`}, {"a citation naming no rule", func(_ *testing.T, _ *string, env *specEnv) { diff --git a/compilers/openapi/lowering_spec_test.go b/compilers/openapi/e2e/lowering_spec_test.go similarity index 94% rename from compilers/openapi/lowering_spec_test.go rename to compilers/openapi/e2e/lowering_spec_test.go index 692d44ad..3b36a2be 100644 --- a/compilers/openapi/lowering_spec_test.go +++ b/compilers/openapi/e2e/lowering_spec_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it reads // docs/compilers/openapi.md, the compiler's specification, and holds each rule // to the evidence it names and each citation of a rule to a rule. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "fmt" @@ -25,7 +25,7 @@ import ( ) // specRoot is the repository root, from this package's directory. -const specRoot = "../.." +const specRoot = "../../.." // specDoc is the specification, relative to specRoot. const specDoc = "docs/compilers/openapi.md" @@ -285,8 +285,8 @@ func declaredDiagCodes(t *testing.T) map[string]bool { // TestLoweringSpec_TestsAreDeclared requires each test a rule names, as // package.TestName, to be a test function the test files of that package -// declare: openapi for the compiler itself, or a package under its internal -// directory. +// declare: openapi for the compiler itself, e2e for this package, or a package +// under the compiler's internal directory. func TestLoweringSpec_TestsAreDeclared(t *testing.T) { t.Parallel() report(t, testProblems(readSpec(t), testFuncs)) @@ -315,20 +315,25 @@ func testProblems(rules []rule, funcsOf func(pkg string) (map[string]bool, error } if declared[pkg] != nil && !declared[pkg][fn] { problems = append(problems, fmt.Sprintf("line %d: %s names %q, which %s does not declare", - r.line, r.key, name, testDir(pkg))) + r.line, r.key, name, packageDir(pkg))) } } } return problems } -// testDir returns the directory of the package a test name's prefix stands -// for, relative to this package. -func testDir(pkg string) string { - if pkg == "openapi" { - return "." +// packageDir returns the directory, from the repository root, of the package +// a test name's prefix stands for: the compiler itself, this package, or one +// under the compiler's internal directory. +func packageDir(pkg string) string { + switch pkg { + case "openapi": + return "compilers/openapi" + case "e2e": + return "compilers/openapi/e2e" + default: + return "compilers/openapi/internal/" + pkg } - return filepath.Join("internal", pkg) } // testFuncs returns the name of every test function the test files of pkg @@ -337,9 +342,9 @@ func testDir(pkg string) string { // test files: a test function is a top-level declaration, so a parse sees it // exactly as the compiler does. func testFuncs(pkg string) (map[string]bool, error) { - paths, err := filepath.Glob(filepath.Join(testDir(pkg), "*_test.go")) + paths, err := filepath.Glob(filepath.Join(specRoot, packageDir(pkg), "*_test.go")) if err != nil || len(paths) == 0 { - return nil, fmt.Errorf("%s holds no test file", testDir(pkg)) + return nil, fmt.Errorf("%s holds no test file", packageDir(pkg)) } out := map[string]bool{} fset := token.NewFileSet() diff --git a/compilers/openapi/main_test.go b/compilers/openapi/e2e/main_test.go similarity index 96% rename from compilers/openapi/main_test.go rename to compilers/openapi/e2e/main_test.go index 4f373fd2..025db244 100644 --- a/compilers/openapi/main_test.go +++ b/compilers/openapi/e2e/main_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "testing" diff --git a/compilers/openapi/mapping_lowered_test.go b/compilers/openapi/e2e/mapping_lowered_test.go similarity index 99% rename from compilers/openapi/mapping_lowered_test.go rename to compilers/openapi/e2e/mapping_lowered_test.go index d4afe28c..5cfdd438 100644 --- a/compilers/openapi/mapping_lowered_test.go +++ b/compilers/openapi/e2e/mapping_lowered_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it holds the // load phase's resolution of mapping targets to what the lowering reads, so it // has no single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "os" diff --git a/compilers/openapi/mapping_spellings_test.go b/compilers/openapi/e2e/mapping_spellings_test.go similarity index 98% rename from compilers/openapi/mapping_spellings_test.go rename to compilers/openapi/e2e/mapping_spellings_test.go index b6f6cc69..adb104c8 100644 --- a/compilers/openapi/mapping_spellings_test.go +++ b/compilers/openapi/e2e/mapping_spellings_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it crosses // every spelling of a discriminator mapping target with every kind of position // it can name, so it has no single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "fmt" diff --git a/compilers/openapi/namespaces_test.go b/compilers/openapi/e2e/namespaces_test.go similarity index 96% rename from compilers/openapi/namespaces_test.go rename to compilers/openapi/e2e/namespaces_test.go index 9749e5c2..0e4a366f 100644 --- a/compilers/openapi/namespaces_test.go +++ b/compilers/openapi/e2e/namespaces_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "os" @@ -26,7 +26,7 @@ import ( // listing them would otherwise look unused. func TestNamespaces_DeclaredAreExactlyWhatTheCorpusUses(t *testing.T) { t.Parallel() - specs, err := filepath.Glob("../../testdata/*/openapi/*.yaml") + specs, err := filepath.Glob("../../../testdata/*/openapi/*.yaml") require.NoError(t, err) require.NotEmpty(t, specs, "the corpus must exist, or this test holds nothing to anything") diff --git a/compilers/openapi/nesting_test.go b/compilers/openapi/e2e/nesting_test.go similarity index 98% rename from compilers/openapi/nesting_test.go rename to compilers/openapi/e2e/nesting_test.go index ab447db7..9b25958b 100644 --- a/compilers/openapi/nesting_test.go +++ b/compilers/openapi/e2e/nesting_test.go @@ -1,4 +1,4 @@ -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "fmt" diff --git a/compilers/openapi/null_schema_test.go b/compilers/openapi/e2e/null_schema_test.go similarity index 99% rename from compilers/openapi/null_schema_test.go rename to compilers/openapi/e2e/null_schema_test.go index a93cc1ea..53ab5317 100644 --- a/compilers/openapi/null_schema_test.go +++ b/compilers/openapi/e2e/null_schema_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "encoding/json/jsontext" diff --git a/compilers/openapi/nullable_widening_test.go b/compilers/openapi/e2e/nullable_widening_test.go similarity index 99% rename from compilers/openapi/nullable_widening_test.go rename to compilers/openapi/e2e/nullable_widening_test.go index bbbf9680..cbb218b4 100644 --- a/compilers/openapi/nullable_widening_test.go +++ b/compilers/openapi/e2e/nullable_widening_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "encoding/json/jsontext" diff --git a/compilers/openapi/overlay_test.go b/compilers/openapi/e2e/overlay_test.go similarity index 99% rename from compilers/openapi/overlay_test.go rename to compilers/openapi/e2e/overlay_test.go index 44c8ebfd..9eb5f956 100644 --- a/compilers/openapi/overlay_test.go +++ b/compilers/openapi/e2e/overlay_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "encoding/json/jsontext" diff --git a/compilers/openapi/ownership_test.go b/compilers/openapi/e2e/ownership_test.go similarity index 99% rename from compilers/openapi/ownership_test.go rename to compilers/openapi/e2e/ownership_test.go index 96f5a14e..eb15dd30 100644 --- a/compilers/openapi/ownership_test.go +++ b/compilers/openapi/e2e/ownership_test.go @@ -2,7 +2,7 @@ // rule that a coordinate is built by the lowering that owns it whichever path // reaches it first (docs/micro-compiler-design.md §6.2) to every shape that // broke it, so it has no single source file to pair with. -package openapi_test +package e2e_test import ( "fmt" diff --git a/compilers/openapi/plus_refs_test.go b/compilers/openapi/e2e/plus_refs_test.go similarity index 97% rename from compilers/openapi/plus_refs_test.go rename to compilers/openapi/e2e/plus_refs_test.go index 2374dd45..4a1adc59 100644 --- a/compilers/openapi/plus_refs_test.go +++ b/compilers/openapi/e2e/plus_refs_test.go @@ -1,4 +1,4 @@ -package openapi +package e2e_test import ( "cmp" @@ -13,6 +13,7 @@ import ( "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" @@ -337,9 +338,9 @@ components: require.NoError(t, os.WriteFile(filepath.Join(dir, "ext.yaml"), []byte(ext), 0o600)) root := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n" + paths + tail - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) require.NotNil(t, doc) @@ -388,9 +389,9 @@ paths: /m: {$ref: '#/paths/~1a+b/x-n'} ` - doc, diags, err := New().Compile(t.Context(), + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: Options{AllowExternalRefs: true}}) + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) require.NoError(t, err) require.NotNil(t, doc) diff --git a/compilers/openapi/e2e/pointer_tokens_test.go b/compilers/openapi/e2e/pointer_tokens_test.go new file mode 100644 index 00000000..57e36021 --- /dev/null +++ b/compilers/openapi/e2e/pointer_tokens_test.go @@ -0,0 +1,69 @@ +package e2e_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" +) + +// TestCompile_APointerTokenPastUFFFFIsRefusedUpstream pins GitHub #516. The +// library caps its reference-token character class at U+FFFF +// (jsonpointer/navigation.go, tokenRegex), though RFC 6901 admits up to +// U+10FFFF, so a reference object through an emoji key is refused and one +// through a Basic Multilingual Plane key resolves. A schema $ref, which the +// compiler binds itself, resolves through either, in either declaration order. +// +// The twins differ only in that key. A refused path item mount is lost. When +// the astral twin mounts like the other, the library is fixed: assert that and +// close #516. +func TestCompile_APointerTokenPastUFFFFIsRefusedUpstream(t *testing.T) { + t.Parallel() + twin := func(key string) string { + return `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + "/` + key + `": + get: {operationId: getKey, responses: {"200": {description: ok}}} + /mount: {$ref: '#/paths/~1` + key + `'} +components: + schemas: + Early: {$ref: '#/components/schemas/Later/properties/` + key + `'} + Later: {type: object, properties: {"` + key + `": {type: object}}} + Holder: {type: object, properties: {"` + key + `": {type: object}}} + Uses: {$ref: '#/components/schemas/Holder/properties/` + key + `'} +` + } + property := func(owner, key string) ir.TypeID { + return ir.TypeID("t/anon/components/schemas/" + owner + "/properties/" + key) + } + + basic, basicDiags := parseFull(t, twin("é")) + openapitest.RequireNoErrorDiags(t, basicDiags) + assert.Len(t, operationsNamed(basic, "getKey"), 2, + "a key inside the Basic Multilingual Plane resolves: the item is mounted at both paths") + assertAliases(t, basic, "Uses", property("Holder", "é"), "a resolved reference aliases its target") + assertAliases(t, basic, "Early", property("Later", "é"), "in either declaration order") + + astral, astralDiags := parseFull(t, twin("😀")) + assert.True(t, openapitest.HasDiagCodeAt(astralDiags, diag.UnresolvedRef, "/paths/~1mount"), + "the resolver still refuses a token past U+FFFF; if it no longer does, see this test's comment") + assert.Len(t, operationsNamed(astral, "getKey"), 1, + "the refused reference mounts nothing, which is what the fix will change") + assertAliases(t, astral, "Uses", property("Holder", "😀"), "a schema $ref resolves past U+FFFF") + assertAliases(t, astral, "Early", property("Later", "😀"), "in either declaration order") +} + +// assertAliases asserts the component schema name lowered to an alias over +// target: what the position's $ref came to stand for. +func assertAliases(t *testing.T, doc *ir.Document, name string, target ir.TypeID, msg string) { + t.Helper() + alias, ok := doc.Types[componentID(name)].(*ir.Scalar) + require.True(t, ok, "%s lowers to an alias over what its $ref names", name) + require.NotNil(t, alias.Base, "the alias names what it stands for") + assert.Equal(t, target, alias.Base.Target, msg) +} diff --git a/compilers/openapi/promotion_carriers_test.go b/compilers/openapi/e2e/promotion_carriers_test.go similarity index 98% rename from compilers/openapi/promotion_carriers_test.go rename to compilers/openapi/e2e/promotion_carriers_test.go index 778e358f..2ac12d48 100644 --- a/compilers/openapi/promotion_carriers_test.go +++ b/compilers/openapi/e2e/promotion_carriers_test.go @@ -3,7 +3,7 @@ // hand-written: a carrier the IR gains, and the compiler starts building without // a PromoteDeprecation call, would be covered by no row and reddens nothing — // the exact shape of the defect Parameter had before GitHub #423. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "go/types" diff --git a/compilers/openapi/promotion_test.go b/compilers/openapi/e2e/promotion_test.go similarity index 99% rename from compilers/openapi/promotion_test.go rename to compilers/openapi/e2e/promotion_test.go index 50f93184..e60f1685 100644 --- a/compilers/openapi/promotion_test.go +++ b/compilers/openapi/e2e/promotion_test.go @@ -2,7 +2,7 @@ // field it is the only OpenAPI spelling for. It belongs to no single source // file — the policy is on the compiler's options, the reading is one function // beneath both walks, and it is applied at every carrier that can record it. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "testing" diff --git a/compilers/openapi/purity_test.go b/compilers/openapi/e2e/purity_test.go similarity index 99% rename from compilers/openapi/purity_test.go rename to compilers/openapi/e2e/purity_test.go index 3c67bc8f..3adc911f 100644 --- a/compilers/openapi/purity_test.go +++ b/compilers/openapi/e2e/purity_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "net/http" diff --git a/compilers/openapi/reference_ends_test.go b/compilers/openapi/e2e/reference_ends_test.go similarity index 99% rename from compilers/openapi/reference_ends_test.go rename to compilers/openapi/e2e/reference_ends_test.go index bfd124ca..9f72790a 100644 --- a/compilers/openapi/reference_ends_test.go +++ b/compilers/openapi/e2e/reference_ends_test.go @@ -1,6 +1,6 @@ // This file pins how the binder reads what a reference object resolved to: // at the node the parser's chain reached, in the source or another document. -package openapi_test +package e2e_test import ( "encoding/json/jsontext" diff --git a/compilers/openapi/refusal_order_test.go b/compilers/openapi/e2e/refusal_order_test.go similarity index 99% rename from compilers/openapi/refusal_order_test.go rename to compilers/openapi/e2e/refusal_order_test.go index 8bec5427..92172b04 100644 --- a/compilers/openapi/refusal_order_test.go +++ b/compilers/openapi/e2e/refusal_order_test.go @@ -1,4 +1,4 @@ -package openapi_test +package e2e_test import ( "fmt" diff --git a/compilers/openapi/reuse_corpus_test.go b/compilers/openapi/e2e/reuse_corpus_test.go similarity index 96% rename from compilers/openapi/reuse_corpus_test.go rename to compilers/openapi/e2e/reuse_corpus_test.go index 0c3fb3c6..540769f7 100644 --- a/compilers/openapi/reuse_corpus_test.go +++ b/compilers/openapi/e2e/reuse_corpus_test.go @@ -1,7 +1,7 @@ // This file is a package-level suite, not a per-source-file test: it sweeps the // whole committed corpus through the compiler twice and compares the results, // so it has no single source file to pair with. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "encoding/json/v2" @@ -77,7 +77,7 @@ func encodeDoc(t *testing.T, doc any) string { } // corpusRoot is every committed spec directory, addressed relative to this file. -const corpusRoot = "../../testdata" +const corpusRoot = "../../../testdata" // corpusSpecs lists every committed spec under testdata, as harness.SpecFiles // lists a directory. diff --git a/compilers/openapi/streaming_test.go b/compilers/openapi/e2e/streaming_test.go similarity index 98% rename from compilers/openapi/streaming_test.go rename to compilers/openapi/e2e/streaming_test.go index cf630736..10c88247 100644 --- a/compilers/openapi/streaming_test.go +++ b/compilers/openapi/e2e/streaming_test.go @@ -2,7 +2,7 @@ // option surface that switches it, which no single source file owns: the policy // lives on the compiler's options, the media types are read where content is // lowered, and the result is written onto the operation core. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "testing" diff --git a/compilers/openapi/testdata/fuzz/FuzzCycleDetector/7d5c7e6c57176224 b/compilers/openapi/e2e/testdata/fuzz/FuzzCycleDetector/7d5c7e6c57176224 similarity index 100% rename from compilers/openapi/testdata/fuzz/FuzzCycleDetector/7d5c7e6c57176224 rename to compilers/openapi/e2e/testdata/fuzz/FuzzCycleDetector/7d5c7e6c57176224 diff --git a/compilers/openapi/testdata/fuzz/FuzzCycleDetector/a897d2fc8d321096 b/compilers/openapi/e2e/testdata/fuzz/FuzzCycleDetector/a897d2fc8d321096 similarity index 100% rename from compilers/openapi/testdata/fuzz/FuzzCycleDetector/a897d2fc8d321096 rename to compilers/openapi/e2e/testdata/fuzz/FuzzCycleDetector/a897d2fc8d321096 diff --git a/compilers/openapi/unknownkeys_test.go b/compilers/openapi/e2e/unknownkeys_test.go similarity index 99% rename from compilers/openapi/unknownkeys_test.go rename to compilers/openapi/e2e/unknownkeys_test.go index 40e71eb1..6f0bac62 100644 --- a/compilers/openapi/unknownkeys_test.go +++ b/compilers/openapi/e2e/unknownkeys_test.go @@ -2,7 +2,7 @@ // reaches the IR rather than vanishing between the parser and the lowering. It // is separate from annotations_test.go because the subject is the complement of // what the model names, not any one annotation. -package openapi_test // external test package — exercises only the public API +package e2e_test // external test package — exercises only the public API import ( "encoding/json/jsontext" @@ -23,7 +23,7 @@ import ( // round-tripped and verified, none of which a spec written inline reaches. func specFile(t *testing.T, name string) string { t.Helper() - data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", name)) + data, err := os.ReadFile(filepath.Join("..", "..", "..", "testdata", "openapi", name)) require.NoError(t, err) return string(data) } diff --git a/compilers/openapi/openapi_test.go b/compilers/openapi/openapi_test.go index 8ff78a92..051d098d 100644 --- a/compilers/openapi/openapi_test.go +++ b/compilers/openapi/openapi_test.go @@ -18,11 +18,14 @@ import ( "github.com/dexpace/morphic/ir" ) -// parsePetstore compiles the shared golden petstore spec (goldenPetstore, in -// fuzz_test.go) through the public pipeline. Reading the on-disk spec rather -// than a hand-copied literal keeps this test coupled to the same document the -// golden snapshot is compared against, so an edit to the fixture cannot leave -// this test asserting against a stale copy. +// goldenPetstore is the golden petstore spec, addressed relative to this +// package. +const goldenPetstore = "../../testdata/golden/openapi/petstore.yaml" + +// parsePetstore compiles the golden petstore spec through the public pipeline. +// Reading the on-disk spec rather than a hand-copied literal keeps this test +// coupled to the same document the golden snapshot is compared against, so an +// edit to the fixture cannot leave this test asserting against a stale copy. func parsePetstore(t *testing.T) (*ir.Document, []ir.Diagnostic) { t.Helper() data, err := os.ReadFile(goldenPetstore) diff --git a/compilers/openapi/options_test.go b/compilers/openapi/options_test.go index 76fe1a19..803e9ecb 100644 --- a/compilers/openapi/options_test.go +++ b/compilers/openapi/options_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" ) @@ -201,3 +202,18 @@ func TestDecodeOptions_FeedsCompile(t *testing.T) { assert.Equal(t, "a", doc.Services[0].Groups[0].Name.Source, "the decoded option must reach the lowering, not merely parse") } + +// TestDefaultStreamingMediaTypes_IsWhatTheLoweringApplies pins the exported +// list to the one a compile classifies by when Options names none, so a caller +// extending it starts from what a compile really uses. +func TestDefaultStreamingMediaTypes_IsWhatTheLoweringApplies(t *testing.T) { + t.Parallel() + assert.Equal(t, lowering.DefaultStreamingMediaTypes(), 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()) +} diff --git a/compilers/openapi/resolve_internal_test.go b/compilers/openapi/resolve_internal_test.go index 76c84e06..c3f5cf3e 100644 --- a/compilers/openapi/resolve_internal_test.go +++ b/compilers/openapi/resolve_internal_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers/openapi/internal/annotation" - "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" @@ -264,60 +263,3 @@ func TestLowerComponentSchemas_PercentEncodedDiscriminatorMapping(t *testing.T) assert.Equal(t, map[string]ir.TypeID{"cat": componentID("Cat-A")}, pet.Discriminator.Mapping, "the encoded mapping target names the declared component, and the entry is kept") } - -// TestCompile_APointerTokenPastUFFFFIsRefusedUpstream pins GitHub #516. The -// library caps its reference-token character class at U+FFFF -// (jsonpointer/navigation.go, tokenRegex), though RFC 6901 admits up to -// U+10FFFF, so a reference object through an emoji key is refused and one -// through a Basic Multilingual Plane key resolves. A schema $ref, which the -// compiler binds itself, resolves through either, in either declaration order. -// -// The twins differ only in that key. A refused path item mount is lost. When -// the astral twin mounts like the other, the library is fixed: assert that and -// close #516. -func TestCompile_APointerTokenPastUFFFFIsRefusedUpstream(t *testing.T) { - t.Parallel() - twin := func(key string) string { - return `openapi: 3.1.0 -info: {title: t, version: "1"} -paths: - "/` + key + `": - get: {operationId: getKey, responses: {"200": {description: ok}}} - /mount: {$ref: '#/paths/~1` + key + `'} -components: - schemas: - Early: {$ref: '#/components/schemas/Later/properties/` + key + `'} - Later: {type: object, properties: {"` + key + `": {type: object}}} - Holder: {type: object, properties: {"` + key + `": {type: object}}} - Uses: {$ref: '#/components/schemas/Holder/properties/` + key + `'} -` - } - property := func(owner, key string) ir.TypeID { - return ir.TypeID("t/anon/components/schemas/" + owner + "/properties/" + key) - } - - basic, basicDiags := parseFull(t, twin("é")) - openapitest.RequireNoErrorDiags(t, basicDiags) - assert.Len(t, operationsNamed(basic, "getKey"), 2, - "a key inside the Basic Multilingual Plane resolves: the item is mounted at both paths") - assertAliases(t, basic, "Uses", property("Holder", "é"), "a resolved reference aliases its target") - assertAliases(t, basic, "Early", property("Later", "é"), "in either declaration order") - - astral, astralDiags := parseFull(t, twin("😀")) - assert.True(t, openapitest.HasDiagCodeAt(astralDiags, diag.UnresolvedRef, "/paths/~1mount"), - "the resolver still refuses a token past U+FFFF; if it no longer does, see this test's comment") - assert.Len(t, operationsNamed(astral, "getKey"), 1, - "the refused reference mounts nothing, which is what the fix will change") - assertAliases(t, astral, "Uses", property("Holder", "😀"), "a schema $ref resolves past U+FFFF") - assertAliases(t, astral, "Early", property("Later", "😀"), "in either declaration order") -} - -// assertAliases asserts the component schema name lowered to an alias over -// target: what the position's $ref came to stand for. -func assertAliases(t *testing.T, doc *ir.Document, name string, target ir.TypeID, msg string) { - t.Helper() - alias, ok := doc.Types[componentID(name)].(*ir.Scalar) - require.True(t, ok, "%s lowers to an alias over what its $ref names", name) - require.NotNil(t, alias.Base, "the alias names what it stands for") - assert.Equal(t, target, alias.Base.Target, msg) -} diff --git a/compilers/openapi/sourcetable_test.go b/compilers/openapi/sourcetable_test.go index 806f8755..e252d653 100644 --- a/compilers/openapi/sourcetable_test.go +++ b/compilers/openapi/sourcetable_test.go @@ -10,13 +10,28 @@ 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/engine/reader" "github.com/dexpace/morphic/ir" ) -// sourceTableSources is the source compileWith compiles, so a case handing it -// to SourceTable asks about the compile compileWith actually ran. -var sourceTableSources = []compilers.Source{{Path: "spec.yaml", Data: []byte(overlaySpec)}} +// sourceTableSpec is the document the cases compile: one component schema, +// Pet, whose properties the overlays target, written last so a case can append +// a schema of its own. +var sourceTableSpec = openapitest.ComponentSpec(" Pet:\n type: object\n properties:\n name: {type: string}\n") + +// tagOverlay adds a property to Pet, so a compile under it applies an action. +const tagOverlay = "overlay: 1.0.0\ninfo: {title: Patch, version: \"1\"}\n" + + "actions:\n - target: $.components.schemas.Pet.properties\n update:\n tag: {type: string}\n" + +// missOverlay targets a path the document does not declare, which only a lax +// overlay lets pass. +const missOverlay = "overlay: 1.0.0\ninfo: {title: Patch, version: \"1\"}\n" + + "actions:\n - target: $.paths['/nope']\n update: {description: x}\n" + +// sourceTableSources is sourceTableSpec as the one source a case hands to both +// Compile and SourceTable. +var sourceTableSources = []compilers.Source{{Path: "spec.yaml", Data: []byte(sourceTableSpec)}} // TestCompiler_SourceTableBeginsDocumentSourcesOnSuccess pins the contract // half that matters most for a caller reading a successful compile: @@ -26,7 +41,7 @@ var sourceTableSources = []compilers.Source{{Path: "spec.yaml", Data: []byte(ove // come only the documents the compile read, here one a reference names. func TestCompiler_SourceTableBeginsDocumentSourcesOnSuccess(t *testing.T) { t.Parallel() - const split = overlaySpec + " Thing: {$ref: 'other.yaml#/Thing'}\n" + split := sourceTableSpec + " Thing: {$ref: 'other.yaml#/Thing'}\n" other := compilers.Source{Path: "other.yaml", Data: []byte("Thing: {type: string}\n")} tests := []struct { name string @@ -34,16 +49,16 @@ func TestCompiler_SourceTableBeginsDocumentSourcesOnSuccess(t *testing.T) { opts openapi.Options read []string }{ - {"no overlay", overlaySpec, openapi.Options{}, nil}, - {"an overlay that applies", overlaySpec, openapi.Options{ - Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(addTagProperty)}, + {"no overlay", sourceTableSpec, openapi.Options{}, nil}, + {"an overlay that applies", sourceTableSpec, openapi.Options{ + Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(tagOverlay)}, }, nil}, - {"a lax overlay that matches nothing", overlaySpec, openapi.Options{ - Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(noMatch), Lax: true}, + {"a lax overlay that matches nothing", sourceTableSpec, openapi.Options{ + Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(missOverlay), Lax: true}, }, nil}, {"a reference into another document", split, openapi.Options{}, []string{"other.yaml"}}, {"an overlay and a reference", split, openapi.Options{ - Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(addTagProperty)}, + Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(tagOverlay)}, }, []string{"other.yaml"}}, } for _, tt := range tests { diff --git a/docs/compilers/openapi.md b/docs/compilers/openapi.md index 384bb834..03d06ef4 100644 --- a/docs/compilers/openapi.md +++ b/docs/compilers/openapi.md @@ -17,7 +17,7 @@ evidence that pins it, one bullet per field: - **Codes** — every diagnostic code it reports. Each is written in the text, where the text says when it fires. - **Tests** — Go tests that pin what no case reaches, as `package.TestName`: `openapi` for the - compiler's own package, an internal package's name otherwise. + compiler's own package, `e2e` for its end-to-end tests, an internal package's name otherwise. - **Status** — what the rule leaves open: where the compiler does not yet do all the rule says, or a question the rule does not settle, each naming the issue that tracks it, or the milestone that schedules work not yet begun. An issue is named here and nowhere in a rule's text. @@ -26,9 +26,9 @@ The fields are the only bullets a rule writes at the margin, since each is read rule's text is numbered. A rule names a case or a test, or says in its Status what it leaves open. The `TestLoweringSpec` -tests in `compilers/openapi` hold every field to the code and read it both ways: each conformance -case is named by a rule, and each code by a rule or by `codesOutsideTheRules`, which gives the -reason a code has no rule. [`CONTRIBUTING.md`](../../CONTRIBUTING.md) walks adding a rule. +tests in `compilers/openapi/e2e` hold every field to the code and read it both ways: each +conformance case is named by a rule, and each code by a rule or by `codesOutsideTheRules`, which +gives the reason a code has no rule. [`CONTRIBUTING.md`](../../CONTRIBUTING.md) walks adding a rule. ## 1. Reading a document @@ -137,10 +137,10 @@ the overlay is applied and every position names the source, as with no overlay, - Codes: `openapi/invalid-overlay`, `openapi/overlay-failed`, `openapi/overlay-action`, `openapi/overlay-origin-incomplete` - Tests: `overlay.TestApply_RejectsAnOverlayItCannotRead`, - `openapi.TestCompile_StrictRefusesAnActionThatMatchesNothing`, + `e2e.TestCompile_StrictRefusesAnActionThatMatchesNothing`, `overlay.TestApply_StrictReportsAnActionThatChangesNothingWithoutRefusing`, `overlay.TestApplyWithin_DegradesToTheSourceAtTheNodeBudget`, - `openapi.TestCompile_OverlayIsRecordedAsASource` + `e2e.TestCompile_OverlayIsRecordedAsASource` - Status: partly built: an Overlay 1.0.0 document's RFC 9535 filter selector fails to apply (#651). Open: whether lax application should report an action that matches nothing (#652), how an update merges a nested array (#653), and whether an overlay that replaces a value the source declares is @@ -218,7 +218,7 @@ gives it no effect is [`allow-reserved-locations`](#allow-reserved-locations)' a `load.TestResolve_AnObjectReachedTwiceIsReportedOnce`, `load.TestResolve_AFileSpelledTwoWaysPlacesAFindingOnce`, `load.TestResolve_AReachedFindingNamesThePositionThatReadIt`, - `openapi.TestCompile_AFindingInAnExternalDocumentKeepsItsRule`, + `e2e.TestCompile_AFindingInAnExternalDocumentKeepsItsRule`, `canary.TestValidationOracle_AgreesWithTheLibrary` - Status: partly built: a document a reference reaches is held to the source's minor until which minor governs it is settled (#839), and a `null` for a Schema Object's keyword whose value is no @@ -292,8 +292,8 @@ judges a Parameter Object's `allowReserved` against where the declared minor app - Cases: `later-version-constructs`, `reached-outside-model`, `schema-ref-siblings-30` - Codes: `openapi/later-version-construct` -- Tests: `openapi.TestLaterVersionConstructs_LowerAsInTheVersionThatAddedThem`, - `openapi.TestSchemaRefSiblings_LowerAsIn31`, +- Tests: `e2e.TestLaterVersionConstructs_LowerAsInTheVersionThatAddedThem`, + `e2e.TestSchemaRefSiblings_LowerAsIn31`, `oas.TestCheck_LaterConstructsAreWarnedOfInTheCompilersWords`, `oas.TestCheck_EveryKeyBesideA30SchemaRefIsALaterConstruct`, `oas.TestCheck_ValuesAddedLaterAreWarnedOf`, @@ -410,49 +410,49 @@ references that closes on itself is [`reference-cycles`](#reference-cycles)'. - Cases: `schema-identifiers` - Codes: `openapi/unresolved-ref`, `openapi/budget-exceeded`, `openapi/degraded-construct` -- Tests: `openapi.TestCompile_ExternalFileRefIsNotReadByDefault`, - `openapi.TestGolden_SplitSpec`, `openapi.TestExternalContent_ARefInItNamesItsOwnDocument`, - `openapi.TestCompile_AResolutionFailureIsReportedOnceAtItsRef`, +- Tests: `e2e.TestCompile_ExternalFileRefIsNotReadByDefault`, + `e2e.TestGolden_SplitSpec`, `e2e.TestExternalContent_ARefInItNamesItsOwnDocument`, + `e2e.TestCompile_AResolutionFailureIsReportedOnceAtItsRef`, `schema.TestMapping_InAnotherDocumentIsReportedWhereItIsWritten`, `lowering.TestInDocument_PlacesWhatAnotherDocumentDeclares`, `lowering.TestDeclared_LowersAReferencedObjectWhereItIsDeclared`, `lowering.TestDeclared_ReadsTheEndOfTheDocumentWritingTheReference`, `bind.TestBind_PassesAReferenceObjectOfAnotherDocument`, - `openapi.TestCompile_ASiblingRefResolvesWithinTheSourcesDirectory`, - `openapi.TestCompile_ARefOutsideTheSourcesDirectoryIsRefused`, - `openapi.TestCompile_ASymlinkOutOfTheSourcesDirectoryIsRefused`, - `openapi.TestCompile_ARefOutsideTheSourcesDirectoryResolvesWhenAllowed`, - `openapi.TestCompile_ByDefaultOnlyARelativeReferenceIsFollowed`, - `openapi.TestCompile_ByDefaultEachReferenceIsJudgedByItsOwnSpelling`, - `openapi.TestCompile_AURLNoReaderReadsIsRefusedUnread`, - `openapi.TestCompile_ARefAnIDAnswersIsNeverRefusedUnread`, - `openapi.TestCompile_ARelativeRefUnderAnIDReadsWhereTheIDPlacesIt`, + `e2e.TestCompile_ASiblingRefResolvesWithinTheSourcesDirectory`, + `e2e.TestCompile_ARefOutsideTheSourcesDirectoryIsRefused`, + `e2e.TestCompile_ASymlinkOutOfTheSourcesDirectoryIsRefused`, + `e2e.TestCompile_ARefOutsideTheSourcesDirectoryResolvesWhenAllowed`, + `e2e.TestCompile_ByDefaultOnlyARelativeReferenceIsFollowed`, + `e2e.TestCompile_ByDefaultEachReferenceIsJudgedByItsOwnSpelling`, + `e2e.TestCompile_AURLNoReaderReadsIsRefusedUnread`, + `e2e.TestCompile_ARefAnIDAnswersIsNeverRefusedUnread`, + `e2e.TestCompile_ARelativeRefUnderAnIDReadsWhereTheIDPlacesIt`, `bind.TestBind_ReadsARelativeURIUnderAnIDWhereTheIDPlacesIt`, `bind.TestBind_RefusesAReadUnderAnIDAsItsSpellingsWould`, - `openapi.TestCompile_OneFileUnderTwoNamesIsOneDocument`, - `openapi.TestExternalContent_ASchemaRefPastARefNamesItsContent`, - `openapi.TestExternalContent_ASourcePositionItNamesByFileResolvesInEitherOrder`, - `openapi.TestCompile_SchemaEmptyPointerSegmentIsUnresolved`, + `e2e.TestCompile_OneFileUnderTwoNamesIsOneDocument`, + `e2e.TestExternalContent_ASchemaRefPastARefNamesItsContent`, + `e2e.TestExternalContent_ASourcePositionItNamesByFileResolvesInEitherOrder`, + `e2e.TestCompile_SchemaEmptyPointerSegmentIsUnresolved`, `load.TestLoad_ARootDefinitionResolvesAsTheResolverReadsIt`, `load.TestLoad_ARootDefinitionAnswersBeforeASchemasOwn`, `bind.TestBind_ReadsEachHop`, `bind.TestBind_ReadsADefinitionByTheDefsRule`, - `openapi.TestCompile_DefsPointerUnderANestedIDReadsTheBaseItNames`, - `openapi.TestExternalContent_ARefLendsItsUseSiteWhatItsTypeReaches`, + `e2e.TestCompile_DefsPointerUnderANestedIDReadsTheBaseItNames`, + `e2e.TestExternalContent_ARefLendsItsUseSiteWhatItsTypeReaches`, `schema.TestSchema_ARefThroughAnEmptyRefLendsNothing`, `bind.TestBind_ReadsEachPositionAsTheJSONItSpells`, `bind.TestBind_ReadsARepeatedKeyAsItsLast`, `bind.TestBind_StopsAtItsBudget`, `bind.TestBind_CountsWorkLinearInTheDocument`, `bind.TestBind_ReadsEveryWebhookAsAPathItem`, `bind.TestBind_PlacesAnEndAtTheNodeItsChainReached`, - `openapi.TestReferenceEnds_AnEndingSlashIsReadWhereTheParserResolvedIt`, - `openapi.TestReferenceEnds_AnEndInTheSourceIsReadThereUnderSelf`, + `e2e.TestReferenceEnds_AnEndingSlashIsReadWhereTheParserResolvedIt`, + `e2e.TestReferenceEnds_AnEndInTheSourceIsReadThereUnderSelf`, `load.TestBindSchemas_PlacesEveryEndAndBindsEveryRefInTheCorpus`, - `openapi.TestReferenceEnds_AnEndTheBinderCannotPlaceSaysWhy`, + `e2e.TestReferenceEnds_AnEndTheBinderCannotPlaceSaysWhy`, `bind.TestTable_SaysWhyARefWentUnread`, `bind.TestBind_SaysAKeyMayLiePastTheMergeBound`, `bind.TestBind_TakesNoDefinitionPastAReadTheMergeBoundStopped`, `canary.TestBindingOracle_AgreesWithTheLibrary`, `load.TestResolve_AFindingNamesTheDocumentItIsIn`, `load.TestResolve_ReachedFindingsDoNotDependOnDeclarationOrder`, - `openapi.TestCompile_AReferenceObjectToANonObjectIsReportedOnce` + `e2e.TestCompile_AReferenceObjectToANonObjectIsReportedOnce` - Status: partly built: an `encoding` entry naming no part of its body is dropped with no record (#604). Another document's identifiers are not read, so a plain name naming one of its resources is reported unresolved (#1049). An absolute-path `$ref` in a remote document is read as a local @@ -481,15 +481,15 @@ declares is recursion, not a cycle ([`recursion`](#recursion)). An alias scan th of its own is reported as an `openapi/cycle-scan-failed` warning, and the compile proceeds. - Codes: `openapi/cyclic-ref`, `openapi/cycle-scan-failed` -- Tests: `openapi.TestCompile_SchemaRefCycleIsReportedAndStillCompiles`, - `openapi.TestCompile_CycleIsReportedAtItsLeastMemberInEitherOrder`, - `openapi.TestCompile_ReentrantPrefixReportsAlikeInBothDeclarationOrders`, - `openapi.TestCompile_AliasCycleIsRefused`, `load.TestLoad_ADegenerateCycleLoadsAndIsReportedOnce`, - `openapi.TestCompile_ALoopThroughAnotherDocumentIsReportedOnce`, +- Tests: `e2e.TestCompile_SchemaRefCycleIsReportedAndStillCompiles`, + `e2e.TestCompile_CycleIsReportedAtItsLeastMemberInEitherOrder`, + `e2e.TestCompile_ReentrantPrefixReportsAlikeInBothDeclarationOrders`, + `e2e.TestCompile_AliasCycleIsRefused`, `load.TestLoad_ADegenerateCycleLoadsAndIsReportedOnce`, + `e2e.TestCompile_ALoopThroughAnotherDocumentIsReportedOnce`, `load.TestBindingDiags_NamesALoopsMembersByTheirDocument`, `bind.TestBind_ReportsALoopOutsideTheSourceAtItsLeastMember`, `bind.TestBind_ReportsALoopOfDefinitionsAtItsLeastMember`, - `openapi.TestCompile_MergeChainPastBoundStillCompiles`, `bind.TestBind_AnswersAlikeInEitherOrder` + `e2e.TestCompile_MergeChainPastBoundStillCompiles`, `bind.TestBind_AnswersAlikeInEitherOrder` - Status: partly built: a cycle of component Reference Objects is reported as an unresolved reference rather than as `openapi/cyclic-ref` (#580), and a reference object on a failing chain is reported only where the walk reaches it first (#767). @@ -735,7 +735,7 @@ a cycle with no schema in it, which [`reference-cycles`](#reference-cycles) repo - Cases: `recursive` - Tests: `schema.TestLower_RecursiveSchemaTerminates`, `scan.TestAliases_LegalRecursionClean`, - `openapi.TestCompile_SchemaRefCycleIsReportedAndStillCompiles` + `e2e.TestCompile_SchemaRefCycleIsReportedAndStillCompiles` - Status: partly built: an `allOf` that names its own schema, or two schemas that are each other's `Base`, lowers to a base chain that returns to where it started with no diagnostic, and a cycle of required properties that admits no finite value is reported by nothing (#642). @@ -754,7 +754,7 @@ is copied to a use site (§12.2). - Cases: `param-ref-inheritance`, `allof-ref-branch-siblings`, `readonly-writeonly`, `schema-ref-siblings-30` -- Tests: `openapi.TestAnnotationRetention`, `schema.TestModel_RefSiblingDescriptionWins`, +- Tests: `e2e.TestAnnotationRetention`, `schema.TestModel_RefSiblingDescriptionWins`, `schema.TestSchemaExamples_ComponentRefSiblingKeepsThem` ### `reference-object-siblings` @@ -795,9 +795,9 @@ the position: a `$ref` leaves lowering a position to the declaration that owns i - Cases: `defaults`, `readonly-writeonly`, `inline-residue` - Codes: `openapi/degraded-construct` -- Tests: `openapi.TestAnnotationRetention_OtherDeclarationShapes`, +- Tests: `e2e.TestAnnotationRetention_OtherDeclarationShapes`, `schema.TestPropertyPosition_ResidueStaysOutOfTheTypeNode`, - `openapi.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` + `e2e.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` ### `docs-and-deprecation` @@ -842,7 +842,7 @@ each property that references it. - Cases: `readonly-writeonly` - Codes: `openapi/disjoint-visibility` - Tests: `schema.TestModel_ReadOnlyAndWriteOnlyTogetherAreVisibleNowhere`, - `openapi.TestAllOfVisibilityMerge_DisjointRestrictionsAreInvisibleNotAConflict`, + `e2e.TestAllOfVisibilityMerge_DisjointRestrictionsAreInvisibleNotAConflict`, `merge.TestFold_AlreadyInvisibleVisibilityIsNotReAnnounced` ### `nullable` @@ -887,9 +887,9 @@ model does not name. - Codes: `openapi/inferred-nullable`, `openapi/unknown-schema-keyword` - Tests: `schema.TestSchemaRef_NullableNormalization`, `schema.TestNullableWidening_EachShapeReadsByItsVersion`, - `openapi.TestNullableWidening_AKeywordIsReportedOnceAtItself`, - `openapi.TestNullableWidening_DisabledKeepsWhatTheDialectDoesNotDefine`, - `openapi.TestNullableWidening_ACompositionKeepsTheMarker`, + `e2e.TestNullableWidening_AKeywordIsReportedOnceAtItself`, + `e2e.TestNullableWidening_DisabledKeepsWhatTheDialectDoesNotDefine`, + `e2e.TestNullableWidening_ACompositionKeepsTheMarker`, `merge.TestIntersectNull_TheMarkerFollowsTheBit` - Status: partly built: a `nullable` its dialect does not define, read with the policy off, is kept nowhere at a position that lowers to a shared node and carries no property or parameter, as no @@ -1205,10 +1205,10 @@ field's first 64 at most; each later one is kept verbatim, unjudged, under an `schema.TestAllOf_ACopyANarrowingKeptIsStillACopy`, `schema.TestAllOf_AListOfAComponentWinsOverAListOfItsCopy`, `schema.TestAllOf_ARefToASiblingFieldIsDeclaredApart`, - `openapi.TestExternalContent_ANodeAnotherDocumentHoistsIsNoCopyOfAnInlineOne`, - `openapi.TestExternalContent_ACopyInAnotherDocumentIsJudgedThere`, + `e2e.TestExternalContent_ANodeAnotherDocumentHoistsIsNoCopyOfAnInlineOne`, + `e2e.TestExternalContent_ACopyInAnotherDocumentIsJudgedThere`, `schema.TestAllOf_ARedeclarationIsJudgedOnceTheComponentsHaveLowered`, - `openapi.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` + `e2e.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` - Status: partly built: two distinct nodes of related kinds (`format: hex` against `format: byte`, two enums whose members fit each other's kind, two different literals), and a list or map whose broader side carries detail the narrower lacks, are reported as an overlap and never narrowed @@ -1460,7 +1460,7 @@ written with structure is [`union-beside-structure`](#union-beside-structure)'s. - Codes: `openapi/degraded-construct`, `openapi/unresolved-ref` - Tests: `schema.TestHomelessDiscriminator_IsKeptBesideEveryOtherNode`, `schema.TestUnplacedDiscriminator_IsKeptAtEveryUnionSite`, - `openapi.TestMappingTargets_ADiscriminatorAReferenceReachesIsBoundWhereItSits` + `e2e.TestMappingTargets_ADiscriminatorAReferenceReachesIsBoundWhereItSits` - Status: partly built: an empty `propertyName` lowers to IR that `irverify` refuses (#882); a mapping to a component that is a `$ref` to a subtype fails validation (#844), a keyword beside a union branch's `$ref` breaks a mapping to its component (#847), and a mapping to a property whose @@ -1521,7 +1521,7 @@ at that position, whichever is lowered first. - Tests: `schema.TestEnum_HeterogeneousBecomesUnionWithBadValue`, `schema.TestEnum_NonScalarAndMidListMismatch`, `schema.TestEnumAsUnion_UnconvertibleMemberBecomesAny`, - `openapi.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` + `e2e.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` - Status: partly built: an `enum` ignores its `format`, though `Enum.ValueType` can carry it (#607); an enum whose members do not match its declared type lowers to a union with only an info diagnostic (#619); and a `Literal` carries no value type or format (#668). Open: whether a @@ -1757,9 +1757,9 @@ needs to produce it. - Cases: `allof-boolean-branch` - Codes: `openapi/degraded-construct`, `openapi/false-schema` - Tests: `schema.TestLower_DepthCapExceeded`, - `openapi.TestMaxNesting_AdmitsWhatTheLoweringLowersInFull`, - `openapi.TestCompile_ARefToABooleanSchemaResolvesInEitherOrder`, - `openapi.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` + `e2e.TestMaxNesting_AdmitsWhatTheLoweringLowersInFull`, + `e2e.TestCompile_ARefToABooleanSchemaResolvesInEitherOrder`, + `e2e.TestCompile_AReferenceLeavesItsTargetToItsOwnerInEitherOrder` ### `null-schema` @@ -1782,7 +1782,7 @@ out draws, but for `items` beside a 3.0 `type: array`, whose absence [`missing-array-items`](#missing-array-items) reports instead. One inside a value the IR keeps verbatim whole, a `dependentSchemas` entry, stays in that value, since nothing reads it as a schema. -- Tests: `openapi.TestCompile_ANullWhereASchemaBelongsIsNoSchema`, +- Tests: `e2e.TestCompile_ANullWhereASchemaBelongsIsNoSchema`, `schema.TestNullSchema_AnEntryIsTheEmptySchemaAndAKeywordIsLeftOut`, `annotation.TestIsNullLiteral_TellsANullFromFalse` @@ -2654,8 +2654,8 @@ kept and reported as a written one is. - Cases: `path-item-anchored-key` - Codes: `openapi/unknown-object-key`, `openapi/unknown-schema-keyword`, `openapi/unknown-key-budget`, `openapi/unknown-key-entry-taken`, `openapi/unknown-key-unreachable` -- Tests: `openapi.TestUnknownKeys_KeptAtEveryObject`, - `openapi.TestUnknownKeys_SchemaAndObjectAreGradedApart`, +- Tests: `e2e.TestUnknownKeys_KeptAtEveryObject`, + `e2e.TestUnknownKeys_SchemaAndObjectAreGradedApart`, `annotation.TestUnknownKeysIn_BudgetBoundsWhatOneObjectContributes`, `annotation.TestUndefinedKeywordsIn_KeepsWhatTheCallerNames` - Status: partly built: a parameter's key and its schema's keyword of one spelling share an entry @@ -2697,7 +2697,7 @@ list out of an extension, not a promotion, so the entry is left for a consumer t - Codes: `openapi/degraded-construct` - Tests: `lowering.TestPromoteDeprecation_ValueThatIsNotTextIsReported`, `lowering.TestPromoteEnumOpenness_WritesNothing`, - `openapi.TestPromotion_RemovalDateAndVersionAreSeparateFacts` + `e2e.TestPromotion_RemovalDateAndVersionAreSeparateFacts` - Status: partly built: the default mapping covers only deprecation and extensible enums (#656); two keys mapped to one field resolve by sort order in silence (#655); a mapping cannot say which extension values state the fact (#654); and an unknown key under a prefix the policy owns is kept From e73d37557395d79456fd7ac280260c00fc08a166 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Fri, 23 Oct 2026 21:46:38 +0300 Subject: [PATCH 02/11] test(compilers/openapi): gather the end-to-end helpers in one file The helpers several end-to-end files share were declared in whichever file needed them first: the petstore path in fuzz_test.go, the IR readers in conformance_test.go, the on-disk compile in confinement_test.go. They now sit in helpers_test.go in reading order, fixture paths first, then compiling, then reading the IR, then reading diagnostics. What stays where it was is not a helper: a conformance case's assertion stays with its family, the corpus's own reader with conformance_test.go, the lowering-spec checker with its tests, and a fixture one construct's files share with that construct. The declarations are byte-identical. --- compilers/openapi/e2e/amplification_test.go | 2 - compilers/openapi/e2e/anchors_test.go | 35 -- compilers/openapi/e2e/annotations_test.go | 23 -- compilers/openapi/e2e/confinement_test.go | 69 ---- compilers/openapi/e2e/conformance_test.go | 138 ------- .../openapi/e2e/conformance_unmodeled_test.go | 19 - compilers/openapi/e2e/cycles_test.go | 7 - compilers/openapi/e2e/danglingcheck_test.go | 8 - compilers/openapi/e2e/fuzz_test.go | 4 - compilers/openapi/e2e/helpers_test.go | 355 ++++++++++++++++++ compilers/openapi/e2e/later_version_test.go | 14 - compilers/openapi/e2e/refusal_order_test.go | 22 -- compilers/openapi/e2e/reuse_corpus_test.go | 11 - 13 files changed, 355 insertions(+), 352 deletions(-) diff --git a/compilers/openapi/e2e/amplification_test.go b/compilers/openapi/e2e/amplification_test.go index 8d11e6b0..f4b19b48 100644 --- a/compilers/openapi/e2e/amplification_test.go +++ b/compilers/openapi/e2e/amplification_test.go @@ -16,8 +16,6 @@ import ( "github.com/dexpace/morphic/ir" ) -const amplificationBombFixture = "../../../testdata/openapi/amplification_alias_bomb.yaml" - func TestCompile_AliasBombDoesNotExhaustMemory(t *testing.T) { t.Parallel() data, err := os.ReadFile(amplificationBombFixture) diff --git a/compilers/openapi/e2e/anchors_test.go b/compilers/openapi/e2e/anchors_test.go index cabc1c26..3f7d363e 100644 --- a/compilers/openapi/e2e/anchors_test.go +++ b/compilers/openapi/e2e/anchors_test.go @@ -193,21 +193,6 @@ func compileReferring(t *testing.T, path, uri string) *ir.Document { return out } -// compileBesideExpectingErrors compiles root, written beside ext, with -// external references allowed, and returns the diagnostics instead of -// requiring none: the twin comparisons above need a clean compile, and a -// refused external document needs the diagnostic itself. -func compileBesideExpectingErrors(t *testing.T, ext, root string) []ir.Diagnostic { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(ext), 0o600)) - _, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) - require.NoError(t, err, "a refused external document is a diagnostic, not a Go error") - return diags -} - // TestCompile_ARecursiveAnchorInAnExternalDocumentIsRefused pins GitHub #536. // A recursive anchor in an external document, on a folded entry or in a // self-referencing schema, would build its model without end. Both are refused @@ -406,23 +391,3 @@ func operationNamed(doc *ir.Document, name string) (ir.Operation, bool) { } return ops[0], true } - -// operationsNamed returns every operation whose source operationId is name, in -// any group of any service, in the order the groups are walked. -func operationsNamed(doc *ir.Document, name string) []ir.Operation { - groups := make([]ir.OperationGroup, 0, len(doc.Services)) - for _, svc := range doc.Services { - groups = append(groups, svc.Groups...) - } - var ops []ir.Operation - for len(groups) > 0 { - g := groups[0] - groups = append(groups[1:], g.Groups...) - for _, op := range g.Operations { - if op.Name.Source == name { - ops = append(ops, op) - } - } - } - return ops -} diff --git a/compilers/openapi/e2e/annotations_test.go b/compilers/openapi/e2e/annotations_test.go index 625ae9a5..2e752518 100644 --- a/compilers/openapi/e2e/annotations_test.go +++ b/compilers/openapi/e2e/annotations_test.go @@ -14,8 +14,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/internal/harness" "github.com/dexpace/morphic/ir" @@ -52,19 +50,6 @@ type retentionCase struct { assertDiags func(*testing.T, []ir.Diagnostic) } -// compileAnnotationSpec compiles one in-memory annotation-retention spec. -// Every spec in this suite is well-formed by construction, so compilation is -// required to succeed with no Go error; the caller checks diagnostics -// separately, against what its own cell expects. -func compileAnnotationSpec(t *testing.T, name, spec string) (*ir.Document, []ir.Diagnostic) { - t.Helper() - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: name + ".yaml", Data: []byte(spec)}}, compilers.Options{}) - require.NoError(t, err) - require.NotNil(t, doc) - return doc, diags -} - func TestAnnotationRetention(t *testing.T) { t.Parallel() for _, tc := range retentionCases() { @@ -1022,14 +1007,6 @@ func paramUnmodeled(t *testing.T, op ir.Operation) ir.Unmodeled { return op.Params[0].Unmodeled } -// requestContent returns an operation's single request-body content. -func requestContent(t *testing.T, op ir.Operation) ir.Content { - t.Helper() - require.NotNil(t, op.Request) - require.Len(t, op.Request.Contents, 1) - return op.Request.Contents[0] -} - // refSiteAlias returns the node a body position hoisted to hold what was // written beside its $ref, requiring it to be an alias of the referent rather // than the referent itself — which is the difference between keeping the diff --git a/compilers/openapi/e2e/confinement_test.go b/compilers/openapi/e2e/confinement_test.go index 208451d7..8bb8c0b8 100644 --- a/compilers/openapi/e2e/confinement_test.go +++ b/compilers/openapi/e2e/confinement_test.go @@ -4,14 +4,12 @@ package e2e_test // external test package — exercises only the public API import ( - "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" - "sync" "sync/atomic" "testing" @@ -21,44 +19,10 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi" - "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/engine/reader" "github.com/dexpace/morphic/ir" ) -// recordingReader reads through inner and records each request it was handed. -type recordingReader struct { - inner compilers.DocumentReader - - mu sync.Mutex - reqs []compilers.DocumentRequest -} - -// ReadDocument records req, then answers it as inner does. -func (r *recordingReader) ReadDocument(ctx context.Context, req compilers.DocumentRequest) ([]byte, error) { - r.mu.Lock() - r.reqs = append(r.reqs, req) - r.mu.Unlock() - return r.inner.ReadDocument(ctx, req) -} - -// requests returns the requests recorded so far, in order. -func (r *recordingReader) requests() []compilers.DocumentRequest { - r.mu.Lock() - defer r.mu.Unlock() - return append([]compilers.DocumentRequest(nil), r.reqs...) -} - -// locations returns the document each recorded request named, in order. -func (r *recordingReader) locations() []string { - reqs := r.requests() - out := make([]string, 0, len(reqs)) - for _, req := range reqs { - out = append(out, req.Location) - } - return out -} - // otherDoc holds the one response and parameter a confinement test's $refs // name, each told apart from anything the source declares by its description. const otherDoc = `components: @@ -83,39 +47,6 @@ paths: ` } -// layout writes files, named by their slash-separated path under root, creating -// the directories they sit in. -func layout(t *testing.T, root string, files map[string]string) { - t.Helper() - for name, content := range files { - path := filepath.Join(root, filepath.FromSlash(name)) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - } -} - -// compileOnDisk compiles source, read from path, through reader.Disk with -// external references allowed or not. -func compileOnDisk(t *testing.T, path, source string, allow bool) (*ir.Document, []ir.Diagnostic) { - t.Helper() - doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: []byte(source)}}, - compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: allow}}) - require.NoError(t, err) - require.NotNil(t, doc) - return doc, diags -} - -// unresolvedRefs returns the unresolved-ref findings among diags. -func unresolvedRefs(diags []ir.Diagnostic) []ir.Diagnostic { - var out []ir.Diagnostic - for _, d := range diags { - if d.Code == diag.UnresolvedRef { - out = append(out, d) - } - } - return out -} - // requireReadFrom requires that GET /a took its parameter and its response // from the other document, and reported no unresolved reference. func requireReadFrom(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { diff --git a/compilers/openapi/e2e/conformance_test.go b/compilers/openapi/e2e/conformance_test.go index 30ee54f1..92eb9014 100644 --- a/compilers/openapi/e2e/conformance_test.go +++ b/compilers/openapi/e2e/conformance_test.go @@ -10,7 +10,6 @@ import ( "maps" "os" "path/filepath" - "reflect" "slices" "strings" "testing" @@ -311,12 +310,6 @@ func invalidCases() map[string][]string { } } -// assertNoErrorDiags fails when any diagnostic has error severity. -func assertNoErrorDiags(t *testing.T, diags []ir.Diagnostic) { - t.Helper() - assertOnlyErrorDiags(t, diags, nil) -} - // 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) { @@ -334,58 +327,6 @@ func assertOnlyErrorDiags(t *testing.T, diags []ir.Diagnostic, want []string) { } } -// namedID is the stable TypeID of a components-named schema. -func namedID(name string) ir.TypeID { - return ir.TypeID("t/openapi/components/schemas/" + name) -} - -// allOperations flattens every operation across a document's service groups. -func allOperations(doc *ir.Document) []ir.Operation { - var out []ir.Operation - var walk func(gs []ir.OperationGroup) - walk = func(gs []ir.OperationGroup) { - for _, g := range gs { - out = append(out, g.Operations...) - walk(g.Groups) - } - } - for _, svc := range doc.Services { - walk(svc.Groups) - } - return out -} - -// opByName finds an operation by its source operationId. -func opByName(doc *ir.Document, source string) (ir.Operation, bool) { - for _, op := range allOperations(doc) { - if op.Name.Source == source { - return op, true - } - } - return ir.Operation{}, 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 { - if p.WireName == wire { - return p, true - } - } - return ir.Property{}, false -} - -// 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 -} - func assertNamedTypes(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { user, ok := doc.Types[namedID("User")].(*ir.Model) require.True(t, ok, "named schema User present under its pointer-derived ID") @@ -761,23 +702,6 @@ func assertComponentReuse(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { assert.True(t, ok, "the shared callback's operation is registered alongside its parent") } -// operationAt finds the operation bound to one HTTP method and URI template. -// Operations reached through a shared path item have no distinguishing name of -// their own, so their binding is what tells them apart. A webhook binding has -// no URI template, so no webhook is found. -func operationAt(t *testing.T, doc *ir.Document, method, uri string) ir.Operation { - t.Helper() - for _, op := range allOperations(doc) { - for _, hb := range op.Bindings.HTTP { - if hb.Method == method && hb.URITemplate == uri { - return op - } - } - } - t.Fatalf("no operation bound to %s %s", method, uri) - return ir.Operation{} -} - func assertAllOfInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { d, ok := doc.Types[namedID("Derived")].(*ir.Model) require.True(t, ok) @@ -3019,16 +2943,6 @@ func assertHeaderStyleAndExplodeKept(t *testing.T, headers []ir.Property) { "a header that declares neither keyword records neither") } -// headerByWire returns the response header with the given wire name. -func headerByWire(headers []ir.Property, wire string) (ir.Property, bool) { - for _, h := range headers { - if h.WireName == wire { - return h, true - } - } - return ir.Property{}, false -} - func assertMultiContent(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { op, ok := opByName(doc, "getData") require.True(t, ok) @@ -3261,15 +3175,6 @@ func assertSequentialMedia(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { } } -// firstContent returns an operation's single success-response content. -func firstContent(t *testing.T, op ir.Operation) ir.Content { - t.Helper() - require.Len(t, op.Responses, 1) - require.NotNil(t, op.Responses[0].Payload) - require.Len(t, op.Responses[0].Payload.Contents, 1) - return op.Responses[0].Payload.Contents[0] -} - func assertPerStatusErrors(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { op, ok := opByName(doc, "getWidgets") require.True(t, ok) @@ -4232,36 +4137,6 @@ func assertEveryObjectKeepsItsExtensions(t *testing.T, doc *ir.Document) { } } -// unmodeledSite is one Unmodeled entry paired with the walk path of the map -// holding it. -type unmodeledSite struct { - key string - path string - entry ir.UnmodeledEntry -} - -// unmodeledSites returns every Unmodeled entry the document holds. It walks the -// value graph rather than naming carriers so that a test asserting "this is kept -// somewhere sensible" cannot pass by looking only where it expected to. -func unmodeledSites(doc *ir.Document) []unmodeledSite { - unmodeledType := reflect.TypeFor[ir.Unmodeled]() - var out []unmodeledSite - ir.WalkValues(doc, ir.DocumentPath, func(v reflect.Value, path string) bool { - if v.Type() != unmodeledType || !v.CanInterface() { - return true - } - u, ok := reflect.TypeAssert[ir.Unmodeled](v) - if !ok { - return true - } - for key, entry := range u { - out = append(out, unmodeledSite{key: key, path: path, entry: entry}) - } - return true - }) - return out -} - // carrierNarrows reports whether carrier picks out fewer than all of the // document's Unmodeled maps. A carrier that matches every one of them makes the // assertion beside it unconditionally true, which is how a row can name the @@ -4281,19 +4156,6 @@ func carrierNarrows(sites []unmodeledSite, carrier string) bool { return len(matched) < len(all) } -// findUnmodeled returns the site holding key with the given JSON value. The -// value is part of the match because one key spelling occurs at many carriers — -// "openapi:x-mark" is written on a dozen objects in this fixture — so matching -// on the key alone would find a different object's entry and call it a pass. -func findUnmodeled(sites []unmodeledSite, key, wantJSON string) (unmodeledSite, bool) { - for _, site := range sites { - if site.key == key && string(site.entry.Value) == wantJSON { - return site, true - } - } - return unmodeledSite{}, false -} - // assertRawPreservedBinary pins what a !!binary extension keeps: the base64 the // source wrote. Decoding it and storing the bytes in a JSON string lost the // spelling on every value and lost the data itself on any byte that is not diff --git a/compilers/openapi/e2e/conformance_unmodeled_test.go b/compilers/openapi/e2e/conformance_unmodeled_test.go index 03c3bb33..3605b331 100644 --- a/compilers/openapi/e2e/conformance_unmodeled_test.go +++ b/compilers/openapi/e2e/conformance_unmodeled_test.go @@ -21,25 +21,6 @@ import ( "github.com/dexpace/morphic/ir" ) -// unmodeledEntry returns the Unmodeled entry at key, failing when absent. -func unmodeledEntry(t *testing.T, p ir.Unmodeled, key string) ir.UnmodeledEntry { - t.Helper() - entry, ok := p[key] - require.True(t, ok, "%s kept verbatim; got %v", key, p) - return entry -} - -// diagsAt returns the severities of the diagnostics coded code at pointer. -func diagsAt(diags []ir.Diagnostic, code, pointer string) []ir.Severity { - var out []ir.Severity - for _, d := range diags { - if d.Code == code && d.Provenance.Pointer == jsontext.Pointer(pointer) { - out = append(out, d.Severity) - } - } - return out -} - // assertUnhomedKeywords pins the keywords a position writes that the node its // lowering produced has nowhere to carry (GitHub #148, #151). // diff --git a/compilers/openapi/e2e/cycles_test.go b/compilers/openapi/e2e/cycles_test.go index 8e2dc28a..07ca63e3 100644 --- a/compilers/openapi/e2e/cycles_test.go +++ b/compilers/openapi/e2e/cycles_test.go @@ -636,13 +636,6 @@ components: // the fuzzer its seeds in silence. const reproducerDir = "../../../testdata/openapi" -func readReproducer(t *testing.T, file string) []byte { - t.Helper() - data, err := os.ReadFile(filepath.Join(reproducerDir, file+".yaml")) - require.NoError(t, err) - return data -} - // TestCompile_MergeChainPastBoundStillCompiles pins that a legal document whose // `<<` merge chain runs past the mapping view's depth bound is compiled. The // alias scan weighs aliases rather than expanding merges through that view, so diff --git a/compilers/openapi/e2e/danglingcheck_test.go b/compilers/openapi/e2e/danglingcheck_test.go index 2e9af72f..950649a4 100644 --- a/compilers/openapi/e2e/danglingcheck_test.go +++ b/compilers/openapi/e2e/danglingcheck_test.go @@ -59,14 +59,6 @@ func compileFile(t *testing.T, dir, file, srcPath string) (*ir.Document, []ir.Di return doc, diags } -// hasErrorRef reports whether the diagnostics carry an error-severity -// unresolved-ref finding — the signal that an offending entry was dropped. -func hasErrorRef(diags []ir.Diagnostic) bool { - return slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { - return d.Severity == ir.SeverityError && d.Code == "openapi/unresolved-ref" - }) -} - // outcome classifies how a reproducer must be made referentially sound. type outcome int diff --git a/compilers/openapi/e2e/fuzz_test.go b/compilers/openapi/e2e/fuzz_test.go index 60a32a5e..9b901bd2 100644 --- a/compilers/openapi/e2e/fuzz_test.go +++ b/compilers/openapi/e2e/fuzz_test.go @@ -23,10 +23,6 @@ import ( "github.com/dexpace/morphic/ir/irverify" ) -// goldenPetstore is the larger real-ish spec seeded alongside the conformance -// corpus, addressed relative to this test file. -const goldenPetstore = "../../../testdata/golden/openapi/petstore.yaml" - // cycleFixtureDir holds the cycle-detector reproducer and pointer-resolution // fixtures, addressed relative to this test file. const cycleFixtureDir = "../../../testdata/openapi" diff --git a/compilers/openapi/e2e/helpers_test.go b/compilers/openapi/e2e/helpers_test.go index 59e25fdd..4688eca6 100644 --- a/compilers/openapi/e2e/helpers_test.go +++ b/compilers/openapi/e2e/helpers_test.go @@ -1,16 +1,34 @@ package e2e_test import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "os" + "path/filepath" + "reflect" + "slices" + "sync" "testing" "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" "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/internal/harness" "github.com/dexpace/morphic/ir" ) +// goldenPetstore is the larger real-ish spec seeded alongside the conformance +// corpus, addressed relative to this test file. +const goldenPetstore = "../../../testdata/golden/openapi/petstore.yaml" + +const amplificationBombFixture = "../../../testdata/openapi/amplification_alias_bomb.yaml" + // parseFull runs the whole public compiler pipeline over src. That reach back // through openapi is also why openapitest cannot hold it — see that package's // doc comment. @@ -23,6 +41,127 @@ func parseFull(t *testing.T, src string) (*ir.Document, []ir.Diagnostic) { return doc, diags } +// compileAnnotationSpec compiles one in-memory annotation-retention spec. +// Every spec in this suite is well-formed by construction, so compilation is +// required to succeed with no Go error; the caller checks diagnostics +// separately, against what its own cell expects. +func compileAnnotationSpec(t *testing.T, name, spec string) (*ir.Document, []ir.Diagnostic) { + t.Helper() + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: name + ".yaml", Data: []byte(spec)}}, compilers.Options{}) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, diags +} + +// compileBesideExpectingErrors compiles root, written beside ext, with +// external references allowed, and returns the diagnostics instead of +// requiring none: the twin comparisons above need a clean compile, and a +// refused external document needs the diagnostic itself. +func compileBesideExpectingErrors(t *testing.T, ext, root string) []ir.Diagnostic { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "other.yaml"), []byte(ext), 0o600)) + _, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: filepath.Join(dir, "root.yaml"), Data: []byte(root)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: true}}) + require.NoError(t, err, "a refused external document is a diagnostic, not a Go error") + return diags +} + +// compileOnDisk compiles source, read from path, through reader.Disk with +// external references allowed or not. +func compileOnDisk(t *testing.T, path, source string, allow bool) (*ir.Document, []ir.Diagnostic) { + t.Helper() + doc, diags, err := openapi.New().Compile(t.Context(), []compilers.Source{{Path: path, Data: []byte(source)}}, + compilers.Options{Reader: reader.Disk{}, FormatOptions: openapi.Options{AllowExternalRefs: allow}}) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, diags +} + +// layout writes files, named by their slash-separated path under root, creating +// the directories they sit in. +func layout(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, content := range files { + path := filepath.Join(root, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + } +} + +// recordingReader reads through inner and records each request it was handed. +type recordingReader struct { + inner compilers.DocumentReader + + mu sync.Mutex + reqs []compilers.DocumentRequest +} + +// ReadDocument records req, then answers it as inner does. +func (r *recordingReader) ReadDocument(ctx context.Context, req compilers.DocumentRequest) ([]byte, error) { + r.mu.Lock() + r.reqs = append(r.reqs, req) + r.mu.Unlock() + return r.inner.ReadDocument(ctx, req) +} + +// requests returns the requests recorded so far, in order. +func (r *recordingReader) requests() []compilers.DocumentRequest { + r.mu.Lock() + defer r.mu.Unlock() + return append([]compilers.DocumentRequest(nil), r.reqs...) +} + +// locations returns the document each recorded request named, in order. +func (r *recordingReader) locations() []string { + reqs := r.requests() + out := make([]string, 0, len(reqs)) + for _, req := range reqs { + out = append(out, req.Location) + } + return out +} + +func readReproducer(t *testing.T, file string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join(reproducerDir, file+".yaml")) + require.NoError(t, err) + return data +} + +// reversed is src with every mapping's entries in reverse order. +func reversed(t *testing.T, src string) []byte { + t.Helper() + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &root)) + stack := []*yaml.Node{&root} + for len(stack) > 0 { + n := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if n.Kind == yaml.MappingNode { + pairs := slices.Collect(slices.Chunk(n.Content, 2)) + slices.Reverse(pairs) + n.Content = slices.Concat(pairs...) + } + stack = append(stack, n.Content...) + } + out, err := yaml.Marshal(&root) + require.NoError(t, err) + return out +} + +// corpusSpecs lists every committed spec under testdata, as harness.SpecFiles +// lists a directory. +func corpusSpecs(t *testing.T) []string { + t.Helper() + out, err := harness.SpecFiles(t.Context(), corpusRoot) + require.NoError(t, err) + require.NotEmpty(t, out, "corpus sweep found no specs") + 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. @@ -35,9 +174,225 @@ 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 { + return ir.TypeID("t/openapi/components/schemas/" + name) +} + +// allOperations flattens every operation across a document's service groups. +func allOperations(doc *ir.Document) []ir.Operation { + var out []ir.Operation + var walk func(gs []ir.OperationGroup) + walk = func(gs []ir.OperationGroup) { + for _, g := range gs { + out = append(out, g.Operations...) + walk(g.Groups) + } + } + for _, svc := range doc.Services { + walk(svc.Groups) + } + return out +} + +// opByName finds an operation by its source operationId. +func opByName(doc *ir.Document, source string) (ir.Operation, bool) { + for _, op := range allOperations(doc) { + if op.Name.Source == source { + return op, true + } + } + return ir.Operation{}, false +} + +// operationAt finds the operation bound to one HTTP method and URI template. +// Operations reached through a shared path item have no distinguishing name of +// their own, so their binding is what tells them apart. A webhook binding has +// no URI template, so no webhook is found. +func operationAt(t *testing.T, doc *ir.Document, method, uri string) ir.Operation { + t.Helper() + for _, op := range allOperations(doc) { + for _, hb := range op.Bindings.HTTP { + if hb.Method == method && hb.URITemplate == uri { + return op + } + } + } + t.Fatalf("no operation bound to %s %s", method, uri) + return ir.Operation{} +} + +// operationsNamed returns every operation whose source operationId is name, in +// any group of any service, in the order the groups are walked. +func operationsNamed(doc *ir.Document, name string) []ir.Operation { + groups := make([]ir.OperationGroup, 0, len(doc.Services)) + for _, svc := range doc.Services { + groups = append(groups, svc.Groups...) + } + var ops []ir.Operation + for len(groups) > 0 { + g := groups[0] + groups = append(groups[1:], g.Groups...) + for _, op := range g.Operations { + if op.Name.Source == name { + ops = append(ops, op) + } + } + } + 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 { + if p.WireName == wire { + return p, true + } + } + return ir.Property{}, false +} + +// headerByWire returns the response header with the given wire name. +func headerByWire(headers []ir.Property, wire string) (ir.Property, bool) { + for _, h := range headers { + if h.WireName == wire { + return h, true + } + } + return ir.Property{}, false +} + +// firstContent returns an operation's single success-response content. +func firstContent(t *testing.T, op ir.Operation) ir.Content { + t.Helper() + require.Len(t, op.Responses, 1) + require.NotNil(t, op.Responses[0].Payload) + require.Len(t, op.Responses[0].Payload.Contents, 1) + return op.Responses[0].Payload.Contents[0] +} + +// requestContent returns an operation's single request-body content. +func requestContent(t *testing.T, op ir.Operation) ir.Content { + t.Helper() + require.NotNil(t, op.Request) + require.Len(t, op.Request.Contents, 1) + return op.Request.Contents[0] +} + +// withoutVersion encodes doc with what records the declared version set aside: +// its source entries, which name the format and hash the bytes, and its +// diagnostics, which the test compares apart. +func withoutVersion(t *testing.T, doc *ir.Document) string { + t.Helper() + stripped := *doc + stripped.Sources = nil + stripped.Diagnostics = nil + out, err := json.Marshal(&stripped, json.Deterministic(true)) + require.NoError(t, err) + return string(out) +} + +// unmodeledSite is one Unmodeled entry paired with the walk path of the map +// holding it. +type unmodeledSite struct { + key string + path string + entry ir.UnmodeledEntry +} + +// unmodeledSites returns every Unmodeled entry the document holds. It walks the +// value graph rather than naming carriers so that a test asserting "this is kept +// somewhere sensible" cannot pass by looking only where it expected to. +func unmodeledSites(doc *ir.Document) []unmodeledSite { + unmodeledType := reflect.TypeFor[ir.Unmodeled]() + var out []unmodeledSite + ir.WalkValues(doc, ir.DocumentPath, func(v reflect.Value, path string) bool { + if v.Type() != unmodeledType || !v.CanInterface() { + return true + } + u, ok := reflect.TypeAssert[ir.Unmodeled](v) + if !ok { + return true + } + for key, entry := range u { + out = append(out, unmodeledSite{key: key, path: path, entry: entry}) + } + return true + }) + return out +} + +// findUnmodeled returns the site holding key with the given JSON value. The +// value is part of the match because one key spelling occurs at many carriers — +// "openapi:x-mark" is written on a dozen objects in this fixture — so matching +// on the key alone would find a different object's entry and call it a pass. +func findUnmodeled(sites []unmodeledSite, key, wantJSON string) (unmodeledSite, bool) { + for _, site := range sites { + if site.key == key && string(site.entry.Value) == wantJSON { + return site, true + } + } + return unmodeledSite{}, false +} + +// unmodeledEntry returns the Unmodeled entry at key, failing when absent. +func unmodeledEntry(t *testing.T, p ir.Unmodeled, key string) ir.UnmodeledEntry { + t.Helper() + entry, ok := p[key] + require.True(t, ok, "%s kept verbatim; got %v", key, p) + 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() + assertOnlyErrorDiags(t, diags, nil) +} + +// diagsAt returns the severities of the diagnostics coded code at pointer. +func diagsAt(diags []ir.Diagnostic, code, pointer string) []ir.Severity { + var out []ir.Severity + for _, d := range diags { + if d.Code == code && d.Provenance.Pointer == jsontext.Pointer(pointer) { + out = append(out, d.Severity) + } + } + return out +} + +// hasErrorRef reports whether the diagnostics carry an error-severity +// unresolved-ref finding — the signal that an offending entry was dropped. +func hasErrorRef(diags []ir.Diagnostic) bool { + return slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { + return d.Severity == ir.SeverityError && d.Code == "openapi/unresolved-ref" + }) +} + +// unresolvedRefs returns the unresolved-ref findings among diags. +func unresolvedRefs(diags []ir.Diagnostic) []ir.Diagnostic { + var out []ir.Diagnostic + for _, d := range diags { + if d.Code == diag.UnresolvedRef { + out = append(out, d) + } + } + return out +} diff --git a/compilers/openapi/e2e/later_version_test.go b/compilers/openapi/e2e/later_version_test.go index e37058e8..f60eec75 100644 --- a/compilers/openapi/e2e/later_version_test.go +++ b/compilers/openapi/e2e/later_version_test.go @@ -1,7 +1,6 @@ package e2e_test import ( - "encoding/json/v2" "fmt" "os" "path/filepath" @@ -108,16 +107,3 @@ func TestLaterVersionConstructs_LowerAsInTheVersionThatAddedThem(t *testing.T) { assert.Subset(t, later30, later31, "what 3.2 added is owed to 3.0 as to 3.1") assert.Greater(t, len(later30), len(later31), "and what 3.1 added to 3.0 alone") } - -// withoutVersion encodes doc with what records the declared version set aside: -// its source entries, which name the format and hash the bytes, and its -// diagnostics, which the test compares apart. -func withoutVersion(t *testing.T, doc *ir.Document) string { - t.Helper() - stripped := *doc - stripped.Sources = nil - stripped.Diagnostics = nil - out, err := json.Marshal(&stripped, json.Deterministic(true)) - require.NoError(t, err) - return string(out) -} diff --git a/compilers/openapi/e2e/refusal_order_test.go b/compilers/openapi/e2e/refusal_order_test.go index 92172b04..019f17e2 100644 --- a/compilers/openapi/e2e/refusal_order_test.go +++ b/compilers/openapi/e2e/refusal_order_test.go @@ -9,7 +9,6 @@ import ( "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" @@ -71,27 +70,6 @@ func refGraphDoc(r *rand.Rand) string { return b.String() } -// reversed is src with every mapping's entries in reverse order. -func reversed(t *testing.T, src string) []byte { - t.Helper() - var root yaml.Node - require.NoError(t, yaml.Unmarshal([]byte(src), &root)) - stack := []*yaml.Node{&root} - for len(stack) > 0 { - n := stack[len(stack)-1] - stack = stack[:len(stack)-1] - if n.Kind == yaml.MappingNode { - pairs := slices.Collect(slices.Chunk(n.Content, 2)) - slices.Reverse(pairs) - n.Content = slices.Concat(pairs...) - } - stack = append(stack, n.Content...) - } - out, err := yaml.Marshal(&root) - require.NoError(t, err) - return out -} - // cycleRefusals compiles data and returns its cycle refusals and cycle-scan // warnings, each as its code, pointer and message, sorted. func cycleRefusals(t *testing.T, data []byte) []string { diff --git a/compilers/openapi/e2e/reuse_corpus_test.go b/compilers/openapi/e2e/reuse_corpus_test.go index 540769f7..3ed594e5 100644 --- a/compilers/openapi/e2e/reuse_corpus_test.go +++ b/compilers/openapi/e2e/reuse_corpus_test.go @@ -15,7 +15,6 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi" - "github.com/dexpace/morphic/internal/harness" ) // TestReuse_MatchesAFreshParseAcrossTheCorpus sweeps every committed spec @@ -78,13 +77,3 @@ func encodeDoc(t *testing.T, doc any) string { // corpusRoot is every committed spec directory, addressed relative to this file. const corpusRoot = "../../../testdata" - -// corpusSpecs lists every committed spec under testdata, as harness.SpecFiles -// lists a directory. -func corpusSpecs(t *testing.T) []string { - t.Helper() - out, err := harness.SpecFiles(t.Context(), corpusRoot) - require.NoError(t, err) - require.NotEmpty(t, out, "corpus sweep found no specs") - return out -} From 085f4edf0c2096aa68a644f61c7c4593a3d298e8 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Fri, 23 Oct 2026 21:49:10 +0300 Subject: [PATCH 03/11] test(compilers/openapi): split the conformance assertions by family conformance_test.go was 4,659 lines: the corpus runner, its table and 138 assertions. Adding or finding a case meant scrolling past every other construct's. The runner, the table and the corpus's own reader stay in conformance_test.go. Each case's assertion moves to the family file of its construct (conformance_types_test.go, conformance_composition_test.go, conformance_params_test.go and so on), with every helper only that family uses, beside the conformance_unmodeled_test.go that already grouped the Unmodeled assertions. The table keeps its order and the declarations are byte-identical. --- .../e2e/conformance_composition_test.go | 551 +++ .../openapi/e2e/conformance_content_test.go | 282 ++ .../openapi/e2e/conformance_docs_test.go | 306 ++ .../e2e/conformance_extensions_test.go | 179 + .../e2e/conformance_nullability_test.go | 197 + .../e2e/conformance_operations_test.go | 173 + .../openapi/e2e/conformance_params_test.go | 555 +++ .../openapi/e2e/conformance_pathitems_test.go | 310 ++ .../openapi/e2e/conformance_responses_test.go | 145 + .../openapi/e2e/conformance_scalars_test.go | 408 ++ .../openapi/e2e/conformance_security_test.go | 184 + compilers/openapi/e2e/conformance_test.go | 4198 ----------------- .../openapi/e2e/conformance_types_test.go | 567 +++ .../e2e/conformance_validation_test.go | 131 + .../openapi/e2e/conformance_values_test.go | 380 ++ 15 files changed, 4368 insertions(+), 4198 deletions(-) create mode 100644 compilers/openapi/e2e/conformance_composition_test.go create mode 100644 compilers/openapi/e2e/conformance_content_test.go create mode 100644 compilers/openapi/e2e/conformance_docs_test.go create mode 100644 compilers/openapi/e2e/conformance_extensions_test.go create mode 100644 compilers/openapi/e2e/conformance_nullability_test.go create mode 100644 compilers/openapi/e2e/conformance_operations_test.go create mode 100644 compilers/openapi/e2e/conformance_params_test.go create mode 100644 compilers/openapi/e2e/conformance_pathitems_test.go create mode 100644 compilers/openapi/e2e/conformance_responses_test.go create mode 100644 compilers/openapi/e2e/conformance_scalars_test.go create mode 100644 compilers/openapi/e2e/conformance_security_test.go create mode 100644 compilers/openapi/e2e/conformance_types_test.go create mode 100644 compilers/openapi/e2e/conformance_validation_test.go create mode 100644 compilers/openapi/e2e/conformance_values_test.go diff --git a/compilers/openapi/e2e/conformance_composition_test.go b/compilers/openapi/e2e/conformance_composition_test.go new file mode 100644 index 00000000..a319a18d --- /dev/null +++ b/compilers/openapi/e2e/conformance_composition_test.go @@ -0,0 +1,551 @@ +package e2e_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" + "github.com/dexpace/morphic/pass" +) + +func assertAllOfInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + d, ok := doc.Types[namedID("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.Empty(t, d.Mixins) + _, ok = propByWire(d, "extra") + assert.True(t, ok, "property declared alongside allOf survives") + // Model-level detail declared alongside allOf must be captured, not dropped. + assert.Equal(t, "A Base specialized with one extra field.", d.Docs.Description, + "composed schema keeps its description") + assert.NotNil(t, d.Deprecation, "composed schema keeps deprecated") + assert.Equal(t, ir.AdditionalClosed, d.Additional, + "composed schema keeps additionalProperties: false") + assert.Contains(t, d.Unmodeled, "openapi:x-team", "composed schema keeps x-* extensions") +} + +func assertAllOfMixins(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + c, ok := doc.Types[namedID("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) +} + +func assertAllOfInlineMerge(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + m, ok := doc.Types[namedID("Merged")].(*ir.Model) + require.True(t, ok) + require.NotNil(t, m.Base) + assert.Equal(t, namedID("Base"), m.Base.Target) + _, ok = propByWire(m, "name") + assert.True(t, ok, "inline allOf branch contributes its properties") +} + +// assertAllOfNestedInline pins the flattening of +// docs/compilers/openapi.md#allof-classification: a nested spelling classifies +// as the flat one does and keeps each property's own pointer. A wrapper writing +// 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) + 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.Empty(t, m.Unmodeled, "the wrapper is flattened, not kept verbatim") + + n, ok := doc.Types[namedID("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) + 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) + require.True(t, ok) + mc, ok := propByWire(mixed, "c") + require.True(t, ok) + assert.True(t, mc.Required, "the wrapper's required list reaches the property inside it") + wires := make([]string, 0, len(mixed.Properties)) + for _, p := range mixed.Properties { + wires = append(wires, p.WireName) + } + assert.Equal(t, []string{"c", "w"}, wires, "the wrapper's own property merges after its branches'") + + closed, ok := doc.Types[namedID("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) + 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.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"} { + variant, ok := doc.Types[id].(*ir.Model) + require.True(t, ok, "%s should be a composed variant", id) + assert.Equal(t, ir.AdditionalClosed, variant.Additional, "%s: a false nested beside a oneOf closes every variant", id) + assert.Contains(t, variant.Unmodeled, "openapi:allOf/0/allOf/2/allOf/0", "%s keeps the false at its path", id) + a, ok := propByWire(variant, "a") + require.True(t, ok, id) + assert.True(t, a.Required, "%s: the wrapper's required list reaches the variant's property", id) + } + + for _, d := range diags { + if d.Code == diag.DegradedConstruct && d.Provenance.Pointer == "/components/schemas/Described/allOf/0" { + continue // the one kept wrapper + } + assert.NotEqual(t, diag.DegradedConstruct, d.Code, "flattening is exact: %+v", d) + } +} + +func assertAllOfRequiredOnly(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + d, ok := doc.Types[namedID("Derived")].(*ir.Model) + require.True(t, ok) + require.NotNil(t, d.Base, "the $ref branch still becomes Base") + name, ok := propByWire(d, "name") + require.True(t, ok, "the inline branch's own property survives") + assert.True(t, name.Required, + "a required-only allOf branch attaches across the whole composition, not just its own properties map (issue #29)") +} + +// assertRequiredMembers pins where a `required` name lands when no property of +// the object it is written for declares it (ir-design §4.3): in Model.Requires, +// once, beside the Required on the properties it does name. The four shapes are +// four ways an object lowers: a plain object, an allOf making a property its +// base declares required, an object whose catch-all governs the name, which +// 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) + 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) + require.True(t, ok) + require.NotNil(t, created.Base) + assert.Equal(t, namedID("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) + 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) + 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) + assert.Equal(t, []string{"a"}, tagged.Requires, "required only because the catch-all governs it") + _, 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) + require.True(t, ok) + var object *ir.Model + for _, v := range either.Variants { + if m, isModel := doc.Types[v.Type.Target].(*ir.Model); isModel { + object = m + } + } + require.NotNil(t, object, "the object variant is a Model") + variantID, ok := propByWire(object, "id") + require.True(t, ok) + assert.True(t, variantID.Required) + assert.Equal(t, []string{"extra"}, object.Requires, "the variant lists what it declares nowhere") +} + +// assertAllOfOneOfCooccurrence pins both halves of the co-declared composition +// rule: §4.3 distributes a union whose branches all name referents, and the +// rule docs/compilers/openapi.md#union-beside-structure keeps one with an +// inline branch verbatim rather than distributing it halfway. The verbatim half +// is covered over a model body, which owns a node already, and over a scalar +// one, which does not — there the alias hoisted for the union carries what the +// position wrote beside it too. The outside reference to a branch pointer pins +// 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) + 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) + require.Len(t, v.Mixins, 1) + assert.Equal(t, namedID(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) + 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) + 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") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + require.NotNil(t, bounded.Constraints, "and the bounds written beside the union sit on it too") + require.NotNil(t, bounded.Constraints.MinLength) + assert.Equal(t, int64(3), *bounded.Constraints.MinLength) + + outsider, ok := doc.Types[namedID("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, + "the reference gets the branch schema, never the variant composed for that branch") + + assertInlineBranchHint(t, doc) +} + +// assertInlineBranchHint pins the hint an inline composition branch takes when +// an outside reference names its pointer too. The union reaches that node +// through its composition, and the reference builds it (hoistSubSchema) only +// where nothing else did, under the hint the composition recorded. An inline +// branch has only its position to be named by, and the reference path once used +// the bare ordinal "0" (GitHub #181). The value is asserted because the golden +// records whichever hint won without saying the two agree, and the corpus +// carries the shape so the two-order oracle covers it. +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) + 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, + "the union's first variant is the node the branch pointer owns") + + branch, ok := doc.Types[branchID] + require.True(t, ok, "the branch pointer owns a node") + 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) + require.True(t, ok) + require.Len(t, outsider.Properties, 1) + assert.Equal(t, branchID, outsider.Properties[0].Type.Target, + "the outside reference resolves to that same node rather than hoisting a second") +} + +// assertAllOfPositionConstraints pins GitHub #407: a value constraint written +// beside an allOf composing position reaches the same ir.Model.Constraints +// field a plain object's does, rather than falling to Unmodeled under a +// 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) + 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) + require.True(t, ok) + require.NotNil(t, viaAllOf.Base, "the sole allOf $ref still becomes Base") + assert.Equal(t, namedID("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) + assert.NotContains(t, viaAllOf.Unmodeled, "openapi:minProperties", + "minProperties has a home now, so it is not also kept verbatim") + assert.Empty(t, diagsAt(diags, diag.DegradedConstruct, "/components/schemas/ViaAllOf"), + "and no diagnostic claims the node has no home for it") +} + +func assertOneOfDiscriminated(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + pet, ok := doc.Types[namedID("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"]) +} + +// 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) + 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"]) +} + +// assertDiscriminatorInheritance covers the model-hierarchy form of a +// discriminator, which the union form (oneof-discriminated) does not reach: the +// tag is declared on the base, each subtype composes the base by $ref, and the +// wire value each subtype answers to is recorded on the subtype. +// +// Both spellings of that value are pinned, because they come from different +// 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) + require.True(t, ok, "the base is a Model, not a Union") + require.NotNil(t, pet.Discriminator) + tag, ok := propByWire(pet, "petType") + require.True(t, ok, "the base declares the tag property itself") + assert.Equal(t, tag.ID, pet.Discriminator.Property, + "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"]) + + for name, value := range map[string]string{"Cat": "cat", "Dog": "Dog"} { + sub, ok := doc.Types[namedID(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, value, sub.DiscriminatorValue) + assert.Nil(t, sub.Discriminator, "a subtype does not restate its base's discriminator") + } +} + +// assertDiscriminatorInlineSubtype covers the subtypes OpenAPI's implicit +// mapping cannot name (GitHub #517). The implicit tag is the name of the +// component schema that declares a subtype, so one declared inline — as a +// property or a union branch — takes no tag, while it still composes the +// 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) + 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") + + for _, tc := range []struct { + id ir.TypeID + value string + }{ + {namedID("Dog"), "Dog"}, + {"t/anon/components/schemas/Kennel/properties/Dog", ""}, + {"t/anon/components/schemas/Kennel/properties/pet", ""}, + {"t/anon/components/schemas/Either/oneOf/0", ""}, + } { + 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, tc.value, sub.DiscriminatorValue, + "%s: only a component schema has a name to answer to", tc.id) + } +} + +// assertDiscriminatorTransitive covers a hierarchy deeper than the two levels +// discriminator-inheritance reaches: the subtype's own allOf branch names an +// intermediate schema that declares no discriminator, so the tag value can only +// come from an ancestor further up (GitHub #305). What each subtype composes is +// pinned beside its tag, since depth is the point. The undiscriminated chain +// beside them is the boundary: depth alone must not manufacture a value. Puppy +// 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) + 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"], + "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 + // thing a one-hop reading of the chain could see. + for _, tc := range []struct{ name, base, value string }{ + {"Dog", "Pet", "dog"}, + {"Puppy", "Dog", "puppy"}, + {"Whelp", "Puppy", "Whelp"}, + } { + sub, ok := doc.Types[namedID(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, + "%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) + assert.Nil(t, sub.Discriminator, "a subtype does not restate the hierarchy's discriminator") + } + + for _, name := range []string{"Shrub", "Sapling"} { + sub, ok := doc.Types[namedID(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, + "%s has no discriminated ancestor, so walking the chain finds no tag", name) + } +} + +// assertDiscriminatorAliasMapping covers a mapping that spells two keys for one +// subtype (GitHub #410). Model.DiscriminatorValue holds one value and a mapping +// is unordered, so the subtype takes the smallest key in byte order whichever +// was written first — the fixture writes the larger first — while the base's +// 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) + 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")}, + pet.Discriminator.Mapping, "the base keeps every key") + + dog, ok := doc.Types[namedID("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") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Dog"), + "the election is reported at the subtype: %+v", diags) +} + +// assertDiscriminatorDefaultMapping pins Discriminator.Default, whose only source +// is the 3.2 discriminator.defaultMapping — and with it that a 3.2-only schema +// keyword compiles without an error diagnostic (GitHub #146). The library checks +// every schema object against the 3.1 meta-schema whatever the document says, so +// before this the spec below failed to compile and the field had no witness. +func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + assert.Empty(t, diagsAt(diags, "openapi/validation/validation-invalid-schema", + "/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) + require.True(t, ok) + require.NotNil(t, pet.Discriminator) + assert.Equal(t, namedID("Dog"), pet.Discriminator.Default, + "defaultMapping names the variant an unrecognized tag falls back to") + assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"], + "and it is read separately from the mapping") +} + +// assertDiscriminatedUnionBesideModel pins where a discriminator goes when its +// union is kept verbatim beside a model. Pet's and Animal's routes are not +// subtypes of them, so pass.Validate would reject the discriminator on the +// model; it moves whole to Unmodeled beside the union. Vehicle's object +// variant holds one the same way. Shape's routes are true allOf subtypes, so +// the model keeps it, and the document validates clean. +func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + assert.Empty(t, pass.Validate(doc)) + + for _, name := range []string{"Pet", "Animal"} { + m, ok := doc.Types[namedID(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"] + require.True(t, ok, "%s keeps it verbatim instead", name) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + 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) + 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) + 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) + 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.NotContains(t, shape.Unmodeled, "openapi:discriminator") +} + +// assertNullableUnionDiscriminator pins that a discriminator beside a oneOf or +// 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) + 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.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) + 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.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) + 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") + assert.Nil(t, u.Discriminator) + assert.Len(t, u.Variants, 2) +} + +// assertUnionDecoratedBranch pins a union branch annotated the only way 3.0 +// can, by wrapping its $ref (GitHub #1018). The wrapper lowers to a decorator +// 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) + require.True(t, ok) + require.Len(t, u.Variants, 2) + assert.Equal(t, "user", u.Variants[0].Name.Hint) + assert.Equal(t, "pet", u.Variants[1].Name.Hint, "named for the $ref it wraps, as 3.1's sibling spelling is") + + branch, ok := doc.Types[u.Variants[1].Type.Target].(*ir.Model) + 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, "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) + require.True(t, ok) + raw, ok := m.Unmodeled["openapi:not"] + require.True(t, ok, "not-keyword kept verbatim under Unmodeled") + assert.JSONEq(t, `{"required":["b"]}`, string(raw.Value)) + assert.Equal(t, ir.ReasonValidationOnly, raw.Reason) + var found bool + for _, d := range diags { + if d.Code == "openapi/validation-only-keyword" { + found = true + assert.Equal(t, ir.SeverityInfo, d.Severity) + } + } + assert.True(t, found, "expected a validation-only-keyword info diagnostic") +} diff --git a/compilers/openapi/e2e/conformance_content_test.go b/compilers/openapi/e2e/conformance_content_test.go new file mode 100644 index 00000000..e1dd7523 --- /dev/null +++ b/compilers/openapi/e2e/conformance_content_test.go @@ -0,0 +1,282 @@ +package e2e_test + +import ( + "maps" + "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" +) + +func assertMultiContent(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + op, ok := opByName(doc, "getData") + require.True(t, ok) + require.Len(t, op.Responses, 1) + require.NotNil(t, op.Responses[0].Payload) + require.Len(t, op.Responses[0].Payload.Contents, 2, "both media types kept, no primary selection") + assert.Equal(t, "application/json", op.Responses[0].Payload.Contents[0].MediaType) + assert.Equal(t, "application/xml", op.Responses[0].Payload.Contents[1].MediaType) +} + +func assertMultipartEncoding(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + op, ok := opByName(doc, "upload") + require.True(t, ok) + require.NotNil(t, op.Request) + require.Len(t, op.Request.Contents, 1) + enc := op.Request.Contents[0].Encoding + require.NotEmpty(t, enc, "multipart parts carry PartEncoding keyed by PropID") + var sawFile, sawMulti bool + var fileHeaders []ir.Property + for _, pe := range enc { + if pe.Filename { + sawFile = true + fileHeaders = pe.Headers + } + if pe.Multi { + sawMulti = true + } + } + assert.True(t, sawFile, "binary file part carries Filename") + assert.True(t, sawMulti, "array part carries Multi") + assertPartHeaders(t, doc, fileHeaders) + assertFormPartStyle(t, doc) + assertComposedPartEncoding(t, doc) +} + +// assertComposedPartEncoding pins the body that composes its parts rather than +// declaring them. Reading only the schema node's own properties found none on an +// allOf wrapper, so the whole encoding block was discarded with no diagnostic and +// a Content that still looked well-formed, just smaller (GitHub #140). +// +// The keys are asserted to be the properties the IR actually holds: §4.3 keeps a +// composed model's inherited parts on its Base, so a key derived from the +// composed node's own pointer would name a property that exists nowhere. +func assertComposedPartEncoding(t *testing.T, doc *ir.Document) { + t.Helper() + op, ok := opByName(doc, "uploadComposed") + require.True(t, ok) + require.NotNil(t, op.Request) + require.Len(t, op.Request.Contents, 1) + + enc := op.Request.Contents[0].Encoding + require.NotEmpty(t, enc, "a composed body keeps the encoding a declared one does") + + inherited := ir.PropID("p/openapi/components/schemas/UploadForm/properties/file") + file, ok := enc[inherited] + require.True(t, ok, "the inherited part is keyed on the Base that holds it; got keys %v", enc) + assert.Equal(t, []string{"image/png"}, file.ContentTypes) + assert.True(t, file.Filename, "the structural flag is read off the merged type of the inherited part") + + // Every key must name a property some type in the document declares. A part + // keyed by a PropID nothing holds is the failure this lowering can produce + // while still looking well-formed. + for id := range enc { + assert.True(t, propIDExists(doc, id), "encoding key %s names no property in the document", id) + } +} + +// propIDExists reports whether any type in doc declares a property with the +// given ID. +func propIDExists(doc *ir.Document, id ir.PropID) bool { + for _, td := range doc.Types { + m, ok := td.(*ir.Model) + if !ok { + continue + } + if slices.ContainsFunc(m.Properties, func(p ir.Property) bool { return p.ID == id }) { + return true + } + } + return false +} + +// assertFormPartStyle pins the form-serialization half of a part's encoding: a +// urlencoded body's parts carry the same style/explode pair a query parameter +// does, on the part rather than on any binding. +func assertFormPartStyle(t *testing.T, doc *ir.Document) { + t.Helper() + op, ok := opByName(doc, "submitForm") + require.True(t, ok) + require.NotNil(t, op.Request) + require.Len(t, op.Request.Contents, 1) + assert.Equal(t, "application/x-www-form-urlencoded", op.Request.Contents[0].MediaType) + enc := op.Request.Contents[0].Encoding + require.Len(t, enc, 1) + for _, pe := range enc { + assert.Equal(t, "form", pe.Style) + require.NotNil(t, pe.Explode) + assert.True(t, *pe.Explode) + assert.True(t, pe.Multi, "the structural flag is read off the part") + } + // allowReserved has no PartEncoding field, and PartEncoding has no Unmodeled + // map, so it is kept on the content keyed by the part it governs. Two + // documents differing only in it used to compile to one IR (GitHub #291). + entry := unmodeledEntry(t, op.Request.Contents[0].Unmodeled, "openapi:encoding/ids/allowReserved") + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + assert.JSONEq(t, "true", string(entry.Value)) +} + +// assertFileBody pins a binary body: the payload's type degrades to bytes and the +// media types it is carried as move onto Content.File, which is what tells an +// emitter to generate a stream rather than a model. Both routes to that lowering +// are covered — a media type with no schema at all, and a string+binary schema. +func assertFileBody(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + op, ok := opByName(doc, "putBlob") + require.True(t, ok) + require.NotNil(t, op.Request) + require.Len(t, op.Request.Contents, 1) + req := op.Request.Contents[0] + require.NotNil(t, req.File, "application/octet-stream with no schema is a file body") + assert.Equal(t, []string{"application/octet-stream"}, req.File.ContentTypes) + assert.Equal(t, ir.TypeID("t/prim/bytes"), req.Type.Target) + + resp := firstContent(t, op) + require.NotNil(t, resp.File, "a string+binary schema is a file body whatever the media type") + assert.Equal(t, []string{"image/png"}, resp.File.ContentTypes) + assert.Equal(t, ir.TypeID("t/prim/bytes"), resp.Type.Target) +} + +// assertMediaTypeSpelling pins that a media type is classified by its type alone +// while the IR keeps the key as written. One content per classification: a +// multipart body, a form-urlencoded body and a file body with no schema, the +// last on both sides of an operation. +func assertMediaTypeSpelling(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + upload, ok := opByName(doc, "upload") + require.True(t, ok) + multipart := requestContent(t, upload) + assert.Equal(t, "Multipart/Form-Data", multipart.MediaType) + require.Len(t, multipart.Encoding, 1, "a mixed-case multipart key keeps its part encoding") + for _, pe := range multipart.Encoding { + assert.Equal(t, []string{"image/png"}, pe.ContentTypes) + assert.True(t, pe.Filename) + } + + submit, ok := opByName(doc, "submit") + require.True(t, ok) + form := requestContent(t, submit) + assert.Equal(t, "Application/X-WWW-Form-Urlencoded; charset=utf-8", form.MediaType) + require.Len(t, form.Encoding, 1, "a parameterized form-urlencoded key keeps its part encoding") + for _, pe := range form.Encoding { + assert.True(t, pe.Multi) + } + + blob, ok := opByName(doc, "putBlob") + require.True(t, ok) + req := requestContent(t, blob) + require.NotNil(t, req.File, "a mixed-case octet-stream key with no schema is a file body") + assert.Equal(t, []string{"Application/Octet-Stream"}, req.File.ContentTypes) + assert.Equal(t, ir.TypeID("t/prim/bytes"), req.Type.Target) + + resp := firstContent(t, blob) + require.NotNil(t, resp.File, "a parameterized octet-stream key with no schema is a file body") + assert.Equal(t, []string{"application/octet-stream; type=tar"}, resp.File.ContentTypes) + assert.Equal(t, ir.TypeID("t/prim/bytes"), resp.Type.Target) +} + +// assertPartHeaders pins encoding..headers, a TypeRef position reachable +// only through a PartEncoding and, before this case, exercised by no corpus +// spec. It covers both hops a header takes — an inline schema and one behind a +// $ref through components/headers — and requires each target to be in the +// registry, which is what gives the referential-closure walker something to +// check here. +func assertPartHeaders(t *testing.T, doc *ir.Document, headers []ir.Property) { + t.Helper() + require.Len(t, headers, 2, "the file part declares two per-part headers") + byWire := make(map[string]ir.Property, len(headers)) + for _, h := range headers { + byWire[h.WireName] = h + } + + id, ok := byWire["X-Part-Id"] + require.True(t, ok, "inline part header lowered; got %v", byWire) + assert.Equal(t, ir.TypeID("t/prim/uuid"), id.Type.Target) + assert.True(t, id.Required, "a required part header keeps its presence bit") + assert.Equal(t, "per-part correlation id", id.Docs.Description) + + 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, "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) + } +} + +// assertSequentialMedia pins the 3.2 sequential-media lowering, including the +// two forms per-item encoding takes: one Encoding governing every item lands in +// Content.ItemEncoding, while a positional prefixEncoding — which a single +// every-item encoding has no ordinals for — takes itself and the tail encoding +// beside it into Unmodeled instead. +// +// It also pins what itemSchema says about the operation, which for a long while +// was nothing: the keyword states that the body is a sequence of items, so the +// operation streams, and it says so itself rather than being guessed at from +// the media type — multipart/mixed is in no streaming media-type list. +func assertSequentialMedia(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + events, ok := opByName(doc, "streamEvents") + require.True(t, ok) + c := firstContent(t, events) + require.NotNil(t, c.Item, "itemSchema becomes the element type") + require.NotNil(t, c.ItemEncoding, "an encoding governing every item lowers structurally") + assert.Equal(t, []string{"application/json"}, c.ItemEncoding.ContentTypes) + assert.True(t, c.ItemEncoding.Multi, "the construct describes a repeated tail") + assert.Empty(t, c.Unmodeled, "nothing is left over once it lowers") + + assert.Equal(t, ir.StreamingServer, events.Streaming) + require.NotNil(t, events.ResponseStream) + require.NotNil(t, events.ResponseStream.Events) + assert.Empty(t, cmp.Diff(*c.Item, *events.ResponseStream.Events), + "the declared item schema is the stream element type") + assert.Empty(t, events.Provenance.Inferred, "a declared sequence is not a heuristic") + + parts, ok := opByName(doc, "streamParts") + require.True(t, ok) + pc := firstContent(t, parts) + assert.Nil(t, pc.ItemEncoding, "positional prefixes have no every-item form") + for _, key := range []string{"openapi:prefixEncoding", "openapi:itemEncoding"} { + entry, ok := pc.Unmodeled[key] + require.True(t, ok, "%s kept verbatim; got %v", key, pc.Unmodeled) + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + } +} + +// assertComponentMediaTypes32 pins a 3.2 document using components/mediaTypes +// while this compiler resolves no reference into it (GitHub #856): nothing is +// lost, as the section is kept whole and each content entry keeps its $ref, and +// nothing the dialect defines is called undefined. +func assertComponentMediaTypes32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + for _, name := range []string{"listEvents", "getEvent"} { + op, ok := opByName(doc, name) + require.True(t, ok) + content := op.Responses[0].Payload.Contents[0] + assert.Equal(t, "application/json", content.MediaType) + assert.Equal(t, ir.ReasonDegradedLowering, content.Unmodeled["openapi:$ref"].Reason, + "%s: the reference is kept, not followed", name) + } + assert.Equal(t, ir.ReasonNoIRHome, doc.Unmodeled["openapi:components/mediaTypes"].Reason) + assert.False(t, openapitest.HasDiag(diags, diag.UnknownObjectKey)) + openapitest.AssertInfoDiagAt(t, diags, "/components/mediaTypes") +} + +// assertNestedEncoding32 pins GitHub #615's nested Encoding fields: each is kept +// verbatim under the part's own scope with one info, rather than drawing an +// unknown-object-key warning and reaching no entry at all. +func assertNestedEncoding32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "upload") + require.True(t, ok) + require.NotNil(t, op.Request) + content := op.Request.Contents[0] + for _, keyword := range []string{"encoding", "prefixEncoding"} { + entry, ok := content.Unmodeled["openapi:encoding/note/"+keyword] + require.True(t, ok, "%s is kept verbatim; got %v", keyword, slices.Sorted(maps.Keys(content.Unmodeled))) + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + } + assert.False(t, openapitest.HasDiag(diags, diag.UnknownObjectKey), + "3.2 defines these keys, so the census must leave them alone") +} diff --git a/compilers/openapi/e2e/conformance_docs_test.go b/compilers/openapi/e2e/conformance_docs_test.go new file mode 100644 index 00000000..44d65296 --- /dev/null +++ b/compilers/openapi/e2e/conformance_docs_test.go @@ -0,0 +1,306 @@ +package e2e_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" +) + +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) + require.True(t, ok) + assert.NotNil(t, m.Deprecation) + require.Len(t, m.Properties, 1) + assert.NotNil(t, m.Properties[0].Deprecation) + + // A parameter is its own carrier: deprecated written on the parameter object + // reaches it without any schema being involved. + legacy, ok := paramByName(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) + require.True(t, ok) + + // A schema that reduced to a shared primitive keeps its examples on the + // declaring property; one that hoists a node of its own keeps them there. + n, ok := propByWire(m, "n") + require.True(t, ok) + assert.Len(t, n.Examples, 2) + inner, ok := propByWire(m, "inner") + require.True(t, ok) + assert.Empty(t, inner.Examples, "an inline object holds its own examples") + 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")] + require.True(t, ok) + require.Len(t, c.Common().Examples, 1) + require.NotNil(t, c.Common().Examples[0].Value) + assert.Equal(t, "component-level", c.Common().Examples[0].Value.Str) + + // A parameter carries its own examples, keyed by their map key like a media + // type's. + op, ok := opByName(doc, "getItem") + require.True(t, ok) + q, ok := paramByName(op, "q") + require.True(t, ok) + require.Len(t, q.Examples, 1) + assert.Equal(t, "one", q.Examples[0].Name) + require.NotNil(t, q.Examples[0].Value) + assert.Equal(t, "first", q.Examples[0].Value.Str) + + assertResponseExamples(t, doc) +} + +// assertResponseExamples pins the response-side example sites: the plural map on +// a media type, in every spelling the spec allows, and a response header. +func assertResponseExamples(t *testing.T, doc *ir.Document) { + t.Helper() + op, ok := opByName(doc, "getItem") + require.True(t, ok) + require.Len(t, op.Responses[0].Payload.Contents, 1) + ex := op.Responses[0].Payload.Contents[0].Examples + require.Len(t, ex, 4, "every entry lowers, in source order") + + // An inline entry and one written as a $ref, which must resolve to the + // referenced component's value. Both are named by their map key. + require.NotNil(t, ex[0].Value) + assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "hello"}, *ex[0].Value) + assert.Equal(t, "inline", ex[0].Name) + require.NotNil(t, ex[1].Value) + assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "world"}, *ex[1].Value) + assert.Equal(t, "shared", ex[1].Name) + + // The annotations that surround a value travel with it. + assert.Equal(t, "a summary", ex[2].Summary) + assert.Equal(t, "a description", ex[2].Description) + + // An externalValue entry carries no inline value, and is kept for the URL. + assert.Equal(t, "https://example.com/e.json", ex[3].ExternalURL) + assert.Nil(t, ex[3].Value) + assert.Equal(t, "hosted elsewhere", ex[3].Summary) + + headers := op.Responses[0].Headers + require.Len(t, headers, 1) + require.Len(t, headers[0].Examples, 1) + require.NotNil(t, headers[0].Examples[0].Value) + assert.Equal(t, ir.BigVal("5"), headers[0].Examples[0].Value.Num) +} + +// assertSelfURI pins that a declared `$self` reaches the source's entry rather +// than a lowered node, since it says where one input document lives. The case +// 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) +} + +func assertDocsSummaryDesc(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + op, ok := opByName(doc, "ping") + require.True(t, ok) + assert.Equal(t, "Ping the server", op.Docs.Summary) + assert.Equal(t, "Returns pong", op.Docs.Description) + require.Len(t, op.Docs.ExternalDocs, 1) + assert.Equal(t, ir.Link{URL: "https://example.com/docs/ping", Description: "Ping reference"}, + op.Docs.ExternalDocs[0]) + + // 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) + require.True(t, ok) + assert.Equal(t, "User identifier", sc.Docs.Summary) + assert.Equal(t, "Stable identifier for a user.", sc.Docs.Description) + + // info and the root externalDocs are one Docs on the document: summary and + // description come from info, the link from beside it. + assert.Equal(t, "A one-line summary of the API.", doc.Docs.Summary) + assert.Equal(t, "The long-form description of the API.", doc.Docs.Description) + require.Len(t, doc.Docs.ExternalDocs, 1) + assert.Equal(t, ir.Link{URL: "https://example.com/docs", Description: "Full guide"}, + doc.Docs.ExternalDocs[0]) + require.NotNil(t, doc.License) + assert.Equal(t, ir.License{Name: "MIT", Identifier: "MIT"}, *doc.License, + "the 3.1 SPDX identifier is its own field, never folded into the name") +} + +// assertRequestBodyDocs pins GitHub #609: a Request Body Object's `description` +// describes the body rather than any media type inside it, so it reaches +// Payload.Docs. Both spellings are covered — a body $ref'd from components and +// one written inline — and the shared component is reached from two operations, +// so the declaration-pointer path is exercised and one declaration is still one +// type however many mounts read its docs. +func assertRequestBodyDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + created, ok := opByName(doc, "createOrder") + require.True(t, ok) + require.NotNil(t, created.Request) + assert.Equal(t, "A shared order body.", created.Request.Docs.Description, + "a $ref'd body keeps the component's description") + + replaced, ok := opByName(doc, "replaceOrder") + require.True(t, ok) + require.NotNil(t, replaced.Request) + assert.Equal(t, "A shared order body.", replaced.Request.Docs.Description, + "the second mount reads the same declaration's docs") + + shared := ir.TypeID("t/anon/components/requestBodies/OrderBody/content/application~1json/schema") + assert.Equal(t, shared, openapitest.BodyTarget(t, created.Request), + "a shared body interns at its component pointer (issue #107)") + assert.Equal(t, shared, openapitest.BodyTarget(t, replaced.Request), + "...once, whichever mount lowered it first") + + draft, ok := opByName(doc, "saveDraft") + require.True(t, ok) + require.NotNil(t, draft.Request) + assert.Equal(t, "A draft saved inline.", draft.Request.Docs.Description, + "an inline body's own description reaches the payload too") +} + +// assertRefSiteDocs pins GitHub #610 across every site the fold reaches, in one +// document: a Reference Object may write `summary` and `description` beside its +// `$ref`, and OpenAPI says they override the referenced object's. Nothing read +// them, so a mount's own documentation was silently replaced by the +// declaration's. The one component referenced from two operations with +// different overrides also pins the other half: each mount keeps its own and +// neither mutates the declaration. +func assertRefSiteDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + widgets, ok := opByName(doc, "listWidgets") + require.True(t, ok) + gadgets, ok := opByName(doc, "listGadgets") + require.True(t, ok) + + // 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") + 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") + 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) + + // responses — one mount overrides the description, the other keeps the + // declaration's, so neither mutates it. + require.Len(t, widgets.Responses, 1) + assert.Equal(t, "The listing, as this operation returns it.", widgets.Responses[0].Docs.Description) + require.Len(t, gadgets.Responses, 1) + assert.Equal(t, "A page of results.", gadgets.Responses[0].Docs.Description, + "a mount with no siblings leaves the declaration's description alone") + + // error cases are responses too, and reach the same shared lowering. + require.Len(t, widgets.Errors, 1) + assert.Equal(t, "No such widget.", widgets.Errors[0].Docs.Description) + + // headers + require.Len(t, widgets.Responses[0].Headers, 1) + assert.Equal(t, "The unit a rate limit is stated in.", widgets.Responses[0].Headers[0].Docs.Description) + + // examples — the siblings override the Example Object's own pair. + require.NotNil(t, widgets.Responses[0].Payload) + require.Len(t, widgets.Responses[0].Payload.Contents[0].Examples, 1) + example := widgets.Responses[0].Payload.Contents[0].Examples[0] + assert.Equal(t, "One page", example.Summary) + assert.Equal(t, "A single page of results.", example.Description) + + // request bodies + require.NotNil(t, widgets.Request) + assert.Equal(t, "The widget to create.", widgets.Request.Docs.Description) + + // security schemes — the aliasing entry takes its own description, and the + // declaration it names keeps its own; an alias of the alias writes none, so + // it takes the description of the hop beneath it. + require.Len(t, doc.Auth, 3) + byName := map[string]string{} + for _, scheme := range doc.Auth { + byName[scheme.Name.Source] = scheme.Docs.Description + } + assert.Equal(t, "The key this API expects.", byName["ApiKey"]) + assert.Equal(t, "The declaration's own scheme description.", byName["BaseKey"], + "the aliasing entry's siblings do not reach the declaration") + assert.Equal(t, "The key this API expects.", byName["KeyAlias"]) + + assertAliasHopDocs(t, doc) +} + +// assertAliasHopDocs pins the siblings of an alias, a component that is itself a +// $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) { + sprocket, ok := opByName(doc, "makeSprocket") + require.True(t, ok) + limit, ok := paramByName(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") + require.NotNil(t, sprocket.Request) + assert.Equal(t, "The alias's own body description.", sprocket.Request.Docs.Description) + require.Len(t, sprocket.Responses, 1) + require.Len(t, sprocket.Responses[0].Headers, 1) + assert.Equal(t, "The alias's own header description.", sprocket.Responses[0].Headers[0].Docs.Description) + require.Len(t, sprocket.Responses[0].Payload.Contents[0].Examples, 1) + example := sprocket.Responses[0].Payload.Contents[0].Examples[0] + assert.Equal(t, "The alias's own summary", example.Summary) + assert.Equal(t, "Declared description", example.Description, "the field the alias omits stands") + require.Len(t, sprocket.Errors, 1) + assert.Equal(t, "The alias's own response description.", sprocket.Errors[0].Docs.Description) +} + +// assertExamples32 pins GitHub #612 at every site the issue names: the 3.2 +// dataValue lowers like `value`, including through a components/examples $ref, +// and a serializedValue is kept verbatim under Unmodeled wherever it is written, +// alone or beside a dataValue, rather than dropped. +func assertExamples32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "list") + require.True(t, ok) + + // parameters + limit, ok := paramByName(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") + assert.Equal(t, ir.BigVal("5"), limit.Examples[0].Value.Num) + + // headers + require.Len(t, op.Responses, 1) + require.Len(t, op.Responses[0].Headers, 1) + headerExample := op.Responses[0].Headers[0].Examples + require.Len(t, headerExample, 1) + assert.Equal(t, ir.ReasonNoIRHome, headerExample[0].Unmodeled["openapi:serializedValue"].Reason, + "a header's serializedValue keeps the entry verbatim") + + // content, and through it components/examples + examples := op.Responses[0].Payload.Contents[0].Examples + require.Len(t, examples, 4) + byName := map[string]ir.Example{} + for _, e := range examples { + byName[e.Name] = e + } + require.NotNil(t, byName["five"].Value) + assert.Equal(t, ir.BigVal("5"), byName["five"].Value.Num) + require.NotNil(t, byName["one"].Value, "a $ref'd components/examples entry lowers its dataValue") + assert.Equal(t, ir.BigVal("1"), byName["one"].Value.Num) + assert.Equal(t, ir.ReasonNoIRHome, byName["serial"].Unmodeled["openapi:serializedValue"].Reason) + assert.Nil(t, byName["serial"].Value) + require.NotNil(t, byName["both"].Value, "a dataValue beside a serializedValue still lowers") + assert.Equal(t, ir.BigVal("2"), byName["both"].Value.Num) + assert.JSONEq(t, `"\"2\""`, string(byName["both"].Unmodeled["openapi:serializedValue"].Value), + "...and the serialized form beside it is kept too") + + openapitest.AssertInfoDiagAt(t, diags, + "/paths/~1a/get/responses/200/content/application~1json/examples/both/serializedValue") + openapitest.AssertInfoDiagAt(t, diags, "/components/examples/Serial/serializedValue") +} diff --git a/compilers/openapi/e2e/conformance_extensions_test.go b/compilers/openapi/e2e/conformance_extensions_test.go new file mode 100644 index 00000000..ff2b9ebe --- /dev/null +++ b/compilers/openapi/e2e/conformance_extensions_test.go @@ -0,0 +1,179 @@ +package e2e_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir" +) + +func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + m, ok := doc.Types[namedID("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:") + assert.JSONEq(t, "100", string(raw.Value)) + assert.Equal(t, ir.ReasonVendorExtension, raw.Reason) + + assertEveryObjectKeepsItsExtensions(t, doc) + assertRawPreservedBinary(t, m) +} + +// assertEveryObjectKeepsItsExtensions holds the whole contract, not the +// positions that happened to be noticed: every OpenAPI object that admits an +// x-* keeps it. The fixture writes `x-mark` on each with a value naming the +// object, so a row that stops arriving names the lowering that stopped reading. +// +// Carriers are derived from the value graph: a row says which Unmodeled map the +// entry must land on by the path the walk reaches it at. The match is a +// substring one, so a bare ".Unmodeled" ends every path and checks nothing; +// carrierNarrows holds each row to narrowing the document. +func assertEveryObjectKeepsItsExtensions(t *testing.T, doc *ir.Document) { + t.Helper() + sites := unmodeledSites(doc) + for _, tc := range []struct{ object, key, want, carrier string }{ + {"openapi root", "openapi:x-audience", `"public"`, "doc.Unmodeled"}, + {"info", "openapi:info/x-mark", `"XINFO"`, "doc.Unmodeled"}, + {"contact", "openapi:info/contact/x-mark", `"XCONTACT"`, "doc.Unmodeled"}, + {"license", "openapi:info/license/x-mark", `"XLICENSE"`, "doc.Unmodeled"}, + {"externalDocs", "openapi:externalDocs/x-mark", `"XEXTERNALDOCS"`, "doc.Unmodeled"}, + {"components", "openapi:components/x-mark", `"XCOMPONENTS"`, "doc.Unmodeled"}, + {"tag", "openapi:tags/0/x-mark", `"XTAG"`, "doc.Unmodeled"}, + {"tag externalDocs", "openapi:tags/0/externalDocs/x-mark", `"XTAGEXTERNALDOCS"`, "doc.Unmodeled"}, + {"server", "openapi:x-mark", `"XSERVER"`, "doc.Servers[0].Unmodeled"}, + {"server variable", "openapi:x-mark", `"XSERVERVARIABLE"`, "doc.Servers[0].Variables[0].Unmodeled"}, + {"paths", "openapi:paths/x-mark", `"XPATHS"`, "doc.Services[0].Unmodeled"}, + {"path item", "openapi:pathItem/x-mark", `"XPATHITEM"`, ".Operations[0].Unmodeled"}, + {"operation", "openapi:x-internal", `true`, ".Operations[0].Unmodeled"}, + {"operation externalDocs", "openapi:externalDocs/x-mark", `"XOPERATIONEXTERNALDOCS"`, + ".Operations[0].Unmodeled"}, + {"responses", "openapi:responses/x-mark", `"XRESPONSES"`, ".Operations[0].Unmodeled"}, + {"parameter", "openapi:x-mark", `"XPARAMETER"`, ".Params[0].Unmodeled"}, + {"request body", "openapi:x-mark", `"XREQUESTBODY"`, ".Request.Unmodeled"}, + {"media type", "openapi:x-mark", `"XMEDIATYPE"`, ".Contents[0].Unmodeled"}, + {"encoding", "openapi:encoding/f/x-mark", `"XENCODING"`, ".Contents[0].Unmodeled"}, + {"example", "openapi:x-mark", `"XEXAMPLE"`, ".Examples[0].Unmodeled"}, + {"response", "openapi:x-mark", `"XRESPONSE"`, ".Responses[0].Unmodeled"}, + {"error response", "openapi:x-mark", `"XERRORRESPONSE"`, ".Errors[0].Unmodeled"}, + {"header", "openapi:x-mark", `"XHEADER"`, ".Headers[0].Unmodeled"}, + {"callback", "openapi:callbacks/onEvent/x-mark", `"XCALLBACK"`, ".Bindings.HTTP[0].Unmodeled"}, + {"schema xml", "openapi:xml/x-mark", `"XXML"`, "doc.Types[t/openapi/components/schemas/S].Unmodeled"}, + {"schema externalDocs", "openapi:externalDocs/x-mark", `"XSCHEMAEXTERNALDOCS"`, + "doc.Types[t/openapi/components/schemas/S].Unmodeled"}, + {"discriminator", "openapi:discriminator/x-mark", `"XDISCRIMINATOR"`, + "doc.Types[t/openapi/components/schemas/D].Unmodeled"}, + {"security scheme", "openapi:x-mark", `"XSECURITYSCHEME"`, + "doc.Auth[auth/openapi/components/securitySchemes/k].Unmodeled"}, + {"oauth flows", "openapi:flows/x-mark", `"XOAUTHFLOWS"`, + "doc.Auth[auth/openapi/components/securitySchemes/o].Unmodeled"}, + {"oauth flow", "openapi:x-mark", `"XOAUTHFLOW"`, + "doc.Auth[auth/openapi/components/securitySchemes/o].Flows[0].Unmodeled"}, + } { + site, found := findUnmodeled(sites, tc.key, tc.want) + if !assert.True(t, found, "%s extension is dropped: no %s = %s anywhere in the document", + tc.object, tc.key, tc.want) { + continue + } + assert.Equal(t, ir.ReasonVendorExtension, site.entry.Reason, "%s extension reason", tc.object) + assert.True(t, carrierNarrows(sites, tc.carrier), + "%s carrier %q matches every Unmodeled map in the document, so it asserts nothing "+ + "about where the entry landed", tc.object, tc.carrier) + assert.Contains(t, site.path, tc.carrier, "%s extension lands on the wrong carrier", tc.object) + } +} + +// carrierNarrows reports whether carrier picks out fewer than all of the +// document's Unmodeled maps. A carrier that matches every one of them makes the +// assertion beside it unconditionally true, which is how a row can name the +// wrong map and still pass. +// +// Matching none of them is narrow, not broad, so it passes here and fails on the +// assertion beside this one: that is a carrier naming a map the document has +// not got, and reporting it as the vacuous case would name the opposite defect. +func carrierNarrows(sites []unmodeledSite, carrier string) bool { + all, matched := map[string]bool{}, map[string]bool{} + for _, site := range sites { + all[site.path] = true + if strings.Contains(site.path, carrier) { + matched[site.path] = true + } + } + return len(matched) < len(all) +} + +// assertRawPreservedBinary pins what a !!binary extension keeps: the base64 the +// source wrote. Decoding it and storing the bytes in a JSON string lost the +// spelling on every value and lost the data itself on any byte that is not +// valid UTF-8, since v1's encoding/json rewrote those to U+FFFD (GitHub #242). +// +// The block-form row carries the line breaks with it, because they are part of +// what the source wrote; base64.StdEncoding skips them, so a consumer resolving +// the tag reads the same bytes the decode used to store. +func assertRawPreservedBinary(t *testing.T, m *ir.Model) { + t.Helper() + for _, tc := range []struct{ key, want string }{ + {"openapi:x-blob", `"aGVsbG8="`}, + {"openapi:x-raw", `"/w=="`}, + {"openapi:x-wrapped", `"aGVs\nbG8=\n"`}, + } { + entry, found := m.Unmodeled[tc.key] + require.True(t, found, "%s is preserved; got %v", tc.key, m.Unmodeled) + assert.Equal(t, tc.want, string(entry.Value), + "%s keeps its base64 text, not the bytes it names", tc.key) + } +} + +// assertInlineAnnotations covers the positions with no ir.Property or +// ir.Parameter to carry what a declaration writes: each keeps its docs, bounds +// and x-* on a node of its own, while a position declaring nothing still +// resolves straight to the shared primitive. +func assertInlineAnnotations(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + elem := assertAnnotatedScalar(t, doc, "t/anon/components/schemas/Codes/items", 3) + require.NotNil(t, elem.XML) + assert.Equal(t, "Code", elem.XML.Name) + assert.Contains(t, elem.Unmodeled, "openapi:x-facet") + + assertAnnotatedScalar(t, doc, "t/anon/components/schemas/CodeIndex/additionalProperties", 64) + assertAnnotatedScalar(t, doc, + "t/anon/paths/~1codes/get/responses/200/content/application~1json/schema", 8192) + + bare, ok := doc.Types[namedID("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") + + op, ok := opByName(doc, "listCodes") + require.True(t, ok) + require.Len(t, op.Params, 1) + assertCarriedAnnotations(t, op.Params[0].Docs, op.Params[0].Constraints, op.Params[0].Unmodeled, 4) + require.Len(t, op.Responses[0].Headers, 1) + h := op.Responses[0].Headers[0] + assertCarriedAnnotations(t, h.Docs, h.Constraints, h.Unmodeled, 64) +} + +// assertAnnotatedScalar requires the node at id to be a Scalar carrying a +// description and the given maxLength. +func assertAnnotatedScalar(t *testing.T, doc *ir.Document, id ir.TypeID, maxLength int64) *ir.Scalar { + t.Helper() + sc, ok := doc.Types[id].(*ir.Scalar) + require.True(t, ok, "%s must own a Scalar; got %v", id, doc.Types[id]) + assert.NotEmpty(t, sc.Docs.Description) + require.NotNil(t, sc.Constraints) + require.NotNil(t, sc.Constraints.MaxLength) + assert.Equal(t, maxLength, *sc.Constraints.MaxLength) + return sc +} + +// assertCarriedAnnotations requires a parameter's or header's own carrier to +// hold what its schema declared. +func assertCarriedAnnotations(t *testing.T, docs ir.Docs, c *ir.Constraints, p ir.Unmodeled, maxLength int64) { + t.Helper() + assert.NotEmpty(t, docs.Description) + require.NotNil(t, c) + require.NotNil(t, c.MaxLength) + assert.Equal(t, maxLength, *c.MaxLength) + assert.NotEmpty(t, p, "the schema's x-* rides on the carrier too") +} diff --git a/compilers/openapi/e2e/conformance_nullability_test.go b/compilers/openapi/e2e/conformance_nullability_test.go new file mode 100644 index 00000000..0dec1aa5 --- /dev/null +++ b/compilers/openapi/e2e/conformance_nullability_test.go @@ -0,0 +1,197 @@ +package e2e_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" +) + +func assertNullabilityFourStates(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + m, ok := doc.Types[namedID("S")].(*ir.Model) + require.True(t, ok) + require.Len(t, m.Properties, 4) + states := openapitest.PropsByWire(m.Properties) + assert.True(t, states["reqPlain"].Required) + assert.False(t, states["reqPlain"].Type.Nullable) + assert.True(t, states["reqNull"].Required) + assert.True(t, states["reqNull"].Type.Nullable) + assert.False(t, states["optPlain"].Required) + assert.False(t, states["optPlain"].Type.Nullable) + assert.False(t, states["optNull"].Required) + assert.True(t, states["optNull"].Type.Nullable) +} + +func assertNullable30(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + m, ok := doc.Types[namedID("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) + 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.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") + + u, ok := doc.Types[namedID("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") + + assertCollapsedBranchHint(t, doc) +} + +// assertCollapsedBranchHint covers the {X, null} collapse's naming of the +// branch it keeps. The branch pointer is nameable from outside (BranchRef names +// it) and only the first lowering to reach it interns the node, so the collapse +// and an outside $ref must derive the same hint, or the document depends on +// which component is declared first (GitHub #281). The spec declares Collapsed +// before BranchRef on purpose: that is the order in which the collapse reaches +// the pointer first. The permutation half is the two-order oracle's +// (internal/harness). +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) + 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") + assert.Equal(t, branchID, collapsed.Base.Target) + + branch, ok := doc.Types[branchID] + require.True(t, ok, "the branch declares a description, so it owns a node of its own") + 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) + require.True(t, ok) + require.NotNil(t, ref.Base) + assert.Equal(t, branchID, ref.Base.Target, "the outside reference reaches that same node") +} + +// assertNullableEnum31 covers 3.1's spelling of a nullable enum: `null` listed +// among the members of a null-admitting type array. The member is normalized +// onto the Nullable bit of every reference to the enum rather than degrading the +// declaration to a union of literals, so the claim is the pair: a closed Enum of +// the scalar members, and null admitted at each use. The golden cannot pin the +// 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) + 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") + require.Len(t, e.Members, 2, "the null member is normalized away, the scalar members are not") + assert.Equal(t, "red", e.Members[0].Value.Str) + assert.Equal(t, "green", e.Members[1].Value.Str) + for _, d := range diags { + assert.NotEqual(t, "openapi/degraded-construct", d.Code, "normalizing is not degrading: %+v", d) + } + + m, ok := doc.Types[namedID("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, + "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, + "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") + require.True(t, ok) + assert.Equal(t, ir.TypeRef{Target: namedID("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) + 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) + 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, + "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, + "an enum excluding null must not admit it, whatever the type keyword names") +} + +// assertNullabilityConjunction covers how null admission is decided for a schema +// whose keywords disagree: JSON Schema conjoins them, so a position admits null +// when something declares it and nothing takes it away. The claim is agreement: +// the same constraint written two ways reaches one Nullable bit, which is all an +// emitter reads. +// +// Base and Mixins are asserted to stay bare on purpose. They name one side of a +// conjunction, so the bit belongs to the usage that names the whole; a +// 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) + require.True(t, ok) + props := openapitest.PropsByWire(m.Properties) + require.Len(t, props, 5) + + assert.Equal(t, props["direct"].Type.Nullable, props["viaAllOf"].Type.Nullable, + "a sole conjunct's null survives the composition that names it") + assert.True(t, props["viaAllOf"].Type.Nullable) + assert.Equal(t, props["directModel"].Type.Nullable, props["viaAllOfModel"].Type.Nullable, + "a model target has no second hop to recover the null from") + assert.True(t, props["viaAllOfModel"].Type.Nullable) + assert.False(t, props["viaAllOfBoth"].Type.Nullable, + "one conjunct forbidding null decides the conjunction") + + for _, name := range []string{"WrapNullableScalar", "WrapNullableModel", "WrapBoth"} { + wrap, isModel := doc.Types[namedID(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) + } + for i, mix := range wrap.Mixins { + assert.False(t, mix.Nullable, "%s.Mixins[%d] names a conjunct, not a usage", name, i) + } + } + + plain, ok := doc.Types[namedID("PlainUnion")].(*ir.Union) + require.True(t, ok) + distributed, ok := doc.Types[namedID("DistributedUnion")].(*ir.Union) + require.True(t, ok) + require.Len(t, plain.Variants, 2) + require.Len(t, distributed.Variants, 2) + for i := range plain.Variants { + assert.Equal(t, plain.Variants[i].Type.Nullable, distributed.Variants[i].Type.Nullable, + "variant %d: distributing a body that declares no type does not change what its branch admits", i) + } + assert.True(t, plain.Variants[0].Type.Nullable, "the nullable branch is the one that admits null") + assert.False(t, plain.Variants[1].Type.Nullable, "and the plain one is not") +} diff --git a/compilers/openapi/e2e/conformance_operations_test.go b/compilers/openapi/e2e/conformance_operations_test.go new file mode 100644 index 00000000..11ec8a5e --- /dev/null +++ b/compilers/openapi/e2e/conformance_operations_test.go @@ -0,0 +1,173 @@ +package e2e_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir" +) + +func assertTagsGrouping(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + require.Len(t, doc.Services, 1) + require.Len(t, doc.Services[0].Groups, 1) + assert.Equal(t, "pets", doc.Services[0].Groups[0].Name.Source) + require.Len(t, doc.TagDefs, 1) + assert.Equal(t, "pets", doc.TagDefs[0].Name) +} + +// assertGroupIdentity pins what identifies a group: the rule that formed it and +// the key that rule read, never its name. Two pairs among the seven render from +// the same words — the declared tag "default" and the group untagged operations +// fall into, the tag "webhooks" and the webhook group — and two tags differ only +// by what RFC 6901 escapes. Each keeps an ID of its own. The IDs are written out +// rather than derived, so the test cannot agree with a change to the derivation +// (GitHub #673). +func assertGroupIdentity(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + require.Len(t, doc.Services, 1) + got := map[ir.GroupID][]string{} + for _, g := range doc.Services[0].Groups { + require.NotContains(t, got, g.ID, "two groups share an ID") + for _, op := range g.Operations { + got[g.ID] = append(got[g.ID], op.Name.Source) + } + } + assert.Equal(t, map[ir.GroupID][]string{ + "g/tags/default": {"declared"}, + "g/default": {"untagged"}, + "g/tags/webhooks": {"namedLikeWebhooks"}, + "g/tags/ghost": {"undeclared"}, + "g/tags/a~1b": {"slash"}, + "g/tags/a~01b": {"tilde"}, + "g/webhooks": {"onEvent"}, + }, got) +} + +// assertParamIdentity pins what identifies a parameter: the operation it is +// mounted under, its name as one escaped token, and its location. Two +// parameters named id in one operation get two IDs; a path-item parameter is one +// parameter per operation it is copied into; a path item reached from two paths +// roots each mount's parameters at that mount. The IDs are written out rather +// than derived, so the test cannot agree with a change to the derivation, and +// every binding must name its own parameter's ID. +func assertParamIdentity(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + require.Len(t, doc.Services, 1) + got := map[ir.OpID][]ir.ParamID{} + svc := doc.Services[0] + for _, g := range svc.Groups { + for _, op := range g.Operations { + for _, p := range op.Params { + got[op.ID] = append(got[op.ID], p.ID) + } + require.Len(t, op.Bindings.HTTP, 1) + var bound []ir.ParamID + for _, pb := range op.Bindings.HTTP[0].ParamBindings { + bound = append(bound, pb.Param) + } + assert.ElementsMatch(t, got[op.ID], bound, "%s: each binding names its own parameter", op.ID) + } + } + assert.Equal(t, map[ir.OpID][]ir.ParamID{ + "op/openapi/paths/~1items~1{id}/get": { + "param/openapi/paths/~1items~1{id}/get/parameters/id/query", + "param/openapi/paths/~1items~1{id}/get/parameters/a~1b/query", + "param/openapi/paths/~1items~1{id}/get/parameters/id/path", + }, + "op/openapi/paths/~1items~1{id}/post": { + "param/openapi/paths/~1items~1{id}/post/parameters/id/path", + }, + "op/openapi/paths/~1a/get": {"param/openapi/paths/~1a/get/parameters/limit/query"}, + "op/openapi/paths/~1b/get": {"param/openapi/paths/~1b/get/parameters/limit/query"}, + }, got) +} + +// assertEnumMemberIdentity pins what identifies an enum member: the value it +// holds, tagged by kind, under the enum that declares it. The list repeats a +// value, includes names that spell one canonical word sequence, and carries the +// characters a pointer token escapes, so a derivation from the name, the +// position or the unescaped value changes an ID. The IDs are written out, not +// derived. The repeat is kept, its ID ends in its occurrence number, and it is +// reported once at its own entry; the string spelling that ID's tail is no +// repeat. +func assertEnumMemberIdentity(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + memberIDs := func(id ir.TypeID) []ir.EnumMemberID { + enum, ok := doc.Types[id].(*ir.Enum) + require.True(t, ok, "%s is an enum", id) + got := make([]ir.EnumMemberID, 0, len(enum.Members)) + for _, m := range enum.Members { + got = append(got, m.ID) + } + return got + } + assert.Equal(t, []ir.EnumMemberID{ + "e/openapi/components/schemas/E/s:a-b", + "e/openapi/components/schemas/E/s:a_b", + "e/openapi/components/schemas/E/s:A B", + "e/openapi/components/schemas/E/s:a~0b", + "e/openapi/components/schemas/E/s:a-b/2", + "e/openapi/components/schemas/E/s:a-b~12", + }, memberIDs("t/openapi/components/schemas/E")) + assert.Equal(t, []ir.EnumMemberID{ + "e/openapi/components/schemas/N/n:-1e0", + "e/openapi/components/schemas/N/n:1e0", + "e/openapi/components/schemas/N/n:0", + }, memberIDs("t/openapi/components/schemas/N")) + assert.Equal(t, []ir.EnumMemberID{ + "e/anon/paths/~1status/get/responses/200/content/application~1json/schema/s:open", + "e/anon/paths/~1status/get/responses/200/content/application~1json/schema/s:closed", + }, memberIDs("t/anon/paths/~1status/get/responses/200/content/application~1json/schema")) + + var repeats []string + for _, d := range diags { + if d.Code == "openapi/duplicate-enum-value" { + repeats = append(repeats, string(d.Provenance.Pointer)) + } + } + assert.Equal(t, []string{"/components/schemas/E/enum/4"}, repeats) +} + +func assertHTTPBinding(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + op, ok := opByName(doc, "getItem") + require.True(t, ok) + require.Len(t, op.Bindings.HTTP, 1) + hb := op.Bindings.HTTP[0] + assert.Equal(t, "GET", hb.Method) + assert.Equal(t, "/items/{id}", hb.URITemplate) +} + +// assertTagsGrouping32 is the 3.2 counterpart of assertTagsGrouping, and the +// first case in the corpus to witness OperationGroup.Groups: a tag declaring a +// parent nests its group under that parent's, an operation's non-navigational +// tag does not become the group it is filed under, and the declared 3.2 tag +// metadata reaches the registry verbatim. +func assertTagsGrouping32(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + require.Len(t, doc.TagDefs, 4) + require.Equal(t, "books", doc.TagDefs[0].Name, "TagDefs keep the document's declaration order") + assert.Equal(t, "catalog", doc.TagDefs[0].Parent) + assert.Equal(t, "nav", doc.TagDefs[0].Kind) + assert.Equal(t, "nav", doc.TagDefs[1].Kind) + assert.Equal(t, "badge", doc.TagDefs[2].Kind, "the non-navigational kind is recorded, not interpreted") + assert.Empty(t, doc.TagDefs[2].Parent) + + require.Len(t, doc.Services, 1) + svc := doc.Services[0] + require.Len(t, svc.Groups, 1, "the child nests rather than becoming a second top-level group") + catalog := svc.Groups[0] + assert.Equal(t, "catalog", catalog.Name.Source) + assert.Equal(t, "Everything the library holds", catalog.Docs.Description, + "an ancestor created first on its child's behalf still carries its declared docs") + require.Len(t, catalog.Operations, 1) + assert.Equal(t, "showCatalog", catalog.Operations[0].Name.Source) + + require.Len(t, catalog.Groups, 2, "both children nest, in the order operations first reach them") + assert.Equal(t, "magazines", catalog.Groups[1].Name.Source) + books := catalog.Groups[0] + assert.Equal(t, "books", books.Name.Source) + assert.Equal(t, "Book operations", books.Docs.Description) + require.Len(t, books.Operations, 1) + listBooks := books.Operations[0] + assert.Equal(t, "listBooks", listBooks.Name.Source) + assert.Equal(t, []string{"beta", "books"}, listBooks.Tags, + "tag membership keeps every tag the operation named, badge included") +} diff --git a/compilers/openapi/e2e/conformance_params_test.go b/compilers/openapi/e2e/conformance_params_test.go new file mode 100644 index 00000000..8b0811ac --- /dev/null +++ b/compilers/openapi/e2e/conformance_params_test.go @@ -0,0 +1,555 @@ +package e2e_test + +import ( + "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" +) + +func assertParamStyles(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "search") + require.True(t, ok) + require.Len(t, op.Bindings.HTTP, 1) + byParam := map[string]ir.HTTPParamBinding{} + for _, pb := range op.Bindings.HTTP[0].ParamBindings { + byParam[pb.WireName] = pb + } + q := byParam["q"] + assert.Equal(t, "form", q.Style, "query default style is form") + require.NotNil(t, q.Explode) + assert.True(t, *q.Explode, "form default explodes") + assert.Equal(t, "deepObject", byParam["filter"].Style) + assert.Equal(t, "application/json", byParam["payload"].ContentType, "content-style param records its media type") + assert.True(t, byParam["path"].AllowReserved, + "reserved characters passing through unescaped is a wire fact, not a style") + assert.False(t, q.AllowReserved, "and the default is to escape them") + + // "path" is a query parameter in this 3.1 document, the one location the + // keyword belongs to before 3.2 — so a declaration of it, which the + // assertion above pins, is not the off-query case the warning reports. + assert.Zero(t, openapitest.CountDiagsAt(diags, diag.InapplicableLocationKeyword, ir.SeverityWarning), + "a 3.1 query declaration of allowReserved stays silent") + + assertAllowEmptyValueKept(t, op) + + // The path item's shared parameter merges into both of its operations and + // 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") + require.True(t, ok, "search inherits the path-item-level parameter") + clearReqID, ok := paramByName(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") + assert.Equal(t, ir.TypeID("t/anon/paths/~1search/parameters/0/schema"), searchReqID.Type.Target, + "the shared schema is hoisted at the path item's own pointer, not a per-operation one") +} + +// assertAllowEmptyValueKept covers the one serialization flag ir.HTTPParamBinding +// has no field for. Style, explode, allowReserved and the content-style media +// type all land on the binding above; allowEmptyValue used to be read nowhere at +// all, so a document declaring it produced IR indistinguishable from one that +// did not (GitHub #39). It is kept on the logical Parameter, which is the +// carrier at this position with an Unmodeled map. +func assertAllowEmptyValueKept(t *testing.T, op ir.Operation) { + t.Helper() + bare, ok := paramByName(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") + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + assert.JSONEq(t, `true`, string(entry.Value)) +} + +// paramSlot is what OpenAPI identifies a parameter by: its name and location. +// Two parameters may legally share a name at different locations, so a map +// keyed on the name alone silently keeps one of such a pair and compares the +// other against nothing. +type paramSlot struct { + Name string + Location ir.HTTPLocation +} + +// paramWire is what a parameter's location-dependent serialization resolves to: +// the style it serializes with, and whether it explodes. The two travel together +// because OpenAPI settles them together — the location picks the style when none +// is written, and the style picks explode. +type paramWire struct { + Style string + Explode bool +} + +// paramStylePair is one legal (in, style) combination of the Parameter object. +type paramStylePair struct { + location ir.HTTPLocation + style string +} + +// param names the fixture parameter declaring this pair with one explode +// spelling: "