diff --git a/.azure-pipelines/ci-build.yml b/.azure-pipelines/ci-build.yml index 8bef85221..5e8a40e98 100644 --- a/.azure-pipelines/ci-build.yml +++ b/.azure-pipelines/ci-build.yml @@ -21,6 +21,8 @@ pr: variables: buildPlatform: 'Any CPU' buildConfiguration: 'Release' + NuGetOrganizationName: 'openapinet' + privateFeedBaseUrl: 'https://microsoftgraph.pkgs.visualstudio.com/0985d294-5762-4bc2-a565-161ef349ca3e/_packaging/GraphDeveloperExperiences_Public' ProductBinPath: '$(Build.SourcesDirectory)\src\Microsoft.OpenApi\bin\$(BuildConfiguration)' REGISTRY: 'msgraphprodregistry.azurecr.io' IMAGE_NAME: 'public/openapi/hidi' @@ -69,7 +71,7 @@ extends: - task: UseDotNet@2 displayName: 'Use .NET 10' inputs: - version: 10.x + useGlobalJson: true # Install the nuget tool. - task: NuGetToolInstaller@1 @@ -79,6 +81,20 @@ extends: checkLatest: true # Build the Product project + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to Azure Artifacts' + + - pwsh: | + @" + + + + + + + + "@ | Set-Content -Path "$(Build.SourcesDirectory)/nuget.config" -Encoding UTF8 + displayName: 'Create nuget.config (central feed)' - task: DotNetCoreCLI@2 displayName: 'build' inputs: @@ -86,14 +102,18 @@ extends: arguments: '--configuration $(BuildConfiguration) --no-incremental' # Run the Unit test - - task: DotNetCoreCLI@2 + - pwsh: | + dotnet test "$(Build.SourcesDirectory)\Microsoft.OpenApi.slnx" ` + --configuration $(BuildConfiguration) ` + --no-build ` + -- ` + --report-azdo ` + --publish-azdo-test-results ` + --results-directory="$(Agent.TempDirectory)\TestResults" ` + --minimum-expected-tests 1 displayName: 'test' - inputs: - command: test - projects: '$(Build.SourcesDirectory)\Microsoft.OpenApi.slnx' - arguments: '--configuration $(BuildConfiguration) --no-build' - - task: EsrpCodeSigning@5 + - task: EsrpCodeSigning@6 displayName: 'ESRP CodeSigning binaries' inputs: ConnectedServiceName: 'Federated DevX ESRP Managed Identity Connection' @@ -159,7 +179,7 @@ extends: - pwsh: dotnet pack $(Build.SourcesDirectory)/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj -o $(Build.ArtifactStagingDirectory) --configuration $(BuildConfiguration) --no-build --include-symbols --include-source /p:SymbolPackageFormat=snupkg displayName: 'pack Hidi' - - task: EsrpCodeSigning@5 + - task: EsrpCodeSigning@6 displayName: 'ESRP CodeSigning Nuget Packages' inputs: ConnectedServiceName: 'Federated DevX ESRP Managed Identity Connection' @@ -198,7 +218,7 @@ extends: displayName: publish Hidi as executable inputs: command: 'publish' - arguments: -c Release --runtime win-x64 /p:PublishSingleFile=true /p:PackAsTool=false --self-contained --output $(Build.ArtifactStagingDirectory)/Microsoft.OpenApi.Hidi + arguments: -c Release --runtime win-x64 -p:RestoreConfigFile=$(Build.SourcesDirectory)\nuget.config /p:PublishSingleFile=true /p:PackAsTool=false --self-contained --output $(Build.ArtifactStagingDirectory)/Microsoft.OpenApi.Hidi projects: 'src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj' publishWebProjects: False zipAfterPublish: false @@ -208,7 +228,16 @@ extends: inputs: targetFolder: $(Build.ArtifactStagingDirectory)/Nugets sourceFolder: $(Build.ArtifactStagingDirectory) - content: '*.nupkg' + Contents: | + *.nupkg + *.snupkg + + - task: CopyFiles@2 + displayName: 'Include version-check script in Nugets artifact' + inputs: + SourceFolder: '$(Build.SourcesDirectory)/scripts' + Contents: 'check-nuget-package-published.ps1' + TargetFolder: '$(Build.ArtifactStagingDirectory)/Nugets/scripts' # Copy repository files to be used in the deploy stage - task: CopyFiles@2 @@ -247,13 +276,46 @@ extends: pool: vmImage: ubuntu-latest steps: - - task: 1ES.PublishNuget@1 - displayName: 'NuGet push' + - task: PowerShell@2 + displayName: 'Check whether NuGet package version already published (idempotent)' + inputs: + targetType: filePath + filePath: '$(Pipeline.Workspace)/scripts/check-nuget-package-published.ps1' + arguments: '-PackageId "Microsoft.OpenApi.Hidi" -PackageDirectory "$(Pipeline.Workspace)" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"' + pwsh: true + env: + FEED_ACCESS_TOKEN: $(System.AccessToken) + - task: CopyFiles@2 + displayName: 'Stage Hidi NuGet packages for ESRP release' + condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true')) + inputs: + SourceFolder: '$(Pipeline.Workspace)' + Contents: | + Microsoft.OpenApi.Hidi.*.nupkg + Microsoft.OpenApi.Hidi.*.snupkg + TargetFolder: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/hidi' + CleanTargetFolder: true + - task: EsrpRelease@14 + displayName: 'ESRP Release - Hidi NuGet' + condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true')) inputs: - packagesToPush: '$(Pipeline.Workspace)/Microsoft.OpenApi.Hidi.*.nupkg' - packageParentPath: '$(Pipeline.Workspace)' - nuGetFeedType: external - publishFeedCredentials: 'OpenAPI Nuget Connection' + connectedservicename: 'Federated DevX ESRP Managed Identity Connection' + usemanagedidentity: false + keyvaultname: 'akv-prod-eastus' + authcertname: 'ReferenceLibraryPrivateCert' + signcertname: 'ReferencePackagePublisherCertificate' + clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8' + intent: 'packagedistribution' + contenttype: 'NuGet' + organizationname: '$(NuGetOrganizationName)' + contentsource: 'Folder' + folderlocation: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/hidi' + waitforreleasecompletion: true + owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com' + approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com' + serviceendpointurl: 'https://api.esrp.microsoft.com/' + mainpublisher: 'ESRPRELPACMAN' + domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - deployment: deploy_lib condition: and(contains(variables['build.SourceBranch'], 'refs/tags/v'), succeeded()) @@ -272,21 +334,48 @@ extends: pool: vmImage: ubuntu-latest steps: - - pwsh: | - $fileNames = "$(Pipeline.Workspace)/Microsoft.OpenApi.Hidi.*.nupkg", "$(Pipeline.Workspace)/Microsoft.OpenApi.YamlReader.*.nupkg", "$(Pipeline.Workspace)/Microsoft.OpenApi.Workbench.*.nupkg" - foreach($fileName in $fileNames) { - if(Test-Path $fileName) { - Remove-Item $fileName -Verbose - } - } - displayName: remove other nupkgs to avoid duplication - - task: 1ES.PublishNuget@1 - displayName: 'NuGet push' + - task: PowerShell@2 + displayName: 'Check whether NuGet package version already published (idempotent)' inputs: - packagesToPush: '$(Pipeline.Workspace)/Microsoft.OpenApi.*.nupkg' - packageParentPath: '$(Pipeline.Workspace)' - nuGetFeedType: external - publishFeedCredentials: 'OpenAPI Nuget Connection' + targetType: filePath + filePath: '$(Pipeline.Workspace)/scripts/check-nuget-package-published.ps1' + arguments: '-PackageId "Microsoft.OpenApi" -PackageDirectory "$(Pipeline.Workspace)" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"' + pwsh: true + env: + FEED_ACCESS_TOKEN: $(System.AccessToken) + - task: CopyFiles@2 + displayName: 'Stage OpenAPI NuGet packages for ESRP release' + condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true')) + inputs: + SourceFolder: '$(Pipeline.Workspace)' + Contents: | + Microsoft.OpenApi.*.nupkg + Microsoft.OpenApi.*.snupkg + !Microsoft.OpenApi.Hidi.* + !Microsoft.OpenApi.YamlReader.* + TargetFolder: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/openapi' + CleanTargetFolder: true + - task: EsrpRelease@14 + displayName: 'ESRP Release - OpenAPI NuGet' + condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true')) + inputs: + connectedservicename: 'Federated DevX ESRP Managed Identity Connection' + usemanagedidentity: false + keyvaultname: 'akv-prod-eastus' + authcertname: 'ReferenceLibraryPrivateCert' + signcertname: 'ReferencePackagePublisherCertificate' + clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8' + intent: 'packagedistribution' + contenttype: 'NuGet' + organizationname: '$(NuGetOrganizationName)' + contentsource: 'Folder' + folderlocation: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/openapi' + waitforreleasecompletion: true + owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com' + approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com' + serviceendpointurl: 'https://api.esrp.microsoft.com/' + mainpublisher: 'ESRPRELPACMAN' + domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - deployment: deploy_yaml_reader condition: and(contains(variables['build.SourceBranch'], 'refs/tags/v'), succeeded()) @@ -305,13 +394,46 @@ extends: pool: vmImage: ubuntu-latest steps: - - task: 1ES.PublishNuget@1 - displayName: 'NuGet push' + - task: PowerShell@2 + displayName: 'Check whether NuGet package version already published (idempotent)' + inputs: + targetType: filePath + filePath: '$(Pipeline.Workspace)/scripts/check-nuget-package-published.ps1' + arguments: '-PackageId "Microsoft.OpenApi.YamlReader" -PackageDirectory "$(Pipeline.Workspace)" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"' + pwsh: true + env: + FEED_ACCESS_TOKEN: $(System.AccessToken) + - task: CopyFiles@2 + displayName: 'Stage YAML reader NuGet packages for ESRP release' + condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true')) inputs: - packagesToPush: '$(Pipeline.Workspace)/Microsoft.OpenApi.YamlReader.*.nupkg' - packageParentPath: '$(Pipeline.Workspace)' - nuGetFeedType: external - publishFeedCredentials: 'OpenAPI Nuget Connection' + SourceFolder: '$(Pipeline.Workspace)' + Contents: | + Microsoft.OpenApi.YamlReader.*.nupkg + Microsoft.OpenApi.YamlReader.*.snupkg + TargetFolder: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/yaml-reader' + CleanTargetFolder: true + - task: EsrpRelease@14 + displayName: 'ESRP Release - YAML reader NuGet' + condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true')) + inputs: + connectedservicename: 'Federated DevX ESRP Managed Identity Connection' + usemanagedidentity: false + keyvaultname: 'akv-prod-eastus' + authcertname: 'ReferenceLibraryPrivateCert' + signcertname: 'ReferencePackagePublisherCertificate' + clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8' + intent: 'packagedistribution' + contenttype: 'NuGet' + organizationname: '$(NuGetOrganizationName)' + contentsource: 'Folder' + folderlocation: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/yaml-reader' + waitforreleasecompletion: true + owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com' + approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com' + serviceendpointurl: 'https://api.esrp.microsoft.com/' + mainpublisher: 'ESRPRELPACMAN' + domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - deployment: create_github_release condition: and(contains(variables['build.SourceBranch'], 'refs/tags/v'), succeeded()) @@ -441,7 +563,33 @@ extends: displayName: 'Get current date' name: setdate condition: eq(variables['Build.SourceBranch'], variables['PREVIEW_BRANCH']) - + + # Keep feed credentials out of the Docker build context and image layers. + - pwsh: | + if ([string]::IsNullOrWhiteSpace($env:FEED_ACCESS_TOKEN)) { + throw "No Azure Artifacts access token available for the Docker build." + } + $feedAccessToken = [System.Security.SecurityElement]::Escape($env:FEED_ACCESS_TOKEN) + @" + + + + + + + + + + + + + + + "@ | Set-Content -Path "$(Agent.TempDirectory)/hidi-docker.nuget.config" -Encoding UTF8 + displayName: 'Create Docker NuGet config (central feed)' + env: + FEED_ACCESS_TOKEN: $(System.AccessToken) + - script: | docker run --privileged --rm msgraphprodregistry.azurecr.io/tonistiigi/binfmt --install all displayName: "Enable multi-platform builds" @@ -460,6 +608,7 @@ extends: # Using quotes around tags to prevent flag interpretation docker buildx build \ --platform linux/amd64,linux/arm64/v8 \ + --secret id=nuget_config,src="$(Agent.TempDirectory)/hidi-docker.nuget.config" \ --push \ -t "$(REGISTRY)/$(IMAGE_NAME):nightly" \ -t "$(REGISTRY)/$(IMAGE_NAME):${VERSION}.${BUILDDATE}${RUNNUMBER}" \ @@ -472,6 +621,7 @@ extends: echo "Building Docker image for release..." docker buildx build\ --platform linux/amd64,linux/arm64/v8 \ + --secret id=nuget_config,src="$(Agent.TempDirectory)/hidi-docker.nuget.config" \ --push \ -t "$(REGISTRY)/$(IMAGE_NAME):latest" \ -t "$(REGISTRY)/$(IMAGE_NAME):${VERSION}" \ @@ -479,6 +629,14 @@ extends: displayName: 'Build and Push Release Image' condition: contains(variables['Build.SourceBranch'], 'refs/tags/v') + - pwsh: | + $configPath = "$(Agent.TempDirectory)/hidi-docker.nuget.config" + if (Test-Path $configPath) { + Remove-Item $configPath -Force + } + displayName: 'Remove Docker NuGet config' + condition: always() + # once the nuget has been released, fill this form to get the public documentation updated. # https://dev.azure.com/msft-skilling/Content/_workitems/create/User%20Story?templateId=39fb91e3-64a2-4c8a-83db-b2bdf3603dd3&ownerId=c4a28f90-17ae-4384-b514-7273392b082b # https://learn.microsoft.com/en-us/dotnet/api/microsoft.openapi diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..16abc6aef --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,11 @@ +{ + "name": "OpenAPI.NET", + "image": "mcr.microsoft.com/devcontainers/dotnet:10.0", + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csdevkit" + ] + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c3da60bcb..22ae8e30f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,30 +1,48 @@ version: 2 updates: - - package-ecosystem: "github-actions" - # default location of `.github/workflows` - directory: "/" - open-pull-requests-limit: 10 - schedule: - interval: "daily" - - package-ecosystem: "nuget" - # location of package manifests - directory: "/" - open-pull-requests-limit: 10 - schedule: - interval: "daily" - groups: - MicrosoftExtensions: - patterns: - - "Microsoft.Extensions.*" - coverlet: - patterns: - - "coverlet.*" - - package-ecosystem: dotnet-sdk - directory: / - schedule: - interval: "daily" - ignore: - - dependency-name: '*' - update-types: - - version-update:semver-major - - version-update:semver-minor +- package-ecosystem: github-actions + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + groups: + codeql: + patterns: + - github/codeql-action* + cooldown: + default-days: 7 +- package-ecosystem: nuget + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + groups: + MicrosoftExtensions: + patterns: + - Microsoft.Extensions.* + testing: + patterns: + - coverlet.* + - Microsoft.NET.Test.Sdk + - Microsoft.Testing.* + - xunit.* + cooldown: + default-days: 7 +- package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: daily + ignore: + - dependency-name: '*' + update-types: + - version-update:semver-major + - version-update:semver-minor + cooldown: + default-days: 7 +- package-ecosystem: devcontainers + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + cooldown: + default-days: 7 diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml index 9d9039433..f6dafa170 100644 --- a/.github/workflows/auto-merge-dependabot.yml +++ b/.github/workflows/auto-merge-dependabot.yml @@ -19,14 +19,14 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v3.1.0 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs # Only if version bump is not a major version change if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} - run: gh pr merge --auto --merge "$PR_URL" + run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index fb99868ee..4ae9e6ae0 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -18,18 +18,18 @@ jobs: GITHUB_RUN_NUMBER: ${{ github.run_number }} steps: - name: Setup .NET 8 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.x - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.x - name: Checkout repository id: checkout_repo - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} token: ${{ secrets.GITHUB_TOKEN }} @@ -45,7 +45,7 @@ jobs: id: run_unit_tests shell: pwsh run: | - dotnet test Microsoft.OpenApi.slnx -c Release --no-build -v n --collect:"XPlat Code Coverage" + dotnet test --solution Microsoft.OpenApi.slnx -c Release -v n --results-directory=./TestResults --coverlet --coverlet-output-format cobertura --report-gh - name: Install report generator shell: pwsh @@ -55,7 +55,7 @@ jobs: - name: Generate coverage report shell: pwsh run: | - reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./reports/coverage -reporttypes:"Html;MarkdownSummaryGithub;Cobertura" + reportgenerator -reports:./TestResults/**/coverage.cobertura.*.xml -targetdir:./reports/coverage -reporttypes:"Html;MarkdownSummaryGithub;Cobertura" - name: Add coverage to job summary shell: bash @@ -64,14 +64,14 @@ jobs: - name: Upload coverage report if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') || (github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch) - uses: actions/upload-code-coverage@v1 + uses: actions/upload-code-coverage@2b21a77928be8d5168c2b9581a67f2adbebacc52 # v1.4.4 with: file: ./reports/coverage/Cobertura.xml language: CSharp label: code-coverage/dotnet - name: Upload coverage artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage path: reports/coverage @@ -80,15 +80,15 @@ jobs: name: Validate Project for Trimming runs-on: windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET 8 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.x - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.x @@ -102,15 +102,15 @@ jobs: needs: [ci] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET 8 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.x - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.x @@ -125,7 +125,7 @@ jobs: working-directory: ./performance/benchmark - name: Publish benchmark results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error name: benchmark-results diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8ae3c621e..b2311adf8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,21 +20,21 @@ jobs: steps: - name: Checkout repository id: checkout_repo - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET 8 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.0.x - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x - name: Initialize CodeQL id: init_codeql - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: queries: security-and-quality @@ -54,6 +54,6 @@ jobs: - name: Perform CodeQL Analysis id: analyze_codeql - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 # Built with ❤ by [Pipeline Foundation](https://pipeline.foundation) \ No newline at end of file diff --git a/.github/workflows/promote-shipped-apis.yml b/.github/workflows/promote-shipped-apis.yml index 569a73905..825b8944f 100644 --- a/.github/workflows/promote-shipped-apis.yml +++ b/.github/workflows/promote-shipped-apis.yml @@ -7,6 +7,10 @@ on: - support/v2 workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: promote-apis: runs-on: ubuntu-latest @@ -16,13 +20,22 @@ jobs: steps: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.RELEASE_PLEASE_TOKEN_PROVIDER_APP_ID }} private-key: ${{ secrets.RELEASE_PLEASE_TOKEN_PROVIDER_PEM }} + permission-contents: write + permission-pull-requests: write + + - name: Get GitHub App user ID + id: get-user-id + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} @@ -32,9 +45,9 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global url."https://$($env:GH_TOKEN)@github.com/".insteadOf "https://github.com/" + git config --global user.name "${{ steps.app-token.outputs.app-slug }}[bot]" + git config --global user.email "${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" + git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" - name: Check for existing PR id: check_pr diff --git a/.github/workflows/release-please-gha.yml b/.github/workflows/release-please-gha.yml index a6fdc1927..74a6233fb 100644 --- a/.github/workflows/release-please-gha.yml +++ b/.github/workflows/release-please-gha.yml @@ -34,7 +34,7 @@ jobs: if: needs.check-secret.outputs.has-token == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate PublicAPI.Unshipped.txt files are empty shell: pwsh @@ -62,13 +62,13 @@ jobs: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.RELEASE_PLEASE_TOKEN_PROVIDER_APP_ID }} private-key: ${{ secrets.RELEASE_PLEASE_TOKEN_PROVIDER_PEM }} - name: Release Please - uses: googleapis/release-please-action@v5 + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: token: ${{ steps.app-token.outputs.token }} config-file: release-please-config.json diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index fe5afd83a..a6954d70d 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -35,23 +35,23 @@ jobs: runs-on: windows-latest steps: - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: - distribution: 'adopt' + distribution: 'temurin' java-version: 17 - name: Setup .NET 8 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.0.x - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Cache SonarCloud packages - uses: actions/cache@v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar @@ -61,12 +61,10 @@ jobs: - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - CollectCoverage: true - CoverletOutputFormat: 'opencover' # https://github.com/microsoft/vstest/issues/4014#issuecomment-1307913682 shell: pwsh run: | - dotnet tool run dotnet-sonarscanner begin /k:"microsoft_OpenAPI.NET" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="test/**/coverage.opencover.xml" + dotnet tool run dotnet-sonarscanner begin /k:"microsoft_OpenAPI.NET" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover*.xml" dotnet workload restore dotnet build - dotnet test Microsoft.OpenApi.slnx --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - dotnet tool run dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ No newline at end of file + dotnet test --solution Microsoft.OpenApi.slnx --verbosity normal --coverlet --coverlet-output-format opencover --report-gh + dotnet tool run dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/.gitignore b/.gitignore index 258fee87a..09353aba3 100644 --- a/.gitignore +++ b/.gitignore @@ -127,7 +127,7 @@ nCrunchTemp_* *.mm.* AutoTest.Net/ -# Web workbench (sass) +# Sass cache .sass-cache/ # Installshield output folder diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f391d4162..f7929cb4c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.6.0" + ".": "3.10.2" } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 66912fac4..ad2bdf11d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -49,23 +49,7 @@ "console": "internalConsole", "stopAtEntry": false, "requireExactSource": false, - }, - { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/main/debugger-launchjson.md - "name": "Launch Workbench", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/src/Microsoft.OpenApi.WorkBench/bin/Debug/net8.0-windows/Microsoft.OpenApi.Workbench.exe", - "args": [], - "cwd": "${workspaceFolder}/src/Microsoft.OpenApi.Workbench", - // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console - "console": "internalConsole", - "stopAtEntry": false - }, + }, { "name": ".NET Core Attach", "type": "coreclr", diff --git a/.vscode/settings.json b/.vscode/settings.json index 186b10bea..619467ff3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ }, "cSpell.words": [ "csdl", - "Hidi" + "Hidi", + "Xunit" ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e68597ddc..c61ccd31c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,12 +43,6 @@ "/consoleloggerparameters:NoSummary" ], "problemMatcher": "$msCompile" - }, - { - "label": "workbench", - "type": "shell", - "command": "src/Microsoft.OpenApi.WorkBench/bin/Debug/Microsoft.OpenApi.WorkBench.exe", - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f370a06c8..0077ae140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,89 @@ # Changelog +## [3.10.2](https://github.com/microsoft/OpenAPI.NET/compare/v3.10.1...v3.10.2) (2026-08-20) + + +### Bug Fixes + +* duplicate schema example when serializing to v2 ([#3045](https://github.com/microsoft/OpenAPI.NET/issues/3045)) ([50f5a2b](https://github.com/microsoft/OpenAPI.NET/commit/50f5a2bb119089bb48987494d906cb1a95cddbd1)) + +## [3.10.1](https://github.com/microsoft/OpenAPI.NET/compare/v3.10.0...v3.10.1) (2026-08-19) + + +### Bug Fixes + +* circular ref guard ([#3033](https://github.com/microsoft/OpenAPI.NET/issues/3033)) ([8018d40](https://github.com/microsoft/OpenAPI.NET/commit/8018d40aac5fc6ed5e022e6203ee917ecf36472c)) +* harden yaml parsing ([#3027](https://github.com/microsoft/OpenAPI.NET/issues/3027)) ([e0d5e9d](https://github.com/microsoft/OpenAPI.NET/commit/e0d5e9d105ad1a7b29ff6c628793614f5610f048)) + +## [3.10.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.9.0...v3.10.0) (2026-08-11) + + +### Features + +* do not ignore multiple types when serializing to 3.0 ([#2960](https://github.com/microsoft/OpenAPI.NET/issues/2960)) ([ebaf27a](https://github.com/microsoft/OpenAPI.NET/commit/ebaf27ac46f8820e7b817cc33e693632c2fc908a)) + + +### Bug Fixes + +* bound YAML anchor/alias expansion to prevent OOM (billion laughs) ([#3000](https://github.com/microsoft/OpenAPI.NET/issues/3000)) ([2179326](https://github.com/microsoft/OpenAPI.NET/commit/21793261d6860fb6e607b71fd95c0bd320724764)) + +## [3.9.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.8.0...v3.9.0) (2026-07-15) + + +### Features + +* adds support for anchor and id external resolution ([1591007](https://github.com/microsoft/OpenAPI.NET/commit/15910070f2bf3006f79e1c43dd9c2b56e45d665c)) +* **schema:** resolve bare $dynamicRef via $dynamicAnchor index ([#2913](https://github.com/microsoft/OpenAPI.NET/issues/2913)) ([eacc2fc](https://github.com/microsoft/OpenAPI.NET/commit/eacc2fc06537fbb11fe95765e4fb6df09861c9af)) +* support relative URI resolution in $dynamicRef ([#2928](https://github.com/microsoft/OpenAPI.NET/issues/2928)) ([#2945](https://github.com/microsoft/OpenAPI.NET/issues/2945)) ([821053b](https://github.com/microsoft/OpenAPI.NET/commit/821053ba02129e92868df2ae0c26551fb3d5276a)) + + +### Bug Fixes + +* adds explicit error message for invalid json pointers ([63fc55d](https://github.com/microsoft/OpenAPI.NET/commit/63fc55d69274ebd405e677983e0a72efd2b96079)) +* adds explicit error message for invalid json pointers ([bc93efe](https://github.com/microsoft/OpenAPI.NET/commit/bc93efe9d4694e89da1ea30fbc2d031e7f1318ae)) +* default mapping is not being serialized with the correct shape ([fe4a25f](https://github.com/microsoft/OpenAPI.NET/commit/fe4a25f1f25c853404540d30f11812f1208f93c9)) +* differentiate unset value from null value in OpenApiSchema.Const ([#2936](https://github.com/microsoft/OpenAPI.NET/issues/2936)) ([07b525f](https://github.com/microsoft/OpenAPI.NET/commit/07b525f568b65b5003deaccc56829f5e7e8e2641)) +* handle nullability more accurately during serialization for 3.0/2.0 ([#2933](https://github.com/microsoft/OpenAPI.NET/issues/2933)) ([0ace243](https://github.com/microsoft/OpenAPI.NET/commit/0ace243ebbabe82aacc52d49fe58f54f039bdf76)) +* validate required properties of security scheme before serialization ([#2952](https://github.com/microsoft/OpenAPI.NET/issues/2952)) ([f31b192](https://github.com/microsoft/OpenAPI.NET/commit/f31b192e9d59d39671fdfd03b4fa4d309b03021b)) + +## [3.8.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.7.0...v3.8.0) (2026-07-03) + + +### Features + +* add JsonConverter for OpenApiSchema System.Text.Json serialization ([#2915](https://github.com/microsoft/OpenAPI.NET/issues/2915)) ([2f8b3d2](https://github.com/microsoft/OpenAPI.NET/commit/2f8b3d2a160f5b44b5aeaa3d83ff2025302ce1e2)) +* **library:** support schema keywords on references ([434b2f8](https://github.com/microsoft/OpenAPI.NET/commit/434b2f8b34e4a3a27bd99e43f61692f0b00c3054)) +* **library:** support schema keywords on references ([66a9d04](https://github.com/microsoft/OpenAPI.NET/commit/66a9d04036556945ee3222849b7d071a30016b81)), closes [#2903](https://github.com/microsoft/OpenAPI.NET/issues/2903) + + +### Bug Fixes + +* Don't silently skip null assignment to OpenApiDocument.Tags ([3764142](https://github.com/microsoft/OpenAPI.NET/commit/37641424837df0b5e142ca130cb16df699bdb09f)) +* handling of nullable enums for 3.0 ([#2920](https://github.com/microsoft/OpenAPI.NET/issues/2920)) ([beb68f5](https://github.com/microsoft/OpenAPI.NET/commit/beb68f52b86c1437e50f7561d9be1ebdbe146963)) +* **library:** keep v3 schema references ref-only ([c938727](https://github.com/microsoft/OpenAPI.NET/commit/c9387274be6a6955952174d677bea0a35bda2624)) +* preserve JSON Schema 2020-12 keyword siblings on $ref schemas for OAS 3.1+ ([#2896](https://github.com/microsoft/OpenAPI.NET/issues/2896)) ([08160c8](https://github.com/microsoft/OpenAPI.NET/commit/08160c872a5f931eecd12446335c2f51fdad083e)) +* use async method for crypto flush ([6e675d9](https://github.com/microsoft/OpenAPI.NET/commit/6e675d943537312386c9683790848ad1cbacb713)) + +## [3.7.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.6.0...v3.7.0) (2026-06-10) + + +### Features + +* add contains/minContains/maxContains members ([78475e3](https://github.com/microsoft/OpenAPI.NET/commit/78475e38f7c61e349f2d2ad477b5a96a9c3df848)) +* add contains/minContains/maxContains members ([1a974f8](https://github.com/microsoft/OpenAPI.NET/commit/1a974f8dfcd7850c70d80133ceecee08f6671cd7)) +* **library:** add missing json schema properties ([9b1aed6](https://github.com/microsoft/OpenAPI.NET/commit/9b1aed61041551b06926fc2ee4e12705190e16b3)) +* **library:** add missing json schema properties ([82f84e0](https://github.com/microsoft/OpenAPI.NET/commit/82f84e072d9f6b4b5907e9435212fbfc8f82d9c7)) + + +### Bug Fixes + +* **library:** always copy unevaluated properties ([4907d1c](https://github.com/microsoft/OpenAPI.NET/commit/4907d1c1c4d5b8450d32459752e64e5beb592f46)) +* **library:** avoid false circular refs for external schema re-exports ([b635242](https://github.com/microsoft/OpenAPI.NET/commit/b635242a402c3c468eb86dcf2b6d94a05717d92e)) +* **library:** avoid false circular refs for external schema re-exports ([7a443c2](https://github.com/microsoft/OpenAPI.NET/commit/7a443c298fb87346338095b5df86f6608fb4d582)) +* **library:** remove unshipped schema extension fallback ([cf54bb3](https://github.com/microsoft/OpenAPI.NET/commit/cf54bb3e2746c0f6c7a60fde5bcc7fa1139dd8b6)) +* **library:** use version-specific schema keyword callbacks ([6e22ec6](https://github.com/microsoft/OpenAPI.NET/commit/6e22ec6948509d2e256932ee55f1781a544cb53f)) +* **library:** use x-jsonschema schema extensions ([eb1891a](https://github.com/microsoft/OpenAPI.NET/commit/eb1891a8d77915add1cdd88949b40aa43cd525b8)) + ## [3.6.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.5.5...v3.6.0) (2026-06-01) diff --git a/Directory.Build.props b/Directory.Build.props index 781552888..1971fa2c6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,17 +8,19 @@ https://github.com/Microsoft/OpenAPI.NET https://github.com/microsoft/OpenAPI.NET/releases true + OpenSource + 2027-09 http://go.microsoft.com/fwlink/?LinkID=288890 https://github.com/Microsoft/OpenAPI.NET © Microsoft Corporation. All rights reserved. OpenAPI .NET - 3.6.0 + 3.10.2 true - + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 25f1ec589..4e932b975 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-env WORKDIR /app COPY ./src ./hidi/src COPY ./Directory.Build.props ./hidi/Directory.Build.props COPY ./README.md ./hidi/README.md WORKDIR /app/hidi -RUN dotnet publish ./src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj -c Release +# CI supplies the private feed config as a secret; local builds use default NuGet sources. +RUN --mount=type=secret,id=nuget_config,target=/app/hidi/NuGet.Config \ + dotnet publish ./src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj -c Release FROM mcr.microsoft.com/dotnet/runtime:8.0-jammy-chiseled AS runtime WORKDIR /app diff --git a/Microsoft.OpenApi.slnx b/Microsoft.OpenApi.slnx index f764776bf..8356bb428 100644 --- a/Microsoft.OpenApi.slnx +++ b/Microsoft.OpenApi.slnx @@ -9,7 +9,6 @@ - diff --git a/README.md b/README.md index 3c77f909f..8622d64d8 100644 --- a/README.md +++ b/README.md @@ -91,22 +91,6 @@ In order to test the validity of an OpenApi document, we avail the following too A commandline tool for validating and transforming OpenAPI descriptions. [Installation guidelines and documentation](https://github.com/microsoft/OpenAPI.NET/blob/main/src/Microsoft.OpenApi.Hidi/readme.md) -- Microsoft.OpenApi.Workbench - - A workbench tool consisting of a GUI where you can test and convert OpenAPI descriptions in both JSON and YAML from v2-->v3 and vice versa. - - #### Installation guidelines: - 1. Clone the repo locally by running this command: - `git clone https://github.com/microsoft/OpenAPI.NET.git` - 2. Open the solution file `(.slnx)` in the root of the project with Visual Studio - 3. Navigate to the `src/Microsoft.OpenApi.Workbench` directory and set it as the startup project - 4. Run the project and you'll see a GUI pop up resembling the one below: - - - ![workbench preview](https://raw.githubusercontent.com/microsoft/OpenAPI.NET/main/docs/images/workbench.png "a screenshot of the workbench application") - - 5. Copy and paste your OpenAPI descriptions in the **Input Content** window or paste the path to the descriptions file in the **Input File** textbox and click on `Convert` to render the results. - # Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/agents.md b/agents.md new file mode 100644 index 000000000..abeed736e --- /dev/null +++ b/agents.md @@ -0,0 +1,7 @@ +# Regex handling + +- Analyze regex patterns for matching complexity and excessive backtracking, including on long and near-matching inputs. Prefer equivalent patterns with less backtracking when available, while preserving matching semantics and target-framework compatibility. +- For fixed patterns on modern targets, use source-generated regexes with explicit match timeouts (`GeneratedRegex` under `NET8_0_OR_GREATER`). +- Use conditional compilation to provide a regular `Regex` with the same pattern and an explicit match timeout for older targets. Do not duplicate regex validation with a manually maintained character scanner. +- Older-runtime regex matching may still time out under load because timeouts use wall-clock time. If consumers encounter this limitation, recommend upgrading to a modern runtime that uses the source-generated implementation. +- Keep shared patterns in constants and reference those constants in validation diagnostics and tests. diff --git a/docs/images/workbench.png b/docs/images/workbench.png deleted file mode 100644 index 898fe9b5b..000000000 Binary files a/docs/images/workbench.png and /dev/null differ diff --git a/global.json b/global.json index d895dfe5a..7494875e1 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,8 @@ { "sdk": { - "version": "10.0.300" + "version": "10.0.401" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md index 9b5931f81..95726f79a 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md @@ -1,20 +1,20 @@ ``` -BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8457/25H2/2025Update/HudsonValley2) -Snapdragon X 12-core X1E80100 3.40 GHz (Max: 3.42GHz), 1 CPU, 12 logical and 12 physical cores -.NET SDK 10.0.300 - [Host] : .NET 8.0.27 (8.0.27, 8.0.2726.22922), Arm64 RyuJIT armv8.0-a - ShortRun : .NET 8.0.27 (8.0.27, 8.0.2726.22922), Arm64 RyuJIT armv8.0-a +BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat) +AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.400 + [Host] : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3 + ShortRun : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3 Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 ``` -| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | -|------------- |-------------:|--------------:|-------------:|-----------:|-----------:|----------:|-------------:| -| PetStoreYaml | 276.3 μs | 38.27 μs | 2.10 μs | 74.2188 | 11.7188 | - | 305.91 KB | -| PetStoreJson | 112.8 μs | 2.80 μs | 0.15 μs | 41.0156 | 0.4883 | - | 168.05 KB | -| GHESYaml | 608,668.3 μs | 188,763.29 μs | 10,346.75 μs | 44000.0000 | 18000.0000 | 3000.0000 | 250121.85 KB | -| GHESJson | 244,147.6 μs | 361,794.79 μs | 19,831.19 μs | 17000.0000 | 9000.0000 | 2000.0000 | 107293.42 KB | -| GHESNextYaml | 765,440.1 μs | 23,162.26 μs | 1,269.60 μs | 79000.0000 | 20000.0000 | 3000.0000 | 443655.46 KB | -| GHESNextJson | 435,329.2 μs | 241,612.89 μs | 13,243.62 μs | 51000.0000 | 11000.0000 | 2000.0000 | 305423.41 KB | +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | +|------------- |---------------:|--------------:|-------------:|-----------:|-----------:|----------:|-------------:| +| PetStoreYaml | 569.0 μs | 108.17 μs | 5.93 μs | 19.5313 | - | - | 327.8 KB | +| PetStoreJson | 250.8 μs | 20.85 μs | 1.14 μs | 11.7188 | 1.9531 | - | 209.67 KB | +| GHESYaml | 831,169.7 μs | 217,393.05 μs | 11,916.05 μs | 18000.0000 | 10000.0000 | 2000.0000 | 267570.8 KB | +| GHESJson | 367,711.4 μs | 135,955.63 μs | 7,452.19 μs | 9000.0000 | 8000.0000 | 2000.0000 | 140917.06 KB | +| GHESNextYaml | 1,040,243.2 μs | 167,595.29 μs | 9,186.46 μs | 30000.0000 | 11000.0000 | 2000.0000 | 469507.05 KB | +| GHESNextJson | 615,173.6 μs | 143,051.39 μs | 7,841.13 μs | 22000.0000 | 10000.0000 | 2000.0000 | 345247.91 KB | diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv index 6ca713e4b..bb632a223 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv @@ -1,7 +1,7 @@ Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Gen1,Gen2,Allocated -PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,276.3 μs,38.27 μs,2.10 μs,74.2188,11.7188,0.0000,305.91 KB -PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,112.8 μs,2.80 μs,0.15 μs,41.0156,0.4883,0.0000,168.05 KB -GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"608,668.3 μs","188,763.29 μs","10,346.75 μs",44000.0000,18000.0000,3000.0000,250121.85 KB -GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"244,147.6 μs","361,794.79 μs","19,831.19 μs",17000.0000,9000.0000,2000.0000,107293.42 KB -GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"765,440.1 μs","23,162.26 μs","1,269.60 μs",79000.0000,20000.0000,3000.0000,443655.46 KB -GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"435,329.2 μs","241,612.89 μs","13,243.62 μs",51000.0000,11000.0000,2000.0000,305423.41 KB +PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,569.0 μs,108.17 μs,5.93 μs,19.5313,0.0000,0.0000,327.8 KB +PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,250.8 μs,20.85 μs,1.14 μs,11.7188,1.9531,0.0000,209.67 KB +GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"831,169.7 μs","217,393.05 μs","11,916.05 μs",18000.0000,10000.0000,2000.0000,267570.8 KB +GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"367,711.4 μs","135,955.63 μs","7,452.19 μs",9000.0000,8000.0000,2000.0000,140917.06 KB +GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"1,040,243.2 μs","167,595.29 μs","9,186.46 μs",30000.0000,11000.0000,2000.0000,469507.05 KB +GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"615,173.6 μs","143,051.39 μs","7,841.13 μs",22000.0000,10000.0000,2000.0000,345247.91 KB diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html index a6a592c7b..c9feca148 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html @@ -2,7 +2,7 @@ -performance.Descriptions-20260526-120411 +performance.Descriptions-20260819-014451 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.WPF.xaml b/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.WPF.xaml deleted file mode 100644 index 988e787b8..000000000 --- a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.WPF.xaml +++ /dev/null @@ -1,830 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Visible - - - - - - - - - - - Visible - - - - - - - - - - - - - - Visible - - - - - - - - - - - Visible - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Theme.Colors.xaml b/src/Microsoft.OpenApi.Workbench/Themes/Metro/Theme.Colors.xaml deleted file mode 100644 index 02babe07a..000000000 --- a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Theme.Colors.xaml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - #FF282828 - #FF3F3F3F - #FF565656 - #FF858585 - #FFB9B9B9 - #FFD7D7D7 - #FFE7E7E7 - #FFF4F4F4 - #FFF9F9F9 - #FFFFFFFF - - - #E5FFFFFF - #BFFFFFFF - #99FFFFFF - #72FFFFFF - #4CFFFFFF - #00FFFFFF - - - #72000000 - #4C000000 - #26000000 - #00000000 - #66E2E2E2 - - - #FF0086AF - #FF00AADE - #FF80D5EF - #FFB2E1EF - #2600AADE - - - #FFD0284C - #FFF55E7F - #FFFFCAD5 - - - #FF006481 - #FF8A9B0F - #FF3E4700 - #FFF14D0F - #FF8D2E00 - #FF81106B - #FF410135 - #FFFCA910 - #FF8D4902 - #FF037A54 - #FF003F2A - #FF154D85 - #FF02284D - #FF543511 - #FF211303 - #FF89806D - #FF393225 - #FF58458B - #FF211347 - #7FB9B9B9 - #33565656 - #7F3F3F3F - #FF686868 - #8000AADE - #CC3F3F3F - - - - #FF0092BE - #FF00AADE - #FF2BB9E5 - #FF55C8EB - #FF80D7F2 - - \ No newline at end of file diff --git a/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj b/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj index c4961cb10..5a04ce302 100644 --- a/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj +++ b/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj @@ -28,20 +28,12 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + - - - - + + @@ -71,4 +63,4 @@ - \ No newline at end of file + diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs index ee5add0a0..006d1fb3d 100644 --- a/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs +++ b/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.OpenApi.YamlReader; +using System; +using Microsoft.OpenApi.YamlReader; namespace Microsoft.OpenApi.Reader; @@ -17,4 +18,18 @@ public static void AddYamlReader(this OpenApiReaderSettings settings) settings.TryAddReader(OpenApiConstants.Yaml, yamlReader); settings.TryAddReader(OpenApiConstants.Yml, yamlReader); } + + /// + /// Adds a YAML reader for the specified format using per-reader resource limits. + /// + /// The settings to add the reader to. + /// The YAML reader settings. + public static void AddYamlReader(this OpenApiReaderSettings settings, OpenApiYamlReaderSettings yamlSettings) + { + if (settings is null) throw new ArgumentNullException(nameof(settings)); + if (yamlSettings is null) throw new ArgumentNullException(nameof(yamlSettings)); + var yamlReader = new OpenApiYamlReader(yamlSettings); + settings.TryAddReader(OpenApiConstants.Yaml, yamlReader); + settings.TryAddReader(OpenApiConstants.Yml, yamlReader); + } } diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs index 0bf2627ec..13f17d77d 100644 --- a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs +++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs @@ -7,9 +7,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.OpenApi.Reader; -using SharpYaml.Serialization; +using SharpYaml; using System; -using System.Linq; using System.Text; namespace Microsoft.OpenApi.YamlReader @@ -17,10 +16,46 @@ namespace Microsoft.OpenApi.YamlReader /// /// Reader for parsing YAML files into an OpenAPI document. /// + /// + /// Input is converted directly from SharpYaml parser events so resource limits are enforced + /// before SharpYaml's recursive YAML model loader can compose or expand the document. + /// public class OpenApiYamlReader : IOpenApiReader { private const int copyBufferSize = 4096; private static readonly OpenApiJsonReader _jsonReader = new(); + private readonly OpenApiYamlReaderSettings _yamlSettings; + + /// + /// Initializes a YAML reader using the current legacy global conversion limits. + /// + public OpenApiYamlReader() + : this(new() + { + MaxDepth = YamlConverter.MaxDepth, + MaxNodeCount = YamlConverter.MaxNodeCount, + MaxAliasExpansionNodeCount = YamlConverter.MaxAliasExpansionNodeCount, + }) + { + } + + /// + /// Initializes a YAML reader with immutable per-reader resource limits. + /// + /// The YAML reader settings. + public OpenApiYamlReader(OpenApiYamlReaderSettings settings) + { + if (settings is null) throw new ArgumentNullException(nameof(settings)); + settings.Validate(); + _yamlSettings = new() + { + MaxDepth = settings.MaxDepth, + MaxNodeCount = settings.MaxNodeCount, + MaxAliasExpansionNodeCount = settings.MaxAliasExpansionNodeCount, + MaxInputByteCount = settings.MaxInputByteCount, + MaxScalarLength = settings.MaxScalarLength, + }; + } /// public async Task ReadAsync(Stream input, @@ -29,16 +64,33 @@ public async Task ReadAsync(Stream input, CancellationToken cancellationToken = default) { if (input is null) throw new ArgumentNullException(nameof(input)); + if (settings is null) throw new ArgumentNullException(nameof(settings)); if (input is MemoryStream memoryStream) { - return UpdateFormat(Read(memoryStream, location, settings)); + return ReadCore(memoryStream, location, settings, cancellationToken); } else { using var preparedStream = new MemoryStream(); - await input.CopyToAsync(preparedStream, copyBufferSize, cancellationToken).ConfigureAwait(false); + try + { + await CopyToMemoryStreamAsync( + input, + preparedStream, + _yamlSettings.MaxInputByteCount, + cancellationToken).ConfigureAwait(false); + } + catch (OpenApiReaderException ex) + { + return new() + { + Document = null, + Diagnostic = CreateDiagnostic(new(ex)), + }; + } + preparedStream.Position = 0; - return UpdateFormat(Read(preparedStream, location, settings)); + return ReadCore(preparedStream, location, settings, cancellationToken); } } @@ -46,14 +98,22 @@ public async Task ReadAsync(Stream input, public ReadResult Read(MemoryStream input, Uri location, OpenApiReaderSettings settings) + => ReadCore(input, location, settings, CancellationToken.None); + + private ReadResult ReadCore(MemoryStream input, + Uri location, + OpenApiReaderSettings settings, + CancellationToken cancellationToken) { if (input is null) throw new ArgumentNullException(nameof(input)); if (settings is null) throw new ArgumentNullException(nameof(settings)); + cancellationToken.ThrowIfCancellationRequested(); JsonNode jsonNode; // Parse the YAML text in the stream into a sequence of JsonNodes try { + EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount); #if NET // this represents net core, net5 and up using var stream = new StreamReader(input, default, true, -1, settings.LeaveStreamOpen); @@ -61,22 +121,65 @@ public ReadResult Read(MemoryStream input, // the implementation differs and results in a null reference exception in NETFX using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings.LeaveStreamOpen); #endif - jsonNode = LoadJsonNodesFromYamlDocument(stream); + jsonNode = LoadJsonNodesFromYamlDocument(stream, cancellationToken); } catch (JsonException ex) { - var diagnostic = new OpenApiDiagnostic(); - diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message)); - diagnostic.Format = OpenApiConstants.Yaml; return new() { Document = null, - Diagnostic = diagnostic, + Diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message)), + }; + } + catch (OpenApiReaderException ex) + { + return new() + { + Document = null, + Diagnostic = CreateDiagnostic(new(ex)), }; } + cancellationToken.ThrowIfCancellationRequested(); return UpdateFormat(Read(jsonNode, location, settings)); } + + private static async Task CopyToMemoryStreamAsync( + Stream input, + MemoryStream output, + uint maxInputByteCount, + CancellationToken cancellationToken) + { + var buffer = new byte[copyBufferSize]; + long totalBytesRead = 0; + int bytesRead; + while ((bytesRead = await input.ReadAsync( + buffer, + 0, + buffer.Length, + cancellationToken).ConfigureAwait(false)) > 0) + { + if (bytesRead > (long)maxInputByteCount - totalBytesRead) + { + throw CreateInputLimitException(maxInputByteCount); + } + + await output.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + totalBytesRead += bytesRead; + } + } + + private static void EnsureInputWithinLimit(MemoryStream input, uint maxInputByteCount) + { + if (input.Length - input.Position > maxInputByteCount) + { + throw CreateInputLimitException(maxInputByteCount); + } + } + + private static OpenApiReaderException CreateInputLimitException(uint maxInputByteCount) + => new($"The YAML input exceeds the maximum supported size of {maxInputByteCount} bytes."); + private static ReadResult UpdateFormat(ReadResult result) { result.Diagnostic ??= new OpenApiDiagnostic(); @@ -103,13 +206,22 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett // Parse the YAML try { - using var stream = new StreamReader(input); - jsonNode = LoadJsonNodesFromYamlDocument(stream); + EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount); +#if NET + using var stream = new StreamReader(input, default, true, -1, settings?.LeaveStreamOpen ?? false); +#else + using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings?.LeaveStreamOpen ?? false); +#endif + jsonNode = LoadJsonNodesFromYamlDocument(stream, CancellationToken.None); } catch (JsonException ex) { - diagnostic = new(); - diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message)); + diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message)); + return default; + } + catch (OpenApiReaderException ex) + { + diagnostic = CreateDiagnostic(new(ex)); return default; } @@ -123,20 +235,34 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett } /// - /// Helper method to turn streams into a sequence of JsonNodes + /// Converts the first YAML document in a stream into a JSON node. /// /// Stream containing YAML formatted text - /// Instance of a YamlDocument - static JsonNode LoadJsonNodesFromYamlDocument(TextReader input) + /// Propagates notification that parsing should be cancelled. + /// The converted JSON node. + private JsonNode LoadJsonNodesFromYamlDocument(TextReader input, CancellationToken cancellationToken) { - var yamlStream = new YamlStream(); - yamlStream.Load(input); - if (yamlStream.Documents.Any() && yamlStream.Documents[0].ToJsonNode() is { } jsonNode) + try + { + return new YamlJsonParser(_yamlSettings).Parse(input, cancellationToken); + } + catch (YamlException ex) { - return jsonNode; + var location = ex.Start.Line >= 0 + ? $" at line {ex.Start.Line + 1}, column {ex.Start.Column + 1}" + : string.Empty; + throw new OpenApiReaderException($"Unable to parse the YAML document{location}: {ex.Message}", ex); } + } - throw new InvalidOperationException("No documents found in the YAML stream."); + private static OpenApiDiagnostic CreateDiagnostic(OpenApiError error) + { + var diagnostic = new OpenApiDiagnostic + { + Format = OpenApiConstants.Yaml, + }; + diagnostic.Errors.Add(error); + return diagnostic; } } } diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs new file mode 100644 index 000000000..b525f65c6 --- /dev/null +++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs @@ -0,0 +1,73 @@ +using System; + +namespace Microsoft.OpenApi.YamlReader; + +/// +/// Configures resource limits for an . +/// +public sealed class OpenApiYamlReaderSettings +{ + /// + /// Default maximum number of input bytes read from a single YAML document (128 MiB). + /// Bounds the buffered copy of a non-seekable stream, so an endless or oversized response body + /// cannot exhaust memory before parsing begins. + /// + public const uint DefaultMaxInputByteCount = 128 * 1024 * 1024; + + /// + /// Default maximum length of a single YAML scalar value (65,536 UTF-16 code units). + /// Bounds the cost of any one key, string, number, date or block literal. For reference, the + /// longest scalar in the Microsoft Graph beta description is 1,833 code units, so this leaves + /// substantial headroom for legitimate documents. + /// + public const uint DefaultMaxScalarLength = 64 * 1024; + + /// + /// Gets or sets the maximum YAML nesting depth. + /// Defaults to and cannot exceed + /// . + /// + public uint MaxDepth { get; set; } = YamlConverter.DefaultMaxDepth; + + /// + /// Gets or sets the maximum number of JSON nodes materialized from one YAML document. + /// Defaults to and cannot exceed + /// . + /// + public uint MaxNodeCount { get; set; } = YamlConverter.DefaultMaxNodeCount; + + /// + /// Gets or sets the maximum number of JSON nodes materialized specifically from aliases. + /// Defaults to . + /// + public uint MaxAliasExpansionNodeCount { get; set; } = YamlConverter.DefaultMaxAliasExpansionNodeCount; + + /// + /// Gets or sets the maximum number of input bytes read from one YAML document. + /// Defaults to . + /// + public uint MaxInputByteCount { get; set; } = DefaultMaxInputByteCount; + + /// + /// Gets or sets the maximum length of one YAML scalar value. + /// Defaults to . + /// + public uint MaxScalarLength { get; set; } = DefaultMaxScalarLength; + + internal void Validate() + { + YamlConverter.ValidateMaxDepth(MaxDepth, nameof(MaxDepth)); + YamlConverter.ValidateMaxNodeCount(MaxNodeCount, nameof(MaxNodeCount)); + ValidatePositive(MaxAliasExpansionNodeCount, nameof(MaxAliasExpansionNodeCount)); + ValidatePositive(MaxInputByteCount, nameof(MaxInputByteCount)); + ValidatePositive(MaxScalarLength, nameof(MaxScalarLength)); + } + + private static void ValidatePositive(uint value, string parameterName) + { + if (value == 0) + { + throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be greater than zero."); + } + } +} diff --git a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt index 7ea43f370..4c258a43e 100644 --- a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt +++ b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt @@ -15,3 +15,30 @@ static Microsoft.OpenApi.YamlReader.YamlConverter.ToJsonNode(this SharpYaml.Seri static Microsoft.OpenApi.YamlReader.YamlConverter.ToJsonNode(this SharpYaml.Serialization.YamlStream! yaml) -> System.Collections.Generic.IEnumerable! static Microsoft.OpenApi.YamlReader.YamlConverter.ToJsonObject(this SharpYaml.Serialization.YamlMappingNode! yaml) -> System.Text.Json.Nodes.JsonObject! static Microsoft.OpenApi.YamlReader.YamlConverter.ToYamlNode(this System.Text.Json.Nodes.JsonNode! json) -> SharpYaml.Serialization.YamlNode! +const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxDepth = 64 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxNodeCount = 5000000 -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.get -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.set -> void +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.get -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReader.OpenApiYamlReader(Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! settings) -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.OpenApiYamlReaderSettings() -> void +const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxInputByteCount = 134217728 -> uint +const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxScalarLength = 65536 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxAliasExpansionNodeCount = 5000 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedDepth = 256 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedNodeCount = 10000000 -> uint +static Microsoft.OpenApi.Reader.OpenApiReaderSettingsExtensions.AddYamlReader(this Microsoft.OpenApi.Reader.OpenApiReaderSettings! settings, Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! yamlSettings) -> void +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.get -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.set -> void diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs new file mode 100644 index 000000000..4b3ed75d9 --- /dev/null +++ b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs @@ -0,0 +1,125 @@ +namespace Microsoft.OpenApi.YamlReader; + +/// +/// Tracks the resource budget consumed while materializing a single YAML document. +/// +/// +/// +/// A budget instance is scoped to one document and is not thread safe. Callers charge the budget +/// before allocating, so a document that would breach a limit is rejected without the +/// allocation ever happening. +/// +/// +/// Every limit breach throws , which the reader converts into an +/// OpenApiDiagnostic. That is the whole point of this type: hostile input produces a reportable +/// diagnostic rather than an unrecoverable process failure. +/// +/// +internal sealed class YamlConversionBudget +{ + private readonly uint _maxDepth; + private readonly uint _maxNodeCount; + private readonly uint _maxAliasExpansionNodeCount; + private uint _nodeCount; + private uint _aliasExpansionNodeCount; + + /// + /// Initializes a budget for a single document. + /// + /// Maximum nesting depth. Bounds stack and structural growth. + /// Maximum total nodes materialized from the document. + /// + /// Maximum nodes materialized specifically by expanding aliases. This is the anti-amplification + /// limit and is deliberately far smaller than : a large document is + /// legitimate, but a small document that expands into a large one is not. + /// + public YamlConversionBudget(uint maxDepth, uint maxNodeCount, uint maxAliasExpansionNodeCount) + { + _maxDepth = maxDepth; + _maxNodeCount = maxNodeCount; + _maxAliasExpansionNodeCount = maxAliasExpansionNodeCount; + } + + /// + /// Charges one node at the supplied depth. + /// + /// Zero-based nesting depth of the node being materialized. + /// The depth or total node limit would be exceeded. + public void EnterNode(uint depth) + { + ValidateDepth(depth); + AddNodes(1); + } + + /// + /// Charges the full cost of expanding an alias, against both the alias budget and the total budget. + /// + /// Zero-based nesting depth at which the alias appears. + /// Number of nodes the alias will materialize when cloned. + /// + /// Height of the subtree the alias will materialize, where a scalar has height 1. + /// + /// The depth, alias, or total node limit would be exceeded. + /// + /// Must be called before the clone is taken. Charging afterwards would allow the very allocation + /// this limit exists to prevent. + /// + public void EnterAlias(uint depth, uint expandedNodeCount, uint expandedHeight) + { + ValidateDepth(depth); + + // The alias site clears the depth check on its own, but expanding it grafts an entire + // subtree at this position. Without charging the grafted height, an anchor defined at a + // legal depth can be replayed from another legal depth to produce a tree deeper than the + // limit. The underlying YAML parser cannot catch this either, because it sees an alias as + // a single event and never re-walks the anchored content. + if (expandedHeight > _maxDepth - depth) + { + throw new OpenApiReaderException($"The YAML document expands an alias to more than the maximum supported nesting depth of {_maxDepth}."); + } + + if (expandedNodeCount > _maxAliasExpansionNodeCount - _aliasExpansionNodeCount) + { + throw new OpenApiReaderException($"The YAML document expands aliases to more than the maximum supported number of nodes ({_maxAliasExpansionNodeCount})."); + } + + _aliasExpansionNodeCount += expandedNodeCount; + AddNodes(expandedNodeCount); + } + + /// + /// Validates that a node at is within the depth limit. + /// + /// + /// is zero-based, so a node at that depth occupies level + /// depth + 1. Rejecting depth >= _maxDepth therefore admits exactly + /// _maxDepth levels, matching the limit enforced by the underlying YAML parser. + /// The comparison avoids arithmetic so it cannot overflow. + /// + private void ValidateDepth(uint depth) + { + if (depth >= _maxDepth) + { + throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); + } + } + + /// + /// Charges nodes against the total node budget. + /// + /// + /// The remaining headroom is compared as count > _maxNodeCount - _nodeCount rather than + /// _nodeCount + count > _maxNodeCount. Both operands are unsigned, so the latter form could + /// wrap and silently admit an over-budget document; the invariant _nodeCount <= _maxNodeCount + /// makes the subtraction used here safe from underflow. + /// + private void AddNodes(uint count) + { + if (count > _maxNodeCount - _nodeCount) + { + throw new OpenApiReaderException($"The YAML document expands to more than the maximum supported number of nodes ({_maxNodeCount}). This may indicate a YAML anchor/alias expansion attack."); + } + + _nodeCount += count; + } +} diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs index 1cafea77b..aa73618f5 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using SharpYaml; @@ -12,8 +13,114 @@ namespace Microsoft.OpenApi.YamlReader /// /// Provides extensions to convert YAML models to JSON models. /// + /// + /// These limits apply after a SharpYaml model exists. Use + /// for untrusted input so limits are enforced before SharpYaml model loading. + /// public static class YamlConverter { + /// + /// Default maximum nesting depth allowed when converting a YAML node graph into JSON nodes. + /// Mirrors the default System.Text.Json depth limit (64) that already bounds the JSON reader path, + /// protecting the recursive conversion from stack exhaustion on deeply nested documents. + /// + public const uint DefaultMaxDepth = 64; + + /// + /// Default maximum number of JSON nodes that may be materialized from a single YAML document. + /// Guards against YAML anchor/alias expansion ("billion laughs") attacks, where a tiny document + /// expands exponentially when its shared node graph is materialized into an independent JSON tree. + /// + public const uint DefaultMaxNodeCount = 5_000_000; + + /// + /// Default maximum number of JSON nodes that may be materialized from YAML aliases. + /// + public const uint DefaultMaxAliasExpansionNodeCount = 5_000; + + /// + /// Maximum configurable YAML nesting depth. + /// + public const uint MaximumAllowedDepth = 256; + + /// + /// Maximum configurable number of JSON nodes that may be materialized from a single YAML document. + /// Bounds the running node totals so they cannot overflow while accumulating, which would surface as an + /// instead of a reportable diagnostic. + /// + public const uint MaximumAllowedNodeCount = 10_000_000; + + private static uint _maxDepth = DefaultMaxDepth; + private static uint _maxNodeCount = DefaultMaxNodeCount; + private static uint _maxAliasExpansionNodeCount = DefaultMaxAliasExpansionNodeCount; + + /// + /// Gets or sets the maximum nesting depth allowed when converting a YAML node graph into JSON nodes. + /// Defaults to and cannot exceed the library's safe depth ceiling. + /// + /// Thrown when set outside the supported range. + public static uint MaxDepth + { + get => _maxDepth; + set + { + ValidateMaxDepth(value, nameof(value)); + _maxDepth = value; + } + } + + /// + /// Gets or sets the maximum number of JSON nodes that may be materialized from a single YAML document. + /// Defaults to , guarding against YAML anchor/alias expansion + /// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower + /// it to fail faster when only small documents are expected. Cannot exceed + /// . + /// + /// Thrown when set outside the supported range. + public static uint MaxNodeCount + { + get => _maxNodeCount; + set + { + ValidateMaxNodeCount(value, nameof(value)); + _maxNodeCount = value; + } + } + + /// + /// Gets or sets the maximum number of JSON nodes that may be materialized from YAML aliases. + /// + /// Thrown when set to zero. + public static uint MaxAliasExpansionNodeCount + { + get => _maxAliasExpansionNodeCount; + set + { + if (value == 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "MaxAliasExpansionNodeCount must be greater than zero."); + } + + _maxAliasExpansionNodeCount = value; + } + } + + internal static void ValidateMaxDepth(uint value, string parameterName) + { + if (value == 0 || value > MaximumAllowedDepth) + { + throw new ArgumentOutOfRangeException(parameterName, $"MaxDepth must be between 1 and {MaximumAllowedDepth}."); + } + } + + internal static void ValidateMaxNodeCount(uint value, string parameterName) + { + if (value == 0 || value > MaximumAllowedNodeCount) + { + throw new ArgumentOutOfRangeException(parameterName, $"MaxNodeCount must be between 1 and {MaximumAllowedNodeCount}."); + } + } + /// /// Converts all of the documents in a YAML stream to s. /// @@ -42,13 +149,7 @@ public static JsonNode ToJsonNode(this YamlDocument yaml) /// Thrown for YAML that is not compatible with JSON. public static JsonNode ToJsonNode(this YamlNode yaml) { - return yaml switch - { - YamlMappingNode map => map.ToJsonObject(), - YamlSequenceNode seq => seq.ToJsonArray(), - YamlScalarNode scalar => scalar.ToJsonValue(), - _ => throw new NotSupportedException("This yaml isn't convertible to JSON") - }; + return CreateConversionContext().Convert(yaml, 0).Node; } /// @@ -79,14 +180,7 @@ public static YamlNode ToYamlNode(this JsonNode json) /// public static JsonObject ToJsonObject(this YamlMappingNode yaml) { - var node = new JsonObject(); - foreach (var keyValuePair in yaml) - { - var key = ((YamlScalarNode)keyValuePair.Key).Value!; - node[key] = keyValuePair.Value.ToJsonNode(); - } - - return node; + return (JsonObject)CreateConversionContext().Convert(yaml, 0).Node; } private static YamlMappingNode ToYamlMapping(this JsonObject obj) @@ -104,13 +198,7 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj) /// public static JsonArray ToJsonArray(this YamlSequenceNode yaml) { - var node = new JsonArray(); - foreach (var value in yaml) - { - node.Add(value.ToJsonNode()); - } - - return node; + return (JsonArray)CreateConversionContext().Convert(yaml, 0).Node; } private static YamlSequenceNode ToYamlSequence(this JsonArray arr) @@ -126,19 +214,150 @@ private static YamlSequenceNode ToYamlSequence(this JsonArray arr) "NULL" }; - private static JsonValue ToJsonValue(this YamlScalarNode yaml) + private static YamlConversionContext CreateConversionContext() { - return yaml.Style switch + var maxDepth = MaxDepth; + var maxNodeCount = MaxNodeCount; + var maxAliasExpansionNodeCount = MaxAliasExpansionNodeCount; + ValidateMaxDepth(maxDepth, nameof(MaxDepth)); + return new( + new YamlConversionBudget(maxDepth, maxNodeCount, maxAliasExpansionNodeCount)); + } + + internal static JsonValue ToJsonValue(string? value, ScalarStyle style) + { + return style switch { - ScalarStyle.Plain when decimal.TryParse(yaml.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), - ScalarStyle.Plain when bool.TryParse(yaml.Value, out var b) => JsonValue.Create(b), - ScalarStyle.Plain when YamlNullRepresentations.Contains(yaml.Value) => (JsonValue)JsonNullSentinel.JsonNull.DeepClone(), - ScalarStyle.Plain => JsonValue.Create(yaml.Value), - ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded or ScalarStyle.Any => JsonValue.Create(yaml.Value), - _ => throw new ArgumentOutOfRangeException(nameof(yaml)), + ScalarStyle.Plain when decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), + ScalarStyle.Plain when bool.TryParse(value, out var b) => JsonValue.Create(b), + ScalarStyle.Plain when value is not null && YamlNullRepresentations.Contains(value) => (JsonValue)JsonNullSentinel.JsonNull.DeepClone(), + ScalarStyle.Plain => JsonValue.Create(value ?? string.Empty), + ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded or ScalarStyle.Any => JsonValue.Create(value ?? string.Empty), + _ => throw new ArgumentOutOfRangeException(nameof(style)), }; } + private sealed class YamlConversionContext + { + private readonly YamlConversionBudget _budget; + private readonly Dictionary _completed = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _active = new(ReferenceEqualityComparer.Instance); + + public YamlConversionContext(YamlConversionBudget budget) + { + _budget = budget; + } + + public MaterializedNode Convert(YamlNode yaml, uint depth) + { + try + { + RuntimeHelpers.EnsureSufficientExecutionStack(); + } + catch (InsufficientExecutionStackException ex) + { + throw new OpenApiReaderException("The YAML node graph is too deeply nested to convert safely.", ex); + } + + if (_active.Contains(yaml)) + { + throw new OpenApiReaderException("The YAML node graph contains a cycle."); + } + + if (_completed.TryGetValue(yaml, out var completed)) + { + _budget.EnterAlias(depth, completed.NodeCount, completed.Height); + return new(completed.Node.DeepClone(), completed.NodeCount, completed.Height); + } + + _budget.EnterNode(depth); + _active.Add(yaml); + try + { + var materialized = yaml switch + { + YamlMappingNode map => ConvertMapping(map, depth), + YamlSequenceNode sequence => ConvertSequence(sequence, depth), + YamlScalarNode scalar => new MaterializedNode(ToJsonValue(scalar.Value, scalar.Style), 1, 1), + _ => throw new NotSupportedException("This yaml isn't convertible to JSON") + }; + _completed.Add(yaml, materialized); + return materialized; + } + finally + { + _active.Remove(yaml); + } + } + + private MaterializedNode ConvertMapping(YamlMappingNode yaml, uint depth) + { + var node = new JsonObject(); + uint nodeCount = 1; + uint maxChildHeight = 0; + foreach (var keyValuePair in yaml) + { + if (keyValuePair.Key is not YamlScalarNode scalarKey || scalarKey.Value is null) + { + throw new OpenApiReaderException("YAML mapping keys must be scalar values."); + } + + if (node.ContainsKey(scalarKey.Value)) + { + throw new OpenApiReaderException($"The YAML mapping contains the duplicate key '{scalarKey.Value}'."); + } + + var child = Convert(keyValuePair.Value, depth + 1); + node.Add(scalarKey.Value, child.Node); + nodeCount = checked(nodeCount + child.NodeCount); + maxChildHeight = Math.Max(maxChildHeight, child.Height); + } + + return new(node, nodeCount, maxChildHeight + 1); + } + + private MaterializedNode ConvertSequence(YamlSequenceNode yaml, uint depth) + { + var node = new JsonArray(); + uint nodeCount = 1; + uint maxChildHeight = 0; + foreach (var value in yaml) + { + var child = Convert(value, depth + 1); + node.Add(child.Node); + nodeCount = checked(nodeCount + child.NodeCount); + maxChildHeight = Math.Max(maxChildHeight, child.Height); + } + + return new(node, nodeCount, maxChildHeight + 1); + } + } + + private sealed class MaterializedNode + { + public MaterializedNode(JsonNode node, uint nodeCount, uint height) + { + Node = node; + NodeCount = nodeCount; + Height = height; + } + + public JsonNode Node { get; } + public uint NodeCount { get; } + + /// Number of levels in this subtree, where a scalar has height 1. + public uint Height { get; } + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class + { + public static ReferenceEqualityComparer Instance { get; } = new(); + + public bool Equals(T? x, T? y) => ReferenceEquals(x, y); + + public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj); + } + private static bool NeedsQuoting(string value) => string.IsNullOrEmpty(value) || decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _) || diff --git a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs new file mode 100644 index 000000000..c7a3aab90 --- /dev/null +++ b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; +using System.Threading; +using SharpYaml; +using SharpYaml.Events; + +namespace Microsoft.OpenApi.YamlReader; + +/// +/// Iteratively materializes the first YAML document from parser events under resource limits. +/// +internal sealed class YamlJsonParser +{ + private const int LookAheadBufferCapacity = 8; + + private readonly YamlConversionBudget _budget; + private readonly Dictionary _anchors = new(StringComparer.Ordinal); + private readonly HashSet _activeAnchors = new(StringComparer.Ordinal); + private readonly Stack _containers = new(); + private readonly uint _maxScalarLength; + private readonly uint _maxDepth; + private JsonNode? _root; + + public YamlJsonParser(OpenApiYamlReaderSettings settings) + { + _budget = new(settings.MaxDepth, settings.MaxNodeCount, settings.MaxAliasExpansionNodeCount); + _maxScalarLength = settings.MaxScalarLength; + _maxDepth = settings.MaxDepth; + } + + public JsonNode Parse(TextReader input, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var cancellationReader = new CancellationTokenTextReader(input, cancellationToken); + + // SharpYaml applies its own nesting limit, defaulting to 64. Passing the configured limit + // keeps the two enforcement points in agreement; leaving it unset would silently cap every + // reader at 64 regardless of MaxDepth, making values above the default a no-op. + var parser = new Parser( + new LookAheadBuffer(cancellationReader, LookAheadBufferCapacity), + (int)_maxDepth); + var documentStarted = false; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!parser.MoveNext()) + { + break; + } + + switch (parser.Current) + { + case StreamStart: + break; + case DocumentStart: + documentStarted = true; + break; + case MappingStart mappingStart: + StartContainer(new JsonObject(), mappingStart.Anchor); + break; + case SequenceStart sequenceStart: + StartContainer(new JsonArray(), sequenceStart.Anchor); + break; + case Scalar scalar: + AddScalar(scalar, cancellationToken); + break; + case AnchorAlias alias: + AddAlias(alias, cancellationToken); + break; + case MappingEnd: + case SequenceEnd: + EndContainer(); + break; + case DocumentEnd: + return _root ?? throw new OpenApiReaderException("No content found in the YAML document."); + case StreamEnd: + if (documentStarted) + { + return _root ?? throw new OpenApiReaderException("No content found in the YAML document."); + } + + throw new OpenApiReaderException("No documents found in the YAML stream."); + default: + throw new OpenApiReaderException( + $"Unsupported YAML parser event '{parser.Current?.GetType().Name ?? ""}'."); + } + } + + throw new OpenApiReaderException("No documents found in the YAML stream."); + } + + private void StartContainer(JsonNode container, string? anchor) + { + _budget.EnterNode((uint)_containers.Count); + RegisterActiveAnchor(anchor); + _containers.Push(new(container, anchor)); + } + + private void AddScalar(Scalar scalar, CancellationToken cancellationToken) + { + if (scalar.Value is { } value && value.Length > _maxScalarLength) + { + throw new OpenApiReaderException( + $"The YAML scalar exceeds the maximum supported length of {_maxScalarLength} characters."); + } + + _budget.EnterNode((uint)_containers.Count); + cancellationToken.ThrowIfCancellationRequested(); + var materialized = new MaterializedNode( + YamlConverter.ToJsonValue(scalar.Value, scalar.Style), + 1, + 1, + scalar.Value); + + RegisterCompletedAnchor(scalar.Anchor, materialized); + AddNode(materialized); + } + + private void AddAlias(AnchorAlias alias, CancellationToken cancellationToken) + { + if (_activeAnchors.Contains(alias.Value)) + { + throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' forms a cycle."); + } + + if (!_anchors.TryGetValue(alias.Value, out var anchor)) + { + throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' refers to an unknown anchor."); + } + + _budget.EnterAlias((uint)_containers.Count, anchor.NodeCount, anchor.Height); + cancellationToken.ThrowIfCancellationRequested(); + AddNode(new(anchor.Node.DeepClone(), anchor.NodeCount, anchor.Height, anchor.MappingKey)); + } + + private void EndContainer() + { + if (_containers.Count == 0) + { + throw new OpenApiReaderException("The YAML document contains an unexpected container terminator."); + } + + var frame = _containers.Pop(); + if (frame.PendingKey is not null) + { + throw new OpenApiReaderException("The YAML mapping contains a key without a value."); + } + + var materialized = new MaterializedNode(frame.Container, frame.NodeCount, frame.MaxChildHeight + 1, null); + if (frame.Anchor is not null) + { + _activeAnchors.Remove(frame.Anchor); + _anchors.Add(frame.Anchor, materialized); + } + + AddNode(materialized); + } + + private void AddNode(MaterializedNode materialized) + { + if (_containers.Count == 0) + { + if (_root is not null) + { + throw new OpenApiReaderException("The YAML document contains more than one root node."); + } + + _root = materialized.Node; + return; + } + + var frame = _containers.Peek(); + switch (frame.Container) + { + case JsonArray array: + array.Add(materialized.Node); + frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); + frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height); + break; + case JsonObject when frame.PendingKey is null: + frame.PendingKey = materialized.MappingKey + ?? throw new OpenApiReaderException("YAML mapping keys must be scalar values."); + break; + case JsonObject map: + if (map.ContainsKey(frame.PendingKey)) + { + throw new OpenApiReaderException($"The YAML mapping contains the duplicate key '{frame.PendingKey}'."); + } + + map.Add(frame.PendingKey, materialized.Node); + frame.PendingKey = null; + frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); + frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height); + break; + } + } + + private void RegisterActiveAnchor(string? anchor) + { + if (anchor is null || anchor.Length == 0) + { + return; + } + + if (_anchors.ContainsKey(anchor) || !_activeAnchors.Add(anchor)) + { + throw new OpenApiReaderException($"The YAML document contains the duplicate anchor '&{anchor}'."); + } + } + + private void RegisterCompletedAnchor(string? anchor, MaterializedNode materialized) + { + if (anchor is null || anchor.Length == 0) + { + return; + } + + if (_anchors.ContainsKey(anchor) || _activeAnchors.Contains(anchor)) + { + throw new OpenApiReaderException($"The YAML document contains the duplicate anchor '&{anchor}'."); + } + + _anchors.Add(anchor, materialized); + } + + private sealed class ContainerFrame(JsonNode container, string? anchor) + { + public JsonNode Container { get; } = container; + public string? Anchor { get; } = anchor; + public string? PendingKey { get; set; } + public uint NodeCount { get; set; } = 1; + + /// Height of the tallest child added so far; 0 while the container is empty. + public uint MaxChildHeight { get; set; } + } + + private sealed class MaterializedNode + { + public MaterializedNode(JsonNode node, uint nodeCount, uint height, string? mappingKey) + { + Node = node; + NodeCount = nodeCount; + Height = height; + MappingKey = mappingKey; + } + + public JsonNode Node { get; } + public uint NodeCount { get; } + + /// Number of levels in this subtree, where a scalar has height 1. + public uint Height { get; } + + public string? MappingKey { get; } + } + + private sealed class CancellationTokenTextReader(TextReader innerReader, CancellationToken cancellationToken) : TextReader + { + public override int Peek() + { + cancellationToken.ThrowIfCancellationRequested(); + return innerReader.Peek(); + } + + public override int Read() + { + cancellationToken.ThrowIfCancellationRequested(); + return innerReader.Read(); + } + + public override int Read(char[] buffer, int index, int count) + { + cancellationToken.ThrowIfCancellationRequested(); + return innerReader.Read(buffer, index, count); + } + + public override string? ReadLine() + { + cancellationToken.ThrowIfCancellationRequested(); + return base.ReadLine(); + } + + public override string ReadToEnd() + { + cancellationToken.ThrowIfCancellationRequested(); + return base.ReadToEnd(); + } + } +} diff --git a/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs new file mode 100644 index 000000000..2523fdd6b --- /dev/null +++ b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.OpenApi.Reader; + +namespace Microsoft.OpenApi +{ + /// + /// Enables System.Text.Json serialization and deserialization of + /// using the OpenAPI wire format rather than the default reflection-based output. + /// + /// + /// Register this converter via : + /// + /// var options = new JsonSerializerOptions(); + /// options.Converters.Add(new OpenApiSchemaJsonConverter()); + /// var json = JsonSerializer.Serialize(schema, options); + /// + /// + public sealed class OpenApiSchemaJsonConverter : JsonConverter + { + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private readonly OpenApiSpecVersion _version; + + /// + /// Initializes a new instance of targeting OpenAPI 3.2. + /// + public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_2) { } + + /// + /// Initializes a new instance of targeting the specified OpenAPI version. + /// + /// The OpenAPI specification version to use when serializing the schema. + public OpenApiSchemaJsonConverter(OpenApiSpecVersion version) + { + _version = version; + } + + /// + /// + /// Deserializes a bare JSON Schema object into an using + /// to parse it as a schema fragment. + /// Only OpenAPI 3.x versions support JSON Schema; deserializing with + /// is not supported and will throw . + /// + public override OpenApiSchema? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (_version == OpenApiSpecVersion.OpenApi2_0) + throw new NotSupportedException("Deserializing OpenApiSchema is not supported for OpenAPI 2.0."); + + var jsonNode = JsonNode.Parse(ref reader) + ?? throw new JsonException("Failed to parse the JSON input into a valid JsonNode."); + var jsonReader = new OpenApiJsonReader(); + return jsonReader.ReadFragment(jsonNode, _version, new OpenApiDocument(), out _); + } + + /// + public override void Write(Utf8JsonWriter writer, OpenApiSchema value, JsonSerializerOptions options) + { + Utils.CheckArgumentNull(writer); + Utils.CheckArgumentNull(value); + + using var stream = new MemoryStream(); + using (var textWriter = new StreamWriter(stream, Utf8NoBom, bufferSize: 1024, leaveOpen: true)) + { + var openApiWriter = new OpenApiJsonWriter(textWriter); + SerializeSchema(value, openApiWriter); + textWriter.Flush(); + } + + writer.WriteRawValue(stream.ToArray(), skipInputValidation: true); + } + + private void SerializeSchema(OpenApiSchema schema, OpenApiJsonWriter writer) + { + switch (_version) + { + case OpenApiSpecVersion.OpenApi3_2: + schema.SerializeAsV32(writer); + break; + case OpenApiSpecVersion.OpenApi3_1: + schema.SerializeAsV31(writer); + break; + case OpenApiSpecVersion.OpenApi3_0: + schema.SerializeAsV3(writer); + break; + case OpenApiSpecVersion.OpenApi2_0: + schema.SerializeAsV2(writer); + break; + default: + throw new ArgumentOutOfRangeException(nameof(_version), _version, + string.Format(SRResource.OpenApiSpecVersionNotSupported, _version)); + } + } + } +} diff --git a/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs b/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs index cac554318..db2a97caf 100644 --- a/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs +++ b/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -10,10 +11,17 @@ namespace Microsoft.OpenApi /// /// String literal with embedded expressions /// - public class CompositeExpression : RuntimeExpression + public partial class CompositeExpression : RuntimeExpression { private readonly string template; - private readonly Regex expressionPattern = new(@"{(?\$[^}]*)"); + private const string ExpressionPattern = @"{(?\$[^}]*)"; + +#if NET8_0_OR_GREATER + [GeneratedRegex(ExpressionPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex ExpressionRegex(); +#else + private static readonly Regex ExpressionRegex = new(ExpressionPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); +#endif /// /// Expressions embedded into string literal @@ -24,12 +32,17 @@ public class CompositeExpression : RuntimeExpression /// Create a composite expression from a string literal with an embedded expression /// /// + /// Extracting embedded expressions exceeds the regex match timeout. public CompositeExpression(string expression) { template = expression; // Extract subexpressions and convert to RuntimeExpressions - var matches = expressionPattern.Matches(expression); +#if NET8_0_OR_GREATER + var matches = ExpressionRegex().Matches(expression); +#else + var matches = ExpressionRegex.Matches(expression); +#endif foreach (var item in matches.Cast()) { diff --git a/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs b/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs index 8d557b4d1..1ad3a5b8f 100644 --- a/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs +++ b/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs @@ -69,7 +69,12 @@ internal static string ToFirstIdentifier(this JsonSchemaType schemaType) /// internal static string ToSingleIdentifier(this JsonSchemaType schemaType) { - return schemaType.ToIdentifiersInternal().Single(); + if (allSchemaTypes.TryGetValue(schemaType, out var schemaTypeString)) + { + return schemaTypeString; + } + + throw new InvalidOperationException($"ToSingleIdentifier is called with unexpected value '{schemaType}'. Callers must ensure this is called with a valid single value JsonSchemaType."); } /// diff --git a/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj b/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj index a6ea923a6..8e1ebb555 100644 --- a/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj +++ b/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj @@ -24,13 +24,10 @@ true - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - + + + + @@ -54,7 +51,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiParameter.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiParameter.cs index 5669a0bb3..586043d79 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiParameter.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiParameter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text.Json.Nodes; namespace Microsoft.OpenApi; @@ -42,6 +43,7 @@ public interface IOpenApiParameter : IOpenApiDescribedElement, IOpenApiReadOnlyE /// If style is used, and if behavior is n/a (cannot be serialized), /// the value of allowEmptyValue SHALL be ignored. /// + [Obsolete("Use of AllowEmptyValue is not recommended and it is likely to be removed in a later revision.")] public bool AllowEmptyValue { get; } /// diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs index 6d43a087a..476b2a853 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs @@ -243,6 +243,7 @@ public interface IOpenApiSchema : IOpenApiDescribedElement, IOpenApiReadOnlyExte /// You must use the method to check whether Default was assigned a null value in the document. /// Assign to use get null as a serialized value. /// + [Obsolete("Use Examples instead.")] public JsonNode? Example { get; } /// @@ -260,7 +261,7 @@ public interface IOpenApiSchema : IOpenApiDescribedElement, IOpenApiReadOnlyExte /// /// Indicates whether unevaluated properties are allowed. When false, no unevaluated properties are permitted. /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties - /// Only serialized when false and UnevaluatedPropertiesSchema (from IOpenApiSchemaWithUnevaluatedProperties) is null. + /// Only serialized when false and UnevaluatedPropertiesSchema (from IOpenApiSchemaMissingProperties) is null. /// /// /// NOTE: This property differs from the naming pattern of AdditionalPropertiesAllowed for binary compatibility reasons. diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs new file mode 100644 index 000000000..32337cd29 --- /dev/null +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; + +namespace Microsoft.OpenApi; + +/// +/// Compatibility interface for schema properties that cannot be added to +/// in the current major version without a breaking change. +/// This interface provides access to those properties in contexts where callers need a typed model surface. +/// +/// +/// TODO: Remove this interface in the next major version and merge its content into IOpenApiSchema. +/// +public interface IOpenApiSchemaMissingProperties +{ + /// + /// $anchor - identifies a plain-name location-independent fragment within the schema resource. + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-anchor + /// + public string? Anchor { get; } + + /// + /// Indicates whether unevaluated properties are allowed. When false, no unevaluated properties are permitted. + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties + /// Only serialized when false and is null. + /// + /// + /// NOTE: This property differs from the naming pattern of AdditionalPropertiesAllowed for binary compatibility reasons. + /// In the next major version, this will be renamed to UnevaluatedPropertiesAllowed. + /// TODO: Rename to UnevaluatedPropertiesAllowed in the next major version. + /// + public bool UnevaluatedProperties { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties + /// This is a schema that unevaluated properties must validate against. + /// When serialized, this takes precedence over the boolean property. + /// + /// + /// NOTE: This property differs from the naming pattern of AdditionalProperties/AdditionalPropertiesAllowed + /// for binary compatibility reasons. In the next major version: + /// - This property will be renamed to UnevaluatedProperties + /// - The current boolean UnevaluatedProperties property will be renamed to UnevaluatedPropertiesAllowed + /// + /// TODO: Rename this property to UnevaluatedProperties in the next major version. + /// + public IOpenApiSchema? UnevaluatedPropertiesSchema { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentencoding + /// contentEncoding - identifies the encoding of string content. + /// + public string? ContentEncoding { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentmediatype + /// contentMediaType - identifies the media type of string content. + /// + public string? ContentMediaType { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentschema + /// contentSchema - provides a schema that describes the decoded string content. + /// + public IOpenApiSchema? ContentSchema { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-propertynames + /// propertyNames - provides a schema that validates property names. + /// + public IOpenApiSchema? PropertyNames { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-dependentschemas + /// dependentSchemas - maps property names to schemas that are applied when that property is present. + /// + public IDictionary? DependentSchemas { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-if + /// if - applies a conditional schema that determines whether or should be evaluated. + /// + public IOpenApiSchema? If { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-then + /// then - applies when evaluates successfully. + /// + public IOpenApiSchema? Then { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-else + /// else - applies when does not evaluate successfully. + /// + public IOpenApiSchema? Else { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-contains + /// An array instance is valid against "contains" if at least one of its elements is valid against this schema. + /// Inline or referenced schema MUST be of a Schema Object and not a standard JSON Schema. + /// + IOpenApiSchema? Contains { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation + /// The number of elements matching the "contains" schema MUST be less than or equal to this value. + /// + uint? MaxContains { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation + /// The number of elements matching the "contains" schema MUST be greater than or equal to this value. + /// + uint? MinContains { get; } +} diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs index 3379a0837..6fe7e9f9a 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs @@ -1,3 +1,5 @@ +using System; + namespace Microsoft.OpenApi; /// @@ -13,6 +15,7 @@ namespace Microsoft.OpenApi; /// /// TODO: Remove this interface in the next major version and merge its content into IOpenApiSchema. /// +[Obsolete("Use IOpenApiSchemaMissingProperties instead.")] public interface IOpenApiSchemaWithUnevaluatedProperties { /// diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index fff77e4cc..c495ddbea 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -8,6 +8,8 @@ namespace Microsoft.OpenApi; +#pragma warning disable CS0618 + /// /// Schema reference information that includes metadata annotations from JSON Schema 2020-12. /// This class extends OpenApiReference to provide schema-specific metadata override capabilities. @@ -58,6 +60,307 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription /// public IDictionary? Extensions { get; set; } + /// + /// A $id which by default SHOULD override that of the referenced component. + /// Named SchemaId to avoid collision with the inherited reference identifier (BaseOpenApiReference.Id). + /// + public string? SchemaId { get; set; } + + /// + /// The $schema dialect URI which by default SHOULD override that of the referenced component. + /// + public Uri? Schema { get; set; } + + /// + /// A $comment which by default SHOULD override that of the referenced component. + /// + public string? Comment { get; set; } + + /// + /// The $vocabulary which by default SHOULD override that of the referenced component. + /// + public IDictionary? Vocabulary { get; set; } + + /// + /// The $dynamicRef which by default SHOULD override that of the referenced component. + /// + public string? DynamicRef { get; set; } + + /// + /// The $dynamicAnchor which by default SHOULD override that of the referenced component. + /// + public string? DynamicAnchor { get; set; } + + /// + /// The $defs which by default SHOULD override that of the referenced component. + /// + public IDictionary? Definitions { get; set; } + + /// + /// The $anchor which by default SHOULD override that of the referenced component. + /// + public string? Anchor { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? ExclusiveMaximum { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? ExclusiveMinimum { get; set; } + + /// + /// Schema type override. Named SchemaType to avoid collision with . + /// + public JsonSchemaType? SchemaType { get; set; } + + internal bool WasConstExplicitlySet { get; private set; } + + /// + /// Follow JSON Schema definition. + /// + public string? Const + { + get => field; + set + { + // TODO: In the next major release, Const should be made a JsonNode. + // See https://github.com/microsoft/OpenAPI.NET/issues/2935 for more information. + WasConstExplicitlySet = true; + field = value; + } + } + + /// + /// Follow JSON Schema definition. + /// + public string? Format { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? Maximum { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? Minimum { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public int? MaxLength { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public int? MinLength { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? Pattern { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public decimal? MultipleOf { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IList? AllOf { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IList? OneOf { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IList? AnyOf { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? Not { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public ISet? Required { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? Items { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public int? MaxItems { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public int? MinItems { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public bool? UniqueItems { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? Contains { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public uint? MaxContains { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public uint? MinContains { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IDictionary? Properties { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IDictionary? PatternProperties { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public int? MaxProperties { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public int? MinProperties { get; set; } + + /// + /// Indicates if the schema can contain properties other than those defined by the properties map. + /// Follow JSON Schema definition. + /// + public bool? AdditionalPropertiesAllowed { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? AdditionalProperties { get; set; } + + /// + /// Adds support for polymorphism. + /// Follow OpenAPI definition. + /// + public OpenApiDiscriminator? Discriminator { get; set; } + + /// + /// A free-form property to include an example of an instance for this schema. + /// Follow OpenAPI Schema Object definition. + /// + [Obsolete("Use Examples instead.")] + public JsonNode? Example { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IList? Enum { get; set; } + + /// + /// Indicates whether unevaluated properties are allowed. + /// Follow JSON Schema definition. + /// + public bool? UnevaluatedProperties { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? UnevaluatedPropertiesSchema { get; set; } + + /// + /// Additional external documentation for this schema. + /// Follow OpenAPI definition. + /// + public OpenApiExternalDocs? ExternalDocs { get; set; } + + /// + /// This MAY be used only on properties schemas. + /// Follow OpenAPI definition. + /// + public OpenApiXml? Xml { get; set; } + + /// + /// This object stores any unrecognized keywords found in the schema. + /// + public IDictionary? UnrecognizedKeywords { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IDictionary>? DependentRequired { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? ContentEncoding { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public string? ContentMediaType { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? ContentSchema { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? PropertyNames { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IDictionary? DependentSchemas { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? If { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? Then { get; set; } + + /// + /// Follow JSON Schema definition. + /// + public IOpenApiSchema? Else { get; set; } + + /// + /// Indicates whether this reference represents a bare $dynamicRef (no $ref to a component). + /// When true, serialization emits $dynamicRef instead of $ref, and Target resolution + /// uses the $dynamicAnchor index rather than the $ref URI lookup. + /// Computed from whether $dynamicRef is set and ReferenceV3 does not point to a component path. + /// + internal bool IsDynamicRefOnly + => !string.IsNullOrEmpty(DynamicRef) + && ReferenceV3 is string refV3 + && !refV3.StartsWith("#/components/", StringComparison.OrdinalIgnoreCase); + /// /// Parameterless constructor /// @@ -76,24 +379,188 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference) WriteOnly = reference.WriteOnly; Examples = reference.Examples; Extensions = reference.Extensions != null ? new Dictionary(reference.Extensions) : null; + SchemaId = reference.SchemaId; + Schema = reference.Schema; + Comment = reference.Comment; + Vocabulary = reference.Vocabulary != null ? new Dictionary(reference.Vocabulary) : null; + DynamicRef = reference.DynamicRef; + DynamicAnchor = reference.DynamicAnchor; + Definitions = reference.Definitions != null ? new Dictionary(reference.Definitions) : null; + Anchor = reference.Anchor; + ExclusiveMaximum = reference.ExclusiveMaximum; + ExclusiveMinimum = reference.ExclusiveMinimum; + SchemaType = reference.SchemaType; + Const = reference.Const; + WasConstExplicitlySet = reference.WasConstExplicitlySet; + Format = reference.Format; + Maximum = reference.Maximum; + Minimum = reference.Minimum; + MaxLength = reference.MaxLength; + MinLength = reference.MinLength; + Pattern = reference.Pattern; + MultipleOf = reference.MultipleOf; + AllOf = reference.AllOf != null ? [.. reference.AllOf] : null; + OneOf = reference.OneOf != null ? [.. reference.OneOf] : null; + AnyOf = reference.AnyOf != null ? [.. reference.AnyOf] : null; + Not = reference.Not; + Required = reference.Required != null ? new HashSet(reference.Required) : null; + Items = reference.Items; + MaxItems = reference.MaxItems; + MinItems = reference.MinItems; + UniqueItems = reference.UniqueItems; + Contains = reference.Contains; + MaxContains = reference.MaxContains; + MinContains = reference.MinContains; + Properties = reference.Properties != null ? new Dictionary(reference.Properties) : null; + PatternProperties = reference.PatternProperties != null ? new Dictionary(reference.PatternProperties) : null; + MaxProperties = reference.MaxProperties; + MinProperties = reference.MinProperties; + AdditionalPropertiesAllowed = reference.AdditionalPropertiesAllowed; + AdditionalProperties = reference.AdditionalProperties; + Discriminator = reference.Discriminator; + Example = reference.Example; + Enum = reference.Enum != null ? [.. reference.Enum] : null; + UnevaluatedProperties = reference.UnevaluatedProperties; + UnevaluatedPropertiesSchema = reference.UnevaluatedPropertiesSchema; + ExternalDocs = reference.ExternalDocs; + Xml = reference.Xml; + UnrecognizedKeywords = reference.UnrecognizedKeywords != null ? new Dictionary(reference.UnrecognizedKeywords) : null; + DependentRequired = reference.DependentRequired != null ? new Dictionary>(reference.DependentRequired) : null; + ContentEncoding = reference.ContentEncoding; + ContentMediaType = reference.ContentMediaType; + ContentSchema = reference.ContentSchema; + PropertyNames = reference.PropertyNames; + DependentSchemas = reference.DependentSchemas != null ? new Dictionary(reference.DependentSchemas) : null; + If = reference.If; + Then = reference.Then; + Else = reference.Else; + } + + /// + public override void SerializeAsV31(IOpenApiWriter writer) + { + if (IsDynamicRefOnly) + { + writer.WriteStartObject(); + SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV31(w), base.SerializeAdditionalV31Properties); + writer.WriteEndObject(); + } + else + { + base.SerializeAsV31(writer); + } + } + /// + public override void SerializeAsV32(IOpenApiWriter writer) + { + if (IsDynamicRefOnly) + { + writer.WriteStartObject(); + SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV32(w), base.SerializeAdditionalV32Properties); + writer.WriteEndObject(); + } + else + { + base.SerializeAsV32(writer); + } } /// protected override void SerializeAdditionalV31Properties(IOpenApiWriter writer) { - SerializeAdditionalV3XProperties(writer, base.SerializeAdditionalV31Properties); + SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV31(w), base.SerializeAdditionalV31Properties); } /// protected override void SerializeAdditionalV32Properties(IOpenApiWriter writer) { - SerializeAdditionalV3XProperties(writer, base.SerializeAdditionalV32Properties); + SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV32(w), base.SerializeAdditionalV32Properties); } - private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action baseSerializer) + + private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action serializeCallback, Action baseSerializer) { if (Type != ReferenceType.Schema) throw new InvalidOperationException( $"JsonSchemaReference can only be serialized for ReferenceType.Schema, but was {Type}."); baseSerializer(writer); + + // JSON Schema 2020-12 keyword siblings (preserved per OAS 3.1+ / JSON Schema 2020-12 semantics) + writer.WriteProperty(OpenApiConstants.Id, SchemaId); + writer.WriteProperty(OpenApiConstants.DollarSchema, Schema?.ToString()); + writer.WriteProperty(OpenApiConstants.Comment, Comment); + writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); + writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, serializeCallback); + writer.WriteProperty(OpenApiConstants.Anchor, Anchor); + writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); + writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); + + if (WasConstExplicitlySet) + { + writer.WriteRequiredProperty(OpenApiConstants.Const, Const); + } + + WriteSchemaType(writer, OpenApiConstants.Type, SchemaType, allowMultipleTypes: true); + writer.WriteProperty(OpenApiConstants.Format, Format); + writer.WriteProperty(OpenApiConstants.MultipleOf, MultipleOf); + WriteRawProperty(writer, OpenApiConstants.Maximum, Maximum); + WriteRawProperty(writer, OpenApiConstants.ExclusiveMaximum, ExclusiveMaximum); + WriteRawProperty(writer, OpenApiConstants.Minimum, Minimum); + WriteRawProperty(writer, OpenApiConstants.ExclusiveMinimum, ExclusiveMinimum); + writer.WriteProperty(OpenApiConstants.MaxLength, MaxLength); + writer.WriteProperty(OpenApiConstants.MinLength, MinLength); + writer.WriteProperty(OpenApiConstants.Pattern, Pattern); + writer.WriteProperty(OpenApiConstants.MaxItems, MaxItems); + writer.WriteProperty(OpenApiConstants.MinItems, MinItems); + writer.WriteProperty(OpenApiConstants.UniqueItems, UniqueItems); + writer.WriteProperty(OpenApiConstants.MaxProperties, MaxProperties); + writer.WriteProperty(OpenApiConstants.MinProperties, MinProperties); + writer.WriteOptionalCollection(OpenApiConstants.Required, Required, (w, s) => + { + if (!string.IsNullOrEmpty(s)) + { + w.WriteValue(s!); + } + }); + writer.WriteOptionalCollection(OpenApiConstants.Enum, Enum, (w, e) => w.WriteAny(e)); + writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, serializeCallback); + writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, serializeCallback); + writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.Not, Not, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.Items, Items, serializeCallback); + writer.WriteOptionalMap(OpenApiConstants.Properties, Properties, serializeCallback); + writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, serializeCallback); + if (AdditionalProperties is not null) + { + writer.WriteOptionalObject(OpenApiConstants.AdditionalProperties, AdditionalProperties, serializeCallback); + } + else if (AdditionalPropertiesAllowed.HasValue) + { + writer.WriteProperty(OpenApiConstants.AdditionalProperties, AdditionalPropertiesAllowed.Value); + } + writer.WriteOptionalObject(OpenApiConstants.Discriminator, Discriminator, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.Example, Example, (w, e) => w.WriteAny(e)); + if (UnevaluatedPropertiesSchema is not null) + { + writer.WriteOptionalObject(OpenApiConstants.UnevaluatedProperties, UnevaluatedPropertiesSchema, serializeCallback); + } + else if (UnevaluatedProperties.HasValue) + { + writer.WriteProperty(OpenApiConstants.UnevaluatedProperties, UnevaluatedProperties.Value); + } + writer.WriteOptionalObject(OpenApiConstants.ExternalDocs, ExternalDocs, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.Xml, Xml, serializeCallback); + writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); + writer.WriteOptionalObject(OpenApiConstants.Contains, Contains, serializeCallback); + writer.WriteProperty(OpenApiConstants.MaxContains, MaxContains); + writer.WriteProperty(OpenApiConstants.MinContains, MinContains); + writer.WriteProperty(OpenApiConstants.ContentEncoding, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaType, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, serializeCallback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.If, If, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.Then, Then, serializeCallback); + writer.WriteOptionalObject(OpenApiConstants.Else, Else, serializeCallback); + // Additional schema metadata annotations in 3.1 writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); writer.WriteProperty(OpenApiConstants.Title, Title); @@ -116,53 +583,141 @@ private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action - protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject) + private static void WriteRawProperty(IOpenApiWriter writer, string name, string? value) { - base.SetAdditional31MetadataFromMapNode(jsonObject); - - var title = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Title); - if (!string.IsNullOrEmpty(title)) + if (!string.IsNullOrEmpty(value)) { - Title = title; + writer.WritePropertyName(name); + writer.WriteRaw(value!); } + } - // Boolean properties - if (jsonObject.TryGetPropertyValue(OpenApiConstants.Deprecated, out var deprecatedNode) && deprecatedNode is JsonValue deprecatedValue && deprecatedValue.TryGetValue(out var deprecated)) + private static void WriteSchemaType(IOpenApiWriter writer, string name, JsonSchemaType? schemaType, bool allowMultipleTypes) + { + if (!schemaType.HasValue) { - Deprecated = deprecated; + return; } - if (jsonObject.TryGetPropertyValue(OpenApiConstants.ReadOnly, out var readOnlyNode) && readOnlyNode is JsonValue readOnlyValue && readOnlyValue.TryGetValue(out var readOnly)) + var values = schemaType.Value.ToIdentifiers(); + if (values is null || values.Length == 0) { - ReadOnly = readOnly; + return; } - if (jsonObject.TryGetPropertyValue(OpenApiConstants.WriteOnly, out var writeOnlyNode) && writeOnlyNode is JsonValue writeOnlyValue && writeOnlyValue.TryGetValue(out var writeOnly)) + if (allowMultipleTypes && values.Length > 1) { - WriteOnly = writeOnly; + writer.WriteOptionalCollection(name, values, (w, s) => + { + if (!string.IsNullOrEmpty(s)) + { + w.WriteValue(s!); + } + }); } - - // Default value - if (jsonObject.TryGetPropertyValue(OpenApiConstants.Default, out var defaultNode)) + else { - Default = defaultNode; + writer.WriteProperty(name, values[0]); } + } + + /// + [Obsolete("Use ApplySchemaMetadata instead.")] +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member + protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject) +#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member + { + //TODO remove this method in next major release + // no-op: we're using ApplySchemaMetadata + } - // Examples - if (jsonObject.TryGetPropertyValue(OpenApiConstants.Examples, out var examplesNode) && examplesNode is JsonArray examplesArray) + internal void ApplySchemaMetadata(OpenApiSchema schema, JsonObject jsonObject) + { + Title = schema.Title; + Description = schema.Description; + Default = schema.Default; + if (jsonObject.ContainsKey(OpenApiConstants.Deprecated)) { - Examples = examplesArray.OfType().ToList(); + Deprecated = schema.Deprecated; } - - // Extensions (properties starting with "x-") - foreach (var property in jsonObject - .Where(static p => p.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase) - && p.Value is not null)) + if (jsonObject.ContainsKey(OpenApiConstants.ReadOnly)) + { + ReadOnly = schema.ReadOnly; + } + if (jsonObject.ContainsKey(OpenApiConstants.WriteOnly)) + { + WriteOnly = schema.WriteOnly; + } + Examples = schema.Examples; + Extensions = schema.Extensions; + SchemaId = schema.Id; + Schema = schema.Schema; + Comment = schema.Comment; + if (schema.Vocabulary is { Count: > 0 }) + { + Vocabulary = schema.Vocabulary; + } + DynamicRef = schema.DynamicRef; + DynamicAnchor = schema.DynamicAnchor; + if (schema.Definitions is { Count: > 0 }) + { + Definitions = schema.Definitions; + } + Anchor = schema.Anchor; + ExclusiveMaximum = schema.ExclusiveMaximum; + ExclusiveMinimum = schema.ExclusiveMinimum; + SchemaType = schema.Type; + Const = schema.Const; + WasConstExplicitlySet = schema.WasConstExplicitlySet; + Format = schema.Format; + Maximum = schema.Maximum; + Minimum = schema.Minimum; + MaxLength = schema.MaxLength; + MinLength = schema.MinLength; + Pattern = schema.Pattern; + MultipleOf = schema.MultipleOf; + AllOf = schema.AllOf; + OneOf = schema.OneOf; + AnyOf = schema.AnyOf; + Not = schema.Not; + Required = schema.Required; + Items = schema.Items; + MaxItems = schema.MaxItems; + MinItems = schema.MinItems; + UniqueItems = schema.UniqueItems; + Contains = schema.Contains; + MaxContains = schema.MaxContains; + MinContains = schema.MinContains; + Properties = schema.Properties; + PatternProperties = schema.PatternProperties; + MaxProperties = schema.MaxProperties; + MinProperties = schema.MinProperties; + if (jsonObject.TryGetPropertyValue(OpenApiConstants.AdditionalProperties, out var additionalPropertiesNode) && + additionalPropertiesNode is JsonValue) + { + AdditionalPropertiesAllowed = schema.AdditionalPropertiesAllowed; + } + AdditionalProperties = schema.AdditionalProperties; + Discriminator = schema.Discriminator; + Example = schema.Example; + Enum = schema.Enum; + if (jsonObject.TryGetPropertyValue(OpenApiConstants.UnevaluatedProperties, out var unevaluatedPropertiesNode) && + unevaluatedPropertiesNode is JsonValue) { - var extensionValue = property.Value!; - Extensions ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone()); + UnevaluatedProperties = schema.UnevaluatedProperties; } + UnevaluatedPropertiesSchema = schema.UnevaluatedPropertiesSchema; + ExternalDocs = schema.ExternalDocs; + Xml = schema.Xml; + UnrecognizedKeywords = schema.UnrecognizedKeywords; + DependentRequired = schema.DependentRequired; + ContentEncoding = schema.ContentEncoding; + ContentMediaType = schema.ContentMediaType; + ContentSchema = schema.ContentSchema; + PropertyNames = schema.PropertyNames; + DependentSchemas = schema.DependentSchemas; + If = schema.If; + Then = schema.Then; + Else = schema.Else; } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index a54758002..706af35fc 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -90,6 +90,11 @@ public static class OpenApiConstants /// public const string Vocabulary = "$vocabulary"; + /// + /// Field: Anchor + /// + public const string Anchor = "$anchor"; + /// /// Field: DynamicRef /// @@ -230,6 +235,11 @@ public static class OpenApiConstants /// public const string Identifier = "identifier"; + /// + /// Field: x-oai-license-identifier + /// + public const string OaiLicenseIdentifier = "x-oai-license-identifier"; + /// /// Field: Namespace /// @@ -485,6 +495,21 @@ public static class OpenApiConstants /// public const string UniqueItems = "uniqueItems"; + /// + /// Field: Contains + /// + public const string Contains = "contains"; + + /// + /// Field: MaxContains + /// + public const string MaxContains = "maxContains"; + + /// + /// Field: MinContains + /// + public const string MinContains = "minContains"; + /// /// Field: MaxProperties /// @@ -535,11 +560,51 @@ public static class OpenApiConstants /// public const string PatternProperties = "patternProperties"; + /// + /// Field: PropertyNames + /// + public const string PropertyNames = "propertyNames"; + /// /// Extension: x-jsonschema-patternProperties /// public const string PatternPropertiesExtension = "x-jsonschema-patternProperties"; + /// + /// Field: DependentSchemas + /// + public const string DependentSchemas = "dependentSchemas"; + + /// + /// Field: If + /// + public const string If = "if"; + + /// + /// Field: Then + /// + public const string Then = "then"; + + /// + /// Field: Else + /// + public const string Else = "else"; + + /// + /// Field: ContentEncoding + /// + public const string ContentEncoding = "contentEncoding"; + + /// + /// Field: ContentMediaType + /// + public const string ContentMediaType = "contentMediaType"; + + /// + /// Field: ContentSchema + /// + public const string ContentSchema = "contentSchema"; + /// /// Field: AdditionalProperties /// @@ -710,6 +775,11 @@ public static class OpenApiConstants /// public const string OAuth2MetadataUrl = "oauth2MetadataUrl"; + /// + /// Extension: x-oai-oauth2-metadata-url + /// + internal const string OAuth2MetadataUrlExtension = "x-oai-oauth2-metadata-url"; + /// /// Field: OpenIdConnectUrl /// @@ -745,6 +815,11 @@ public static class OpenApiConstants /// public const string ExamplesExtension = "x-examples"; + /// + /// Extension: x-jsonschema-examples + /// + public const string JsonSchemaExamplesExtension = "x-jsonschema-examples"; + /// /// Field: version3_0_0 /// @@ -790,6 +865,66 @@ public static class OpenApiConstants /// public const string DependentRequired = "dependentRequired"; + /// + /// Extension: x-jsonschema-$anchor + /// + public const string AnchorExtension = "x-jsonschema-$anchor"; + + /// + /// Extension: x-jsonschema-propertyNames + /// + public const string PropertyNamesExtension = "x-jsonschema-propertyNames"; + + /// + /// Extension: x-jsonschema-dependentSchemas + /// + public const string DependentSchemasExtension = "x-jsonschema-dependentSchemas"; + + /// + /// Extension: x-jsonschema-if + /// + public const string IfExtension = "x-jsonschema-if"; + + /// + /// Extension: x-jsonschema-then + /// + public const string ThenExtension = "x-jsonschema-then"; + + /// + /// Extension: x-jsonschema-else + /// + public const string ElseExtension = "x-jsonschema-else"; + + /// + /// Extension: x-jsonschema-contentEncoding + /// + public const string ContentEncodingExtension = "x-jsonschema-contentEncoding"; + + /// + /// Extension: x-jsonschema-contentMediaType + /// + public const string ContentMediaTypeExtension = "x-jsonschema-contentMediaType"; + + /// + /// Extension: x-jsonschema-contentSchema + /// + public const string ContentSchemaExtension = "x-jsonschema-contentSchema"; + + /// + /// Extension: x-jsonschema-contains + /// + public const string ContainsExtension = "x-jsonschema-contains"; + + /// + /// Extension: x-jsonschema-maxContains + /// + public const string MaxContainsExtension = "x-jsonschema-maxContains"; + + /// + /// Extension: x-jsonschema-minContains + /// + public const string MinContainsExtension = "x-jsonschema-minContains"; + #region V2.0 /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiDiscriminator.cs b/src/Microsoft.OpenApi/Models/OpenApiDiscriminator.cs index 69e15f9a4..ed7a5f4d3 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDiscriminator.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDiscriminator.cs @@ -58,7 +58,7 @@ public void SerializeAsV32(IOpenApiWriter writer) if (DefaultMapping != null) { writer.WritePropertyName("defaultMapping"); - DefaultMapping.SerializeAsV32(writer); + WriteMappingReference(writer, DefaultMapping); } // extensions @@ -79,7 +79,7 @@ public void SerializeAsV31(IOpenApiWriter writer) if (DefaultMapping != null) { writer.WritePropertyName("x-oas-default-mapping"); - DefaultMapping.SerializeAsV31(writer); + WriteMappingReference(writer, DefaultMapping); } // extensions @@ -108,13 +108,16 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteProperty(OpenApiConstants.PropertyName, PropertyName); // mapping - writer.WriteOptionalMap(OpenApiConstants.Mapping, Mapping, (w, s) => + writer.WriteOptionalMap(OpenApiConstants.Mapping, Mapping, WriteMappingReference); + } + + private static void WriteMappingReference(IOpenApiWriter writer, OpenApiSchemaReference schemaReference) + { + var reference = schemaReference.Reference.ReferenceV3; + if (!string.IsNullOrEmpty(reference)) { - if (!string.IsNullOrEmpty(s.Reference.ReferenceV3) && s.Reference.ReferenceV3 is not null) - { - w.WriteValue(s.Reference.ReferenceV3); - } - }); + writer.WriteValue(reference!); + } } /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 96973a701..03086600d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -89,6 +89,7 @@ public ISet? Tags { if (value is null) { + _tags = null; return; } _tags = value switch @@ -581,8 +582,11 @@ public async Task GetHashCodeAsync(CancellationToken cancellationToken = using var streamWriter = new StreamWriter(cryptoStream); await WriteDocumentAsync(streamWriter, cancellationToken).ConfigureAwait(false); - +#if NET5_0_OR_GREATER + await cryptoStream.FlushFinalBlockAsync(cancellationToken).ConfigureAwait(false); +#else cryptoStream.FlushFinalBlock(); +#endif var hash = sha.Hash; #endif @@ -635,7 +639,11 @@ private static string ConvertByteArrayToString(byte[] hash) string relativePath; var referenceV3 = !string.IsNullOrEmpty(reference.ReferenceV3) ? reference.ReferenceV3! : string.Empty; - if (!string.IsNullOrEmpty(referenceV3) && IsSubComponent(referenceV3)) + if (reference.Type is ReferenceType.Schema && useExternal && TryGetPlainNameFragment(referenceV3, out var plainNameFragment)) + { + relativePath = plainNameFragment; + } + else if (!string.IsNullOrEmpty(referenceV3) && IsSubComponent(referenceV3)) { // Enables setting the complete JSON path for nested subschemas e.g. #/components/schemas/person/properties/address if (useExternal) @@ -698,6 +706,25 @@ private static bool IsSubComponent(string reference) return false; } + private static bool TryGetPlainNameFragment(string reference, out string fragment) + { + fragment = string.Empty; + var parts = reference.Split('#'); + if (parts.Length == 2 && + !string.IsNullOrEmpty(parts[1]) && +#if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER + !parts[1].StartsWith('/')) +#else + !parts[1].StartsWith("/", StringComparison.Ordinal)) +#endif + { + fragment = $"#{parts[1]}"; + return true; + } + + return false; + } + /// /// Reads the stream input and parses it into an Open API document. /// @@ -830,6 +857,34 @@ static bool AddToDictionary(IDictionary dict, string key // Register only if it was actually added to the collection return added && (Workspace?.RegisterComponentForDocument(this, componentToRegister, id) ?? false); } + + /// + /// Finds an operation in the document by its operation ID. + /// + /// The operation ID to search for. + /// The matching , or if not found. + public OpenApiOperation? GetOperationById(string operationId) + { + Utils.CheckArgumentNullOrEmpty(operationId); + + return GetOperationByIdFromPathItems(Paths, operationId) ?? + (Webhooks is not null ? + GetOperationByIdFromPathItems(Webhooks, operationId) : null); + } + + private static OpenApiOperation? GetOperationByIdFromPathItems(IDictionary pathItems, string operationId) + { + foreach (var pathItem in pathItems.Values) + { + if (pathItem.Operations is null) continue; + foreach (var operation in pathItem.Operations.Values) + { + if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal)) + return operation; + } + } + return null; + } } internal class FindSchemaReferences : OpenApiVisitorBase diff --git a/src/Microsoft.OpenApi/Models/OpenApiLicense.cs b/src/Microsoft.OpenApi/Models/OpenApiLicense.cs index 6645cc8d3..4c9a32945 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiLicense.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiLicense.cs @@ -73,6 +73,7 @@ public virtual void SerializeAsV31(IOpenApiWriter writer) public virtual void SerializeAsV3(IOpenApiWriter writer) { WriteInternal(writer, OpenApiSpecVersion.OpenApi3_0); + writer.WriteProperty(OpenApiConstants.OaiLicenseIdentifier, Identifier); writer.WriteEndObject(); } @@ -82,6 +83,7 @@ public virtual void SerializeAsV3(IOpenApiWriter writer) public virtual void SerializeAsV2(IOpenApiWriter writer) { WriteInternal(writer, OpenApiSpecVersion.OpenApi2_0); + writer.WriteProperty(OpenApiConstants.OaiLicenseIdentifier, Identifier); writer.WriteEndObject(); } diff --git a/src/Microsoft.OpenApi/Models/OpenApiOperation.cs b/src/Microsoft.OpenApi/Models/OpenApiOperation.cs index e4625ff1d..ee1afcc0a 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiOperation.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiOperation.cs @@ -75,7 +75,7 @@ public ISet? Tags public IOpenApiRequestBody? RequestBody { get; set; } /// - /// REQUIRED. The list of possible responses as they are returned from executing this operation. + /// The list of possible responses as they are returned from executing this operation. /// public OpenApiResponses? Responses { get; set; } = []; diff --git a/src/Microsoft.OpenApi/Models/OpenApiParameter.cs b/src/Microsoft.OpenApi/Models/OpenApiParameter.cs index d6b060a78..24c8db173 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiParameter.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiParameter.cs @@ -8,6 +8,7 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// Parameter Object. /// @@ -32,6 +33,7 @@ public class OpenApiParameter : IOpenApiExtensible, IOpenApiParameter public bool Deprecated { get; set; } /// + [Obsolete("Use of AllowEmptyValue is not recommended and it is likely to be removed in a later revision.")] public bool AllowEmptyValue { get; set; } /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiPaths.cs b/src/Microsoft.OpenApi/Models/OpenApiPaths.cs index 18e67c313..1aa163e3c 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiPaths.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiPaths.cs @@ -17,6 +17,9 @@ public OpenApiPaths() { } /// Initializes a copy of object /// /// The . + /// + /// This creates a shallow copy, the path items are the same reference as in the provided parameter. + /// public OpenApiPaths(OpenApiPaths paths) : base(dictionary: paths) { } } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiResponse.cs b/src/Microsoft.OpenApi/Models/OpenApiResponse.cs index 4ee39336e..6bd02af25 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiResponse.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiResponse.cs @@ -73,7 +73,7 @@ public virtual void SerializeAsV3(IOpenApiWriter writer) SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); } - private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, + private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) { Utils.CheckArgumentNull(writer); @@ -87,7 +87,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version } // description - writer.WriteRequiredProperty(OpenApiConstants.Description, Description); + writer.WriteProperty(OpenApiConstants.Description, Description); // headers writer.WriteOptionalMap(OpenApiConstants.Headers, Headers, callback); @@ -120,7 +120,7 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) writer.WriteStartObject(); // description - writer.WriteRequiredProperty(OpenApiConstants.Description, Description); + writer.WriteProperty(OpenApiConstants.Description, Description); var extensionsClone = Extensions is not null ? new Dictionary(Extensions) : null; @@ -177,7 +177,7 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) // so remove it from the cloned collection so we don't write it again. extensionsClone?.Remove(key); } - } + } } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 40f24bdd0..79bd88dd5 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -9,6 +9,7 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// The Schema Object allows the definition of input and output data types. /// @@ -18,8 +19,10 @@ namespace Microsoft.OpenApi /// - Serialization: To produce something functionally equivalent to boolean schemas, create an empty /// for "true" behavior, or create a schema with only set to an empty schema for "false" behavior. /// - public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer + public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer { + private static readonly IEnumerable s_singleNullElementList = [JsonNullSentinel.JsonNull]; + /// public string? Title { get; set; } @@ -44,6 +47,9 @@ public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaW /// public IDictionary? Definitions { get; set; } + /// + public string? Anchor { get; set; } + private string? _exclusiveMaximum; /// public string? ExclusiveMaximum @@ -105,16 +111,23 @@ public string? ExclusiveMinimum /// public JsonSchemaType? Type { get; set; } - // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. - private bool IsNullable => - (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)) || - Extensions is not null && - Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && - nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } && - jsonNode.GetValueKind() is JsonValueKind.True; + private bool HasNullType + => Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null); + + internal bool WasConstExplicitlySet { get; private set; } /// - public string? Const { get; set; } + public string? Const + { + get => field; + set + { + // TODO: In the next major release, Const should be made a JsonNode. + // See https://github.com/microsoft/OpenAPI.NET/issues/2935 for more information. + WasConstExplicitlySet = true; + field = value; + } + } /// public string? Format { get; set; } @@ -207,6 +220,15 @@ public string? Minimum /// public bool? UniqueItems { get; set; } + /// + public IOpenApiSchema? Contains { get; set; } + + /// + public uint? MaxContains { get; set; } + + /// + public uint? MinContains { get; set; } + /// public IDictionary? Properties { get; set; } @@ -229,6 +251,7 @@ public string? Minimum public OpenApiDiscriminator? Discriminator { get; set; } /// + [Obsolete("Use Examples instead.")] public JsonNode? Example { get; set; } /// @@ -243,6 +266,30 @@ public string? Minimum /// public IOpenApiSchema? UnevaluatedPropertiesSchema { get; set; } + /// + public string? ContentEncoding { get; set; } + + /// + public string? ContentMediaType { get; set; } + + /// + public IOpenApiSchema? ContentSchema { get; set; } + + /// + public IOpenApiSchema? PropertyNames { get; set; } + + /// + public IDictionary? DependentSchemas { get; set; } + + /// + public IOpenApiSchema? If { get; set; } + + /// + public IOpenApiSchema? Then { get; set; } + + /// + public IOpenApiSchema? Else { get; set; } + /// public OpenApiExternalDocs? ExternalDocs { get; set; } @@ -279,16 +326,43 @@ internal OpenApiSchema(IOpenApiSchema schema) Title = schema.Title ?? Title; Id = schema.Id ?? Id; Const = schema.Const ?? Const; + if (schema is OpenApiSchema concreteSchema) + { + WasConstExplicitlySet = concreteSchema.WasConstExplicitlySet; + } + else if (Const is null) + { + WasConstExplicitlySet = false; + } + Schema = schema.Schema ?? Schema; Comment = schema.Comment ?? Comment; Vocabulary = schema.Vocabulary != null ? new Dictionary(schema.Vocabulary) : null; + if (schema is IOpenApiSchemaMissingProperties { Anchor: not null } missingPropertiesWithAnchor) + { + Anchor = missingPropertiesWithAnchor.Anchor; + } DynamicAnchor = schema.DynamicAnchor ?? DynamicAnchor; DynamicRef = schema.DynamicRef ?? DynamicRef; Definitions = schema.Definitions != null ? new Dictionary(schema.Definitions) : null; UnevaluatedProperties = schema.UnevaluatedProperties; - if (schema is IOpenApiSchemaWithUnevaluatedProperties { UnevaluatedPropertiesSchema: { } unevaluatedSchema }) + if (schema is IOpenApiSchemaMissingProperties missingProperties) { - UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); + Contains = missingProperties.Contains?.CreateShallowCopy(); + MaxContains = missingProperties.MaxContains ?? MaxContains; + MinContains = missingProperties.MinContains ?? MinContains; + if (missingProperties.UnevaluatedPropertiesSchema is { } unevaluatedSchema) + { + UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); + } + ContentEncoding = missingProperties.ContentEncoding ?? ContentEncoding; + ContentMediaType = missingProperties.ContentMediaType ?? ContentMediaType; + ContentSchema = missingProperties.ContentSchema?.CreateShallowCopy(); + PropertyNames = missingProperties.PropertyNames?.CreateShallowCopy(); + DependentSchemas = missingProperties.DependentSchemas != null ? new Dictionary(missingProperties.DependentSchemas) : null; + If = missingProperties.If?.CreateShallowCopy(); + Then = missingProperties.Then?.CreateShallowCopy(); + Else = missingProperties.Else?.CreateShallowCopy(); } ExclusiveMaximum = schema.ExclusiveMaximum ?? ExclusiveMaximum; ExclusiveMinimum = schema.ExclusiveMinimum ?? ExclusiveMinimum; @@ -404,7 +478,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version if (version >= OpenApiSpecVersion.OpenApi3_1) { - WriteJsonSchemaKeywords(writer); + WriteJsonSchemaKeywords(writer, callback); } // title @@ -456,39 +530,36 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // enum var enumValue = Enum is not { Count: > 0 } - && !string.IsNullOrEmpty(Const) + && WasConstExplicitlySet && version < OpenApiSpecVersion.OpenApi3_1 ? new List { JsonValue.Create(Const)! } : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); - // Handle oneOf/anyOf with null type for v3.0 downcast - IList? effectiveOneOf = OneOf; - IList? effectiveAnyOf = AnyOf; - bool hasNullInComposition = false; - JsonSchemaType? inferredType = null; - if (version == OpenApiSpecVersion.OpenApi3_0) { - (effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); - hasNullInComposition |= nullInOneOf; - inferredType = inferredOneOf ?? inferredType; - (effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); - hasNullInComposition |= nullInAnyOf; - inferredType = inferredAnyOf ?? inferredType; + // If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value. + if (Type == JsonSchemaType.Null && + OneOf is not { Count: > 0 } && + AnyOf is not { Count: > 0 } && + AllOf is not { Count: > 0 } && + Enum is not { Count: > 0 }) + { + writer.WriteOptionalCollection(OpenApiConstants.Enum, s_singleNullElementList, (nodeWriter, s) => nodeWriter.WriteAny(s)); + } } // type - SerializeTypeProperty(writer, version, inferredType); + SerializeTypePropertyForVersion3AndLater(writer, version, callback); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); // anyOf - writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback); + writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback); // oneOf - writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback); + writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback); // not writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback); @@ -526,7 +597,12 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // nullable if (version == OpenApiSpecVersion.OpenApi3_0) { - SerializeNullable(writer, version, hasNullInComposition); + // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20 + // This keyword only takes effect if type is explicitly defined within the same Schema Object. + // + // We don't care to avoid an unnecessary serialization. + // So, we attempt to serialize it regardless of whether or not a type property was serialized. + SerializeNullable(writer, version); } // discriminator @@ -545,7 +621,10 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalObject(OpenApiConstants.ExternalDocs, ExternalDocs, callback); // example - writer.WriteOptionalObject(OpenApiConstants.Example, Example, (w, e) => w.WriteAny(e)); + writer.WriteOptionalObject( + OpenApiConstants.Example, + version < OpenApiSpecVersion.OpenApi3_1 ? GetCompatibilityExample() : Example, + (w, e) => w.WriteAny(e)); // deprecated writer.WriteProperty(OpenApiConstants.Deprecated, Deprecated, false); @@ -578,6 +657,8 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version { writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback); } + + WriteV3CompatibilityKeywords(writer, callback); } // extensions @@ -599,17 +680,23 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) SerializeAsV2(writer: writer, parentRequiredProperties: new HashSet(), propertyName: null); } - internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) + internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action callback) { writer.WriteProperty(OpenApiConstants.Id, Id); writer.WriteProperty(OpenApiConstants.DollarSchema, Schema?.ToString()); writer.WriteProperty(OpenApiConstants.Comment, Comment); - writer.WriteProperty(OpenApiConstants.Const, Const); + + if (WasConstExplicitlySet) + { + writer.WriteRequiredProperty(OpenApiConstants.Const, Const); + } + writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); - writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, callback); + writer.WriteProperty(OpenApiConstants.Anchor, Anchor); writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); - + // UnevaluatedProperties: similar to AdditionalProperties, serialize as schema if present, else as boolean. // Only emit when the type could include objects. // Skip when type is explicitly set to a non-object type (array, string, number, integer, boolean, null). @@ -620,7 +707,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteOptionalObject( OpenApiConstants.UnevaluatedProperties, UnevaluatedPropertiesSchema, - (w, s) => s.SerializeAsV31(w)); + callback); } else if (!UnevaluatedProperties) { @@ -628,8 +715,42 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) } } writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (nodeWriter, s) => nodeWriter.WriteAny(s)); - writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, callback); writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); + + // contains + writer.WriteOptionalObject(OpenApiConstants.Contains, Contains, callback); + + // maxContains + writer.WriteProperty(OpenApiConstants.MaxContains, MaxContains); + + // minContains + writer.WriteProperty(OpenApiConstants.MinContains, MinContains); + writer.WriteProperty(OpenApiConstants.ContentEncoding, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaType, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, callback); + writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, callback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, callback); + writer.WriteOptionalObject(OpenApiConstants.If, If, callback); + writer.WriteOptionalObject(OpenApiConstants.Then, Then, callback); + writer.WriteOptionalObject(OpenApiConstants.Else, Else, callback); + } + + private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action callback) + { + writer.WriteProperty(OpenApiConstants.AnchorExtension, Anchor); + writer.WriteProperty(OpenApiConstants.ContentEncodingExtension, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaTypeExtension, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchemaExtension, ContentSchema, callback); + writer.WriteOptionalObject(OpenApiConstants.ContainsExtension, Contains, callback); + writer.WriteProperty(OpenApiConstants.MaxContainsExtension, MaxContains); + writer.WriteProperty(OpenApiConstants.MinContainsExtension, MinContains); + writer.WriteOptionalObject(OpenApiConstants.PropertyNamesExtension, PropertyNames, callback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemasExtension, DependentSchemas, callback); + writer.WriteOptionalObject(OpenApiConstants.IfExtension, If, callback); + writer.WriteOptionalObject(OpenApiConstants.ThenExtension, Then, callback); + writer.WriteOptionalObject(OpenApiConstants.ElseExtension, Else, callback); + writer.WriteOptionalCollection(OpenApiConstants.JsonSchemaExamplesExtension, GetCompatibilityExamplesExtension(), (nodeWriter, s) => nodeWriter.WriteAny(s)); } internal void WriteAsItemsProperties(IOpenApiWriter writer) @@ -717,7 +838,7 @@ private void SerializeAsV2( writer.WriteStartObject(); // type - SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); + SerializeTypePropertyForVersion2(writer); // description writer.WriteProperty(OpenApiConstants.Description, Description); @@ -776,7 +897,7 @@ private void SerializeAsV2( }); // enum - var enumValue = Enum is not { Count: > 0 } && !string.IsNullOrEmpty(Const) + var enumValue = Enum is not { Count: > 0 } && WasConstExplicitlySet ? new List { JsonValue.Create(Const)! } : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); @@ -799,6 +920,7 @@ private void SerializeAsV2( // oneOf (Not Supported in V2) - Write the first schema only as an allOf. writer.WriteOptionalCollection(OpenApiConstants.AllOf, OneOf?.Take(1), (w, s) => s.SerializeAsV2(w)); } +#pragma warning restore CS0618 } // properties @@ -842,7 +964,7 @@ private void SerializeAsV2( writer.WriteOptionalObject(OpenApiConstants.ExternalDocs, ExternalDocs, (w, s) => s.SerializeAsV2(w)); // example - writer.WriteOptionalObject(OpenApiConstants.Example, Example, (w, e) => w.WriteAny(e)); + writer.WriteOptionalObject(OpenApiConstants.Example, GetCompatibilityExample(), (w, e) => w.WriteAny(e)); // x-nullable extension SerializeNullable(writer, OpenApiSpecVersion.OpenApi2_0); @@ -873,119 +995,162 @@ private void SerializeAsV2( writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, (w, s) => s.SerializeAsV2(w)); } + writer.WriteOptionalCollection(OpenApiConstants.JsonSchemaExamplesExtension, GetCompatibilityExamplesExtension(), (nodeWriter, s) => nodeWriter.WriteAny(s)); + // extensions writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); writer.WriteEndObject(); } - private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) + private void SerializeTypePropertyForVersion2(IOpenApiWriter writer) { - // Use original type or inferred type when the explicit type is not set - var typeToUse = Type ?? inferredType; - - if (typeToUse is null) + if (Type is not { } type || type == JsonSchemaType.Null) { return; } - var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value; - var typeWithoutNull = unifiedType & ~JsonSchemaType.Null; - - switch (version) + var typeWithoutNull = type & ~JsonSchemaType.Null; + if (!HasMultipleTypes(typeWithoutNull)) { - case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0: - if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull)) - { - writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); - } - break; - default: - WriteUnifiedSchemaType(unifiedType, writer); - break; + writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); } } - private static bool IsPowerOfTwo(int x) - { - return x != 0 && (x & (x - 1)) == 0; - } - - private static bool HasMultipleTypes(JsonSchemaType schemaType) + /// + /// Serializes the "type" property for OpenAPI v3 and later versions, + /// falling back to anyOf/oneOf when multiple types cannot be expressed + /// using the "type" property alone (OpenAPI 3.0). + /// + private void SerializeTypePropertyForVersion3AndLater(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) { - var schemaTypeNumeric = (int)schemaType; - return !IsPowerOfTwo(schemaTypeNumeric); - } + if (Type is not { } type) + { + return; + } - private static void WriteUnifiedSchemaType(JsonSchemaType type, IOpenApiWriter writer) - { - var array = (from JsonSchemaType flag in jsonSchemaTypeValues - where type.HasFlag(flag) - select flag.ToFirstIdentifier()).ToArray(); - if (array.Length > 1) + if (version == OpenApiSpecVersion.OpenApi3_0) { - writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) => + if (type == JsonSchemaType.Null) { - if (!string.IsNullOrEmpty(s) && s is not null) + return; + } + + var typeWithoutNull = type & ~JsonSchemaType.Null; + var hasNull = typeWithoutNull != type; + var arrayWithoutNull = (from JsonSchemaType flag in jsonSchemaTypeValues + where typeWithoutNull.HasFlag(flag) + select flag).ToArray(); + + // - If we have more than one type (excluding null), we have to use anyOf/oneOf. + // - If we have exactly one type alone (without null), we emit the type property. + // - If we have exactly one non-null type and also we have the null type, we emit the type property and nullable: true (handled in SerializeNullable) + if (arrayWithoutNull.Length > 1) + { + // If the schema doesn't already have anyOf/oneOf, we can write multiple types as such. + var canWriteAsAnyOf = AnyOf is not { Count: > 0 }; + var canWriteAsOneOf = OneOf is not { Count: > 0 }; + if (canWriteAsAnyOf) + { + writer.WriteOptionalCollection(OpenApiConstants.AnyOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback); + return; + } + else if (canWriteAsOneOf) { - w.WriteValue(s); + writer.WriteOptionalCollection(OpenApiConstants.OneOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback); + return; } - }); + } + else if (arrayWithoutNull.Length == 1) + { + writer.WriteProperty(OpenApiConstants.Type, arrayWithoutNull[0].ToSingleIdentifier()); + return; + } } else { - writer.WriteProperty(OpenApiConstants.Type, array[0]); + var array = (from JsonSchemaType flag in jsonSchemaTypeValues + where type.HasFlag(flag) + select flag).ToArray(); + + if (array.Length > 1) + { + writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) => w.WriteValue(s.ToSingleIdentifier())); + } + else if (array.Length == 1) + { + writer.WriteProperty(OpenApiConstants.Type, array[0].ToSingleIdentifier()); + } } - } - private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) - { - if (IsNullable || hasNullInComposition) + static OpenApiSchema[] ConstructChildSchemasForTypes(JsonSchemaType[] types, bool hasNull) { - switch (version) + var schemas = new OpenApiSchema[types.Length + (hasNull ? 1 : 0)]; + for (int i = 0; i < types.Length; i++) { - case OpenApiSpecVersion.OpenApi2_0: - writer.WriteProperty(OpenApiConstants.NullableExtension, true); - break; - case OpenApiSpecVersion.OpenApi3_0: - writer.WriteProperty(OpenApiConstants.Nullable, true); - break; + schemas[i] = new OpenApiSchema() + { + Type = types[i] + }; } + + if (hasNull) + { + schemas[schemas.Length - 1] = new OpenApiSchema() { Type = JsonSchemaType.Null }; + } + + return schemas; } } - /// - /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type. - /// - /// The list of schemas in the composition. - /// A tuple with the effective list, inferred type, and whether null is present in composition. - private static (IList? effective, JsonSchemaType? inferredType, bool hasNullInComposition) - ProcessCompositionForNull(IList? composition) + private JsonNode? GetCompatibilityExample() + { +#pragma warning disable CS0618 // Type or member is obsolete + return Example ?? Examples?.FirstOrDefault(); +#pragma warning restore CS0618 // Type or member is obsolete + } + + private IEnumerable? GetCompatibilityExamplesExtension() { - if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null)) + if (Examples is null || Examples.Count == 0) { - // Nothing to patch - return (composition, null, false); + return null; } - var nonNullSchemas = composition - .Where(static s => s.Type is null or not JsonSchemaType.Null) - .ToList(); - - if (nonNullSchemas.Count > 0) +#pragma warning disable CS0618 // Type or member is obsolete + if (Example is not null) { - JsonSchemaType commonType = 0; + return Examples; + } +#pragma warning restore CS0618 // Type or member is obsolete - foreach (var schema in nonNullSchemas) - { - commonType |= schema.Type.GetValueOrDefault() & ~JsonSchemaType.Null; - } + return Examples.Count > 1 ? Examples.Skip(1) : null; + } - return (nonNullSchemas, commonType, true); - } - else + private static bool IsPowerOfTwo(int x) + { + return x != 0 && (x & (x - 1)) == 0; + } + + private static bool HasMultipleTypes(JsonSchemaType schemaType) + { + var schemaTypeNumeric = (int)schemaType; + return !IsPowerOfTwo(schemaTypeNumeric); + } + + private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version) + { + if (HasNullType) { - return (null, null, true); + switch (version) + { + case OpenApiSpecVersion.OpenApi2_0: + writer.WriteProperty(OpenApiConstants.NullableExtension, true); + break; + case OpenApiSpecVersion.OpenApi3_0: + writer.WriteProperty(OpenApiConstants.Nullable, true); + break; + } } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs b/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs index 626f7f2bd..225910968 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSecurityScheme.cs @@ -97,6 +97,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version Action callback) { Utils.CheckArgumentNull(writer); + EnsureRequiredPropertiesAreSet(version); writer.WriteStartObject(); @@ -132,7 +133,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version } else { - writer.WriteProperty("x-oauth2-metadata-url", OAuth2MetadataUrl?.ToString()); + writer.WriteProperty(OpenApiConstants.OAuth2MetadataUrlExtension, OAuth2MetadataUrl?.ToString()); } writer.WriteOptionalObject(OpenApiConstants.Flows, Flows, callback); break; @@ -176,6 +177,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version public virtual void SerializeAsV2(IOpenApiWriter writer) { Utils.CheckArgumentNull(writer); + EnsureRequiredPropertiesAreSet(OpenApiSpecVersion.OpenApi2_0); if (Type == SecuritySchemeType.Http && Scheme != OpenApiConstants.Basic) { @@ -239,6 +241,25 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) writer.WriteEndObject(); } + private void EnsureRequiredPropertiesAreSet(OpenApiSpecVersion version) + { + switch (Type) + { + case null: + throw new OpenApiException("The 'type' property is required for security schemes."); + case SecuritySchemeType.ApiKey when string.IsNullOrEmpty(Name): + throw new OpenApiException("The 'name' property is required for apiKey security schemes."); + case SecuritySchemeType.ApiKey when In is null: + throw new OpenApiException("The 'in' property is required for apiKey security schemes."); + case SecuritySchemeType.Http when version >= OpenApiSpecVersion.OpenApi3_0 && string.IsNullOrEmpty(Scheme): + throw new OpenApiException("The 'scheme' property is required for http security schemes."); + case SecuritySchemeType.OAuth2 when Flows is null: + throw new OpenApiException("The 'flows' property is required for oauth2 security schemes."); + case SecuritySchemeType.OpenIdConnect when version >= OpenApiSpecVersion.OpenApi3_0 && OpenIdConnectUrl is null: + throw new OpenApiException("The 'openIdConnectUrl' property is required for openIdConnect security schemes."); + } + } + /// /// Arbitrarily chooses one object from the /// to populate in V2 security scheme. diff --git a/src/Microsoft.OpenApi/Models/OpenApiXml.cs b/src/Microsoft.OpenApi/Models/OpenApiXml.cs index 82d7c2180..6e4e88962 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiXml.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiXml.cs @@ -30,7 +30,7 @@ public class OpenApiXml : IOpenApiSerializable, IOpenApiExtensible /// Declares whether the property definition translates to an attribute instead of an element. /// Default value is false. /// - [Obsolete("Use NodeType property instead. This property will be removed in a future version.")] + [Obsolete("Use NodeType set to OpenApiXmlNodeType.Attribute instead. This property will be removed in a future version.")] internal bool Attribute { get @@ -47,7 +47,7 @@ internal bool Attribute /// Signifies whether the array is wrapped. /// Default value is false. /// - [Obsolete("Use NodeType property instead. This property will be removed in a future version.")] + [Obsolete("Use NodeType set to OpenApiXmlNodeType.Element instead. This property will be removed in a future version.")] internal bool Wrapped { get diff --git a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs index ed2936bd4..2af750476 100644 --- a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs +++ b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; namespace Microsoft.OpenApi; /// @@ -10,6 +11,11 @@ namespace Microsoft.OpenApi; /// The type for the reference holding the additional fields and annotations public abstract class BaseOpenApiReferenceHolder : IOpenApiReferenceHolder where T : class, IOpenApiReferenceable, U where U : IOpenApiReferenceable, IOpenApiSerializable where V : BaseOpenApiReference, new() { + [ThreadStatic] + private static HashSet>? t_activeReferenceAccesses; + [ThreadStatic] + private static HashSet>? t_activeTargetActions; + /// public virtual U? Target { @@ -19,28 +25,129 @@ public virtual U? Target return Reference.HostDocument.ResolveReferenceTo(Reference, this as IOpenApiSchema); } } + + /// + /// Gets a value from the resolved target while detecting cycles in delegated member access. + /// + /// The type of value to get from the target. + /// Selects the value from the resolved target. + /// The selected value, or the default value when the target cannot be resolved. + /// + /// The guard remains active while reads the target member. This covers + /// the complete delegated call chain without changing the immediate-resolution semantics of + /// or walking an acyclic chain more than once. + /// + private protected TResult GetFromTarget(Func selector) + { + Utils.CheckArgumentNull(selector); + return ExecuteWithReferenceAccessGuard(this, () => + { + return Target is { } target ? selector(target) : default!; + }); + } + + /// + /// Executes an action against the resolved target while detecting cycles in delegated access. + /// + /// The action to execute against the resolved target. + private protected void ApplyToTarget(Action action) + { + Utils.CheckArgumentNull(action); + ExecuteWithTargetActionGuard(this, () => + { + if (Target is { } target) + { + action(target); + } + return null; + }); + } + + private static TResult ExecuteWithReferenceAccessGuard( + BaseOpenApiReferenceHolder holder, + Func action) + { + return ExecuteWithReferenceGuard(ref t_activeReferenceAccesses, holder, action); + } + + private static TResult ExecuteWithTargetActionGuard( + BaseOpenApiReferenceHolder holder, + Func action) + { + return ExecuteWithReferenceGuard(ref t_activeTargetActions, holder, action); + } + + private static TResult ExecuteWithReferenceGuard( + ref HashSet>? activeReferences, + BaseOpenApiReferenceHolder holder, + Func action) + { + activeReferences ??= new HashSet>(ReferenceHolderComparer.Instance); + if (!activeReferences.Add(holder)) + { + throw new InvalidOperationException($"Circular reference detected while resolving reference: {holder.Reference.ReferenceV3}"); + } + + try + { + RuntimeHelpers.EnsureSufficientExecutionStack(); + return action(); + } + catch (InsufficientExecutionStackException ex) + { + throw new InvalidOperationException( + $"The chain of references starting at {holder.Reference.ReferenceV3} is nested too deeply to resolve.", + ex); + } + finally + { + activeReferences.Remove(holder); + if (activeReferences.Count == 0) + { + activeReferences = null; + } + } + } + /// public T? RecursiveTarget { get { - return ResolveRecursiveTarget(new HashSet>()); + var visitedReferences = new HashSet>(ReferenceHolderComparer.Instance); + BaseOpenApiReferenceHolder current = this; + + while (visitedReferences.Add(current)) + { + switch (current.Target) + { + case BaseOpenApiReferenceHolder recursiveTarget: + current = recursiveTarget; + break; + case T concrete: + return concrete; + default: + return null; + } + } + + throw new InvalidOperationException($"Circular reference detected while resolving reference: {current.Reference.ReferenceV3}"); } } - private T? ResolveRecursiveTarget(ISet> visitedReferences) + private sealed class ReferenceHolderComparer : IEqualityComparer> { - if (!visitedReferences.Add(this)) + internal static ReferenceHolderComparer Instance { get; } = new(); + + public bool Equals(BaseOpenApiReferenceHolder? x, BaseOpenApiReferenceHolder? y) { - throw new InvalidOperationException($"Circular reference detected while resolving reference: {Reference.ReferenceV3}"); + return ReferenceEquals(x, y); } - return Target switch + public int GetHashCode(BaseOpenApiReferenceHolder obj) { - BaseOpenApiReferenceHolder recursiveTarget => recursiveTarget.ResolveRecursiveTarget(visitedReferences), - T concrete => concrete, - _ => null - }; + return RuntimeHelpers.GetHashCode(obj); + } } /// /// Copy the reference as a target element with overrides. @@ -160,9 +267,6 @@ private protected void SerializeInternal(IOpenApiWriter writer, Action action) { Utils.CheckArgumentNull(writer); - if (Target is not null) - { - action(writer, Target); - } + ApplyToTarget(element => action(writer, element)); } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiCallbackReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiCallbackReference.cs index bdf22eb03..cd6cfdeff 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiCallbackReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiCallbackReference.cs @@ -33,10 +33,10 @@ private OpenApiCallbackReference(OpenApiCallbackReference callback) : base(callb } /// - public Dictionary? PathItems { get => Target?.PathItems; } + public Dictionary? PathItems { get => GetFromTarget(static target => target.PathItems); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiCallback CopyReferenceAsTargetElementWithOverrides(IOpenApiCallback source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiExampleReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiExampleReference.cs index 5834d4fb9..207271afe 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiExampleReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiExampleReference.cs @@ -35,31 +35,31 @@ private OpenApiExampleReference(OpenApiExampleReference example) : base(example) /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// public string? Summary { - get => string.IsNullOrEmpty(Reference.Summary) ? Target?.Summary : Reference.Summary; + get => string.IsNullOrEmpty(Reference.Summary) ? GetFromTarget(static target => target.Summary) : Reference.Summary; set => Reference.Summary = value; } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// - public string? ExternalValue { get => Target?.ExternalValue; } + public string? ExternalValue { get => GetFromTarget(static target => target.ExternalValue); } /// - public JsonNode? Value { get => Target?.Value; } + public JsonNode? Value { get => GetFromTarget(static target => target.Value); } /// - public JsonNode? DataValue { get => Target?.DataValue; } + public JsonNode? DataValue { get => GetFromTarget(static target => target.DataValue); } /// - public string? SerializedValue { get => Target?.SerializedValue; } + public string? SerializedValue { get => GetFromTarget(static target => target.SerializedValue); } /// public override IOpenApiExample CopyReferenceAsTargetElementWithOverrides(IOpenApiExample source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiHeaderReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiHeaderReference.cs index d0c7fe04e..b419724f4 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiHeaderReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiHeaderReference.cs @@ -36,42 +36,42 @@ private OpenApiHeaderReference(OpenApiHeaderReference header) : base(header) /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public bool Required { get => Target?.Required ?? default; } + public bool Required { get => GetFromTarget(static target => target.Required); } /// - public bool Deprecated { get => Target?.Deprecated ?? default; } + public bool Deprecated { get => GetFromTarget(static target => target.Deprecated); } /// - public bool AllowEmptyValue { get => Target?.AllowEmptyValue ?? default; } + public bool AllowEmptyValue { get => GetFromTarget(static target => target.AllowEmptyValue); } /// - public IOpenApiSchema? Schema { get => Target?.Schema; } + public IOpenApiSchema? Schema { get => GetFromTarget(static target => target.Schema); } /// - public ParameterStyle? Style { get => Target?.Style; } + public ParameterStyle? Style { get => GetFromTarget(static target => target.Style); } /// - public bool Explode { get => Target?.Explode ?? default; } + public bool Explode { get => GetFromTarget(static target => target.Explode); } /// - public bool AllowReserved { get => Target?.AllowReserved ?? default; } + public bool AllowReserved { get => GetFromTarget(static target => target.AllowReserved); } /// - public JsonNode? Example { get => Target?.Example; } + public JsonNode? Example { get => GetFromTarget(static target => target.Example); } /// - public IDictionary? Examples { get => Target?.Examples; } + public IDictionary? Examples { get => GetFromTarget(static target => target.Examples); } /// - public IDictionary? Content { get => Target?.Content; } + public IDictionary? Content { get => GetFromTarget(static target => target.Content); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiHeader CopyReferenceAsTargetElementWithOverrides(IOpenApiHeader source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiLinkReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiLinkReference.cs index a11decf63..2e338493c 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiLinkReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiLinkReference.cs @@ -34,27 +34,27 @@ private OpenApiLinkReference(OpenApiLinkReference reference) : base(reference) /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public string? OperationRef { get => Target?.OperationRef; } + public string? OperationRef { get => GetFromTarget(static target => target.OperationRef); } /// - public string? OperationId { get => Target?.OperationId; } + public string? OperationId { get => GetFromTarget(static target => target.OperationId); } /// - public OpenApiServer? Server { get => Target?.Server; } + public OpenApiServer? Server { get => GetFromTarget(static target => target.Server); } /// - public IDictionary? Parameters { get => Target?.Parameters; } + public IDictionary? Parameters { get => GetFromTarget(static target => target.Parameters); } /// - public RuntimeExpressionAnyWrapper? RequestBody { get => Target?.RequestBody; } + public RuntimeExpressionAnyWrapper? RequestBody { get => GetFromTarget(static target => target.RequestBody); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override void SerializeAsV2(IOpenApiWriter writer) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiMediaTypeReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiMediaTypeReference.cs index 47816ed44..4ba18ffc1 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiMediaTypeReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiMediaTypeReference.cs @@ -34,28 +34,28 @@ private OpenApiMediaTypeReference(OpenApiMediaTypeReference mediaTypeReference) } /// - public IOpenApiSchema? Schema { get => Target?.Schema; } + public IOpenApiSchema? Schema { get => GetFromTarget(static target => target.Schema); } /// - public IOpenApiSchema? ItemSchema { get => Target?.ItemSchema; } + public IOpenApiSchema? ItemSchema { get => GetFromTarget(static target => target.ItemSchema); } /// - public JsonNode? Example { get => Target?.Example; } + public JsonNode? Example { get => GetFromTarget(static target => target.Example); } /// - public IDictionary? Examples { get => Target?.Examples; } + public IDictionary? Examples { get => GetFromTarget(static target => target.Examples); } /// - public IDictionary? Encoding { get => Target?.Encoding; } + public IDictionary? Encoding { get => GetFromTarget(static target => target.Encoding); } /// - public OpenApiEncoding? ItemEncoding { get => Target?.ItemEncoding; } + public OpenApiEncoding? ItemEncoding { get => GetFromTarget(static target => target.ItemEncoding); } /// - public IList? PrefixEncoding { get => Target?.PrefixEncoding; } + public IList? PrefixEncoding { get => GetFromTarget(static target => target.PrefixEncoding); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiMediaType CopyReferenceAsTargetElementWithOverrides(IOpenApiMediaType source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiParameterReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiParameterReference.cs index 3c9670ac5..a0c1a3783 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiParameterReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiParameterReference.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using System.Collections.Generic; using System.Text.Json.Nodes; namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// Parameter Object Reference. /// @@ -34,50 +36,51 @@ private OpenApiParameterReference(OpenApiParameterReference parameter):base(para } /// - public string? Name { get => Target?.Name; } + public string? Name { get => GetFromTarget(static target => target.Name); } /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public bool Required { get => Target?.Required ?? default; } + public bool Required { get => GetFromTarget(static target => target.Required); } /// - public bool Deprecated { get => Target?.Deprecated ?? default; } + public bool Deprecated { get => GetFromTarget(static target => target.Deprecated); } /// - public bool AllowEmptyValue { get => Target?.AllowEmptyValue ?? default; } + [Obsolete("Use of AllowEmptyValue is not recommended and it is likely to be removed in a later revision.")] + public bool AllowEmptyValue { get => GetFromTarget(static target => target.AllowEmptyValue); } /// - public bool AllowReserved { get => Target?.AllowReserved ?? default; } + public bool AllowReserved { get => GetFromTarget(static target => target.AllowReserved); } /// - public IOpenApiSchema? Schema { get => Target?.Schema; } + public IOpenApiSchema? Schema { get => GetFromTarget(static target => target.Schema); } /// - public IDictionary? Examples { get => Target?.Examples; } + public IDictionary? Examples { get => GetFromTarget(static target => target.Examples); } /// - public JsonNode? Example { get => Target?.Example; } + public JsonNode? Example { get => GetFromTarget(static target => target.Example); } /// - public ParameterLocation? In { get => Target?.In; } + public ParameterLocation? In { get => GetFromTarget(static target => target.In); } /// - public ParameterStyle? Style { get => Target?.Style; } + public ParameterStyle? Style { get => GetFromTarget(static target => target.Style); } /// - public bool Explode { get => Target?.Explode ?? default; } + public bool Explode { get => GetFromTarget(static target => target.Explode); } /// - public IDictionary? Content { get => Target?.Content; } + public IDictionary? Content { get => GetFromTarget(static target => target.Content); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiParameter CopyReferenceAsTargetElementWithOverrides(IOpenApiParameter source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs index 291c75308..9df1dd719 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs @@ -38,28 +38,28 @@ private OpenApiPathItemReference(OpenApiPathItemReference pathItem) : base(pathI /// public string? Summary { - get => string.IsNullOrEmpty(Reference.Summary) ? Target?.Summary : Reference.Summary; + get => string.IsNullOrEmpty(Reference.Summary) ? GetFromTarget(static target => target.Summary) : Reference.Summary; set => Reference.Summary = value; } /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public Dictionary? Operations { get => Target?.Operations; } + public Dictionary? Operations { get => GetFromTarget(static target => target.Operations); } /// - public IList? Servers { get => Target?.Servers; } + public IList? Servers { get => GetFromTarget(static target => target.Servers); } /// - public IList? Parameters { get => Target?.Parameters; } + public IList? Parameters { get => GetFromTarget(static target => target.Parameters); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiPathItem CopyReferenceAsTargetElementWithOverrides(IOpenApiPathItem source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs index f700f1bc9..66883e557 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs @@ -36,18 +36,18 @@ private OpenApiRequestBodyReference(OpenApiRequestBodyReference openApiRequestBo /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public IDictionary? Content { get => Target?.Content; } + public IDictionary? Content { get => GetFromTarget(static target => target.Content); } /// - public bool Required { get => Target?.Required ?? false; } + public bool Required { get => GetFromTarget(static target => target.Required); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiRequestBody CopyReferenceAsTargetElementWithOverrides(IOpenApiRequestBody source) @@ -64,7 +64,7 @@ public override void SerializeAsV2(IOpenApiWriter writer) { if (writer.GetSettings().ShouldInlineReference(Reference)) { - return Target?.ConvertToBodyParameter(writer); + return GetFromTarget(target => target.ConvertToBodyParameter(writer)); } return Reference.Id is not null ? new OpenApiParameterReference(Reference.Id, Reference.HostDocument) : null; @@ -74,7 +74,7 @@ public override void SerializeAsV2(IOpenApiWriter writer) { if (writer.GetSettings().ShouldInlineReference(Reference)) { - return Target?.ConvertToFormDataParameters(writer); + return GetFromTarget(target => target.ConvertToFormDataParameters(writer)); } if (Content == null || !Content.Any()) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiResponseReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiResponseReference.cs index b99922e79..afd158d37 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiResponseReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiResponseReference.cs @@ -35,28 +35,28 @@ private OpenApiResponseReference(OpenApiResponseReference openApiResponseReferen /// public string? Summary { - get => string.IsNullOrEmpty(Reference.Summary) ? Target?.Summary : Reference.Summary; + get => string.IsNullOrEmpty(Reference.Summary) ? GetFromTarget(static target => target.Summary) : Reference.Summary; set => Reference.Summary = value; } /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public IDictionary? Content { get => Target?.Content; } + public IDictionary? Content { get => GetFromTarget(static target => target.Content); } /// - public IDictionary? Headers { get => Target?.Headers; } + public IDictionary? Headers { get => GetFromTarget(static target => target.Headers); } /// - public IDictionary? Links { get => Target?.Links; } + public IDictionary? Links { get => GetFromTarget(static target => target.Links); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// public override IOpenApiResponse CopyReferenceAsTargetElementWithOverrides(IOpenApiResponse source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 67eb79645..b5bbf8b6d 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -7,10 +7,15 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// - /// Schema reference object + /// Schema reference object. + /// Convenience getters return $ref-sibling keyword values authored on + /// before falling back to resolved values from . + /// These getters are object-model conveniences and do not represent JSON Schema + /// evaluation semantics. /// - public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible + public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible { /// @@ -34,141 +39,186 @@ private OpenApiSchemaReference(OpenApiSchemaReference schema) : base(schema) { } + /// + /// Resolves the target schema. For $dynamicRef-only references, delegates to + /// which resolves via per-document + /// $dynamicAnchor and $anchor registries. Returns null when anchors are ambiguous + /// (multiple candidates require dynamic-scope tracking this library does not perform). + /// + public override IOpenApiSchema? Target + { + get + { + if (Reference.IsDynamicRefOnly + && Reference.HostDocument is { } doc + && doc.Workspace is { } ws) + { + return ws.ResolveDynamicRef(doc, Reference.DynamicRef!); + } + return base.Target; + } + } + /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// public string? Title { - get => string.IsNullOrEmpty(Reference.Title) ? Target?.Title : Reference.Title; + get => string.IsNullOrEmpty(Reference.Title) ? GetFromTarget(static target => target.Title) : Reference.Title; set => Reference.Title = value; } /// - public Uri? Schema { get => Target?.Schema; } + public Uri? Schema { get => Reference.Schema ?? GetFromTarget(static target => target.Schema); set => Reference.Schema = value; } + /// + public string? Id { get => string.IsNullOrEmpty(Reference.SchemaId) ? GetFromTarget(static target => target.Id) : Reference.SchemaId; set => Reference.SchemaId = value; } /// - public string? Id { get => Target?.Id; } + public string? Comment { get => string.IsNullOrEmpty(Reference.Comment) ? GetFromTarget(static target => target.Comment) : Reference.Comment; set => Reference.Comment = value; } /// - public string? Comment { get => Target?.Comment; } + public IDictionary? Vocabulary { get => Reference.Vocabulary ?? GetFromTarget(static target => target.Vocabulary); set => Reference.Vocabulary = value; } /// - public IDictionary? Vocabulary { get => Target?.Vocabulary; } + public string? DynamicRef { get => string.IsNullOrEmpty(Reference.DynamicRef) ? GetFromTarget(static target => target.DynamicRef) : Reference.DynamicRef; set => Reference.DynamicRef = value; } /// - public string? DynamicRef { get => Target?.DynamicRef; } + public string? DynamicAnchor { get => string.IsNullOrEmpty(Reference.DynamicAnchor) ? GetFromTarget(static target => target.DynamicAnchor) : Reference.DynamicAnchor; set => Reference.DynamicAnchor = value; } /// - public string? DynamicAnchor { get => Target?.DynamicAnchor; } + public IDictionary? Definitions { get => Reference.Definitions ?? GetFromTarget(static target => target.Definitions); set => Reference.Definitions = value; } /// - public IDictionary? Definitions { get => Target?.Definitions; } + public string? Anchor { get => string.IsNullOrEmpty(Reference.Anchor) ? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.Anchor) : Reference.Anchor; set => Reference.Anchor = value; } /// - public string? ExclusiveMaximum { get => Target?.ExclusiveMaximum; } + public string? ExclusiveMaximum { get => string.IsNullOrEmpty(Reference.ExclusiveMaximum) ? GetFromTarget(static target => target.ExclusiveMaximum) : Reference.ExclusiveMaximum; set => Reference.ExclusiveMaximum = value; } /// - public string? ExclusiveMinimum { get => Target?.ExclusiveMinimum; } + public string? ExclusiveMinimum { get => string.IsNullOrEmpty(Reference.ExclusiveMinimum) ? GetFromTarget(static target => target.ExclusiveMinimum) : Reference.ExclusiveMinimum; set => Reference.ExclusiveMinimum = value; } /// - public JsonSchemaType? Type { get => Target?.Type; } + public JsonSchemaType? Type { get => Reference.SchemaType ?? GetFromTarget(static target => target.Type); set => Reference.SchemaType = value; } /// - public string? Const { get => Target?.Const; } + public string? Const { get => Reference.WasConstExplicitlySet ? Reference.Const : GetFromTarget(static target => target.Const); set => Reference.Const = value; } /// - public string? Format { get => Target?.Format; } + public string? Format { get => string.IsNullOrEmpty(Reference.Format) ? GetFromTarget(static target => target.Format) : Reference.Format; set => Reference.Format = value; } /// - public string? Maximum { get => Target?.Maximum; } + public string? Maximum { get => string.IsNullOrEmpty(Reference.Maximum) ? GetFromTarget(static target => target.Maximum) : Reference.Maximum; set => Reference.Maximum = value; } /// - public string? Minimum { get => Target?.Minimum; } + public string? Minimum { get => string.IsNullOrEmpty(Reference.Minimum) ? GetFromTarget(static target => target.Minimum) : Reference.Minimum; set => Reference.Minimum = value; } /// - public int? MaxLength { get => Target?.MaxLength; } + public int? MaxLength { get => Reference.MaxLength ?? GetFromTarget(static target => target.MaxLength); set => Reference.MaxLength = value; } /// - public int? MinLength { get => Target?.MinLength; } + public int? MinLength { get => Reference.MinLength ?? GetFromTarget(static target => target.MinLength); set => Reference.MinLength = value; } /// - public string? Pattern { get => Target?.Pattern; } + public string? Pattern { get => string.IsNullOrEmpty(Reference.Pattern) ? GetFromTarget(static target => target.Pattern) : Reference.Pattern; set => Reference.Pattern = value; } /// - public decimal? MultipleOf { get => Target?.MultipleOf; } + public decimal? MultipleOf { get => Reference.MultipleOf ?? GetFromTarget(static target => target.MultipleOf); set => Reference.MultipleOf = value; } /// public JsonNode? Default { - get => Reference.Default ?? Target?.Default; + get => Reference.Default ?? GetFromTarget(static target => target.Default); set => Reference.Default = value; } /// public bool ReadOnly { - get => Reference.ReadOnly ?? Target?.ReadOnly ?? false; + get => Reference.ReadOnly ?? GetFromTarget(static target => target.ReadOnly); set => Reference.ReadOnly = value; } /// public bool WriteOnly { - get => Reference.WriteOnly ?? Target?.WriteOnly ?? false; + get => Reference.WriteOnly ?? GetFromTarget(static target => target.WriteOnly); set => Reference.WriteOnly = value; } /// - public IList? AllOf { get => Target?.AllOf; } + public IList? AllOf { get => Reference.AllOf ?? GetFromTarget(static target => target.AllOf); set => Reference.AllOf = value; } + /// + public IList? OneOf { get => Reference.OneOf ?? GetFromTarget(static target => target.OneOf); set => Reference.OneOf = value; } + /// + public IList? AnyOf { get => Reference.AnyOf ?? GetFromTarget(static target => target.AnyOf); set => Reference.AnyOf = value; } /// - public IList? OneOf { get => Target?.OneOf; } + public IOpenApiSchema? Not { get => Reference.Not ?? GetFromTarget(static target => target.Not); set => Reference.Not = value; } /// - public IList? AnyOf { get => Target?.AnyOf; } + public ISet? Required { get => Reference.Required ?? GetFromTarget(static target => target.Required); set => Reference.Required = value; } /// - public IOpenApiSchema? Not { get => Target?.Not; } + public IOpenApiSchema? Items { get => Reference.Items ?? GetFromTarget(static target => target.Items); set => Reference.Items = value; } /// - public ISet? Required { get => Target?.Required; } + public int? MaxItems { get => Reference.MaxItems ?? GetFromTarget(static target => target.MaxItems); set => Reference.MaxItems = value; } /// - public IOpenApiSchema? Items { get => Target?.Items; } + public int? MinItems { get => Reference.MinItems ?? GetFromTarget(static target => target.MinItems); set => Reference.MinItems = value; } /// - public int? MaxItems { get => Target?.MaxItems; } + public bool? UniqueItems { get => Reference.UniqueItems ?? GetFromTarget(static target => target.UniqueItems); set => Reference.UniqueItems = value; } /// - public int? MinItems { get => Target?.MinItems; } + public IOpenApiSchema? Contains { get => Reference.Contains ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.Contains); set => Reference.Contains = value; } /// - public bool? UniqueItems { get => Target?.UniqueItems; } + public uint? MaxContains { get => Reference.MaxContains ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.MaxContains); set => Reference.MaxContains = value; } /// - public IDictionary? Properties { get => Target?.Properties; } + public uint? MinContains { get => Reference.MinContains ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.MinContains); set => Reference.MinContains = value; } /// - public IDictionary? PatternProperties { get => Target?.PatternProperties; } + public IDictionary? Properties { get => Reference.Properties ?? GetFromTarget(static target => target.Properties); set => Reference.Properties = value; } /// - public int? MaxProperties { get => Target?.MaxProperties; } + public IDictionary? PatternProperties { get => Reference.PatternProperties ?? GetFromTarget(static target => target.PatternProperties); set => Reference.PatternProperties = value; } /// - public int? MinProperties { get => Target?.MinProperties; } + public int? MaxProperties { get => Reference.MaxProperties ?? GetFromTarget(static target => target.MaxProperties); set => Reference.MaxProperties = value; } /// - public bool AdditionalPropertiesAllowed { get => Target?.AdditionalPropertiesAllowed ?? true; } + public int? MinProperties { get => Reference.MinProperties ?? GetFromTarget(static target => target.MinProperties); set => Reference.MinProperties = value; } /// - public IOpenApiSchema? AdditionalProperties { get => Target?.AdditionalProperties; } + public bool AdditionalPropertiesAllowed { get => Reference.AdditionalPropertiesAllowed ?? GetFromTarget(static target => (bool?)target.AdditionalPropertiesAllowed) ?? true; set => Reference.AdditionalPropertiesAllowed = value; } /// - public OpenApiDiscriminator? Discriminator { get => Target?.Discriminator; } + public IOpenApiSchema? AdditionalProperties { get => Reference.AdditionalProperties ?? GetFromTarget(static target => target.AdditionalProperties); set => Reference.AdditionalProperties = value; } /// - public JsonNode? Example { get => Target?.Example; } + public OpenApiDiscriminator? Discriminator { get => Reference.Discriminator ?? GetFromTarget(static target => target.Discriminator); set => Reference.Discriminator = value; } + /// + [Obsolete("Use Examples instead.")] + public JsonNode? Example { get => Reference.Example ?? GetFromTarget(static target => target.Example); set => Reference.Example = value; } /// public IList? Examples { - get => Reference.Examples ?? Target?.Examples; + get => Reference.Examples ?? GetFromTarget(static target => target.Examples); set => Reference.Examples = value; } /// - public IList? Enum { get => Target?.Enum; } + public IList? Enum { get => Reference.Enum ?? GetFromTarget(static target => target.Enum); set => Reference.Enum = value; } + /// + public bool UnevaluatedProperties { get => Reference.UnevaluatedProperties ?? GetFromTarget(static target => (bool?)target.UnevaluatedProperties) ?? true; set => Reference.UnevaluatedProperties = value; } + /// + public IOpenApiSchema? UnevaluatedPropertiesSchema { get => Reference.UnevaluatedPropertiesSchema ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.UnevaluatedPropertiesSchema); set => Reference.UnevaluatedPropertiesSchema = value; } + /// + public string? ContentEncoding { get => string.IsNullOrEmpty(Reference.ContentEncoding) ? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.ContentEncoding) : Reference.ContentEncoding; set => Reference.ContentEncoding = value; } + /// + public string? ContentMediaType { get => string.IsNullOrEmpty(Reference.ContentMediaType) ? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.ContentMediaType) : Reference.ContentMediaType; set => Reference.ContentMediaType = value; } + /// + public IOpenApiSchema? ContentSchema { get => Reference.ContentSchema ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.ContentSchema); set => Reference.ContentSchema = value; } + /// + public IOpenApiSchema? PropertyNames { get => Reference.PropertyNames ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.PropertyNames); set => Reference.PropertyNames = value; } + /// + public IDictionary? DependentSchemas { get => Reference.DependentSchemas ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.DependentSchemas); set => Reference.DependentSchemas = value; } + /// + public IOpenApiSchema? If { get => Reference.If ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.If); set => Reference.If = value; } /// - public bool UnevaluatedProperties { get => Target?.UnevaluatedProperties ?? true; } + public IOpenApiSchema? Then { get => Reference.Then ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.Then); set => Reference.Then = value; } /// - public IOpenApiSchema? UnevaluatedPropertiesSchema { get => (Target as IOpenApiSchemaWithUnevaluatedProperties)?.UnevaluatedPropertiesSchema; } + public IOpenApiSchema? Else { get => Reference.Else ?? GetFromTarget(static target => (target as IOpenApiSchemaMissingProperties)?.Else); set => Reference.Else = value; } /// - public OpenApiExternalDocs? ExternalDocs { get => Target?.ExternalDocs; } + public OpenApiExternalDocs? ExternalDocs { get => Reference.ExternalDocs ?? GetFromTarget(static target => target.ExternalDocs); set => Reference.ExternalDocs = value; } /// public bool Deprecated { - get => Reference.Deprecated ?? Target?.Deprecated ?? false; + get => Reference.Deprecated ?? GetFromTarget(static target => target.Deprecated); set => Reference.Deprecated = value; } /// - public OpenApiXml? Xml { get => Target?.Xml; } + public OpenApiXml? Xml { get => Reference.Xml ?? GetFromTarget(static target => target.Xml); set => Reference.Xml = value; } /// public IDictionary? Extensions { - get => Reference.Extensions ?? Target?.Extensions; + get => Reference.Extensions ?? GetFromTarget(static target => target.Extensions); set => Reference.Extensions = value; } /// - public IDictionary? UnrecognizedKeywords { get => Target?.UnrecognizedKeywords; } + public IDictionary? UnrecognizedKeywords { get => Reference.UnrecognizedKeywords ?? GetFromTarget(static target => target.UnrecognizedKeywords); set => Reference.UnrecognizedKeywords = value; } /// - public IDictionary>? DependentRequired { get => Target?.DependentRequired; } + public IDictionary>? DependentRequired { get => Reference.DependentRequired ?? GetFromTarget(static target => target.DependentRequired); set => Reference.DependentRequired = value; } /// public override void SerializeAsV31(IOpenApiWriter writer) @@ -226,5 +276,6 @@ protected override JsonSchemaReference CopyReference(JsonSchemaReference sourceR { return new JsonSchemaReference(sourceReference); } +#pragma warning restore CS0618 } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs index 8fdd0b73a..7ec7bee31 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSecuritySchemeReference.cs @@ -32,39 +32,39 @@ private OpenApiSecuritySchemeReference(OpenApiSecuritySchemeReference openApiSec /// public string? Description { - get => string.IsNullOrEmpty(Reference.Description) ? Target?.Description : Reference.Description; + get => string.IsNullOrEmpty(Reference.Description) ? GetFromTarget(static target => target.Description) : Reference.Description; set => Reference.Description = value; } /// - public string? Name { get => Target?.Name; } + public string? Name { get => GetFromTarget(static target => target.Name); } /// - public ParameterLocation? In { get => Target?.In; } + public ParameterLocation? In { get => GetFromTarget(static target => target.In); } /// - public string? Scheme { get => Target?.Scheme; } + public string? Scheme { get => GetFromTarget(static target => target.Scheme); } /// - public string? BearerFormat { get => Target?.BearerFormat; } + public string? BearerFormat { get => GetFromTarget(static target => target.BearerFormat); } /// - public OpenApiOAuthFlows? Flows { get => Target?.Flows; } + public OpenApiOAuthFlows? Flows { get => GetFromTarget(static target => target.Flows); } /// - public Uri? OpenIdConnectUrl { get => Target?.OpenIdConnectUrl; } + public Uri? OpenIdConnectUrl { get => GetFromTarget(static target => target.OpenIdConnectUrl); } /// - public Uri? OAuth2MetadataUrl { get => Target is IOAuth2MetadataProvider oauth2MetadataProvider ? oauth2MetadataProvider.OAuth2MetadataUrl : null; } + public Uri? OAuth2MetadataUrl { get => GetFromTarget(static target => target is IOAuth2MetadataProvider provider ? provider.OAuth2MetadataUrl : null); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// - public SecuritySchemeType? Type { get => Target?.Type; } + public SecuritySchemeType? Type { get => GetFromTarget(static target => target.Type); } /// - public bool Deprecated { get => Target?.Deprecated ?? default; } + public bool Deprecated { get => GetFromTarget(static target => target.Deprecated); } /// public override IOpenApiSecurityScheme CopyReferenceAsTargetElementWithOverrides(IOpenApiSecurityScheme source) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiTagReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiTagReference.cs index 78b92638c..84f97cc24 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiTagReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiTagReference.cs @@ -48,37 +48,40 @@ private OpenApiTagReference(OpenApiTagReference openApiTagReference) : base(open /// public string? Description { - get => Target?.Description; + get => GetFromTarget(static target => target.Description); } /// - public OpenApiExternalDocs? ExternalDocs { get => Target?.ExternalDocs; } + public OpenApiExternalDocs? ExternalDocs { get => GetFromTarget(static target => target.ExternalDocs); } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions { get => GetFromTarget(static target => target.Extensions); } /// - public string? Name { get => Target?.Name ?? Reference?.Id; } + public string? Name { get => GetFromTarget(static target => target.Name) ?? Reference?.Id; } /// - public string? Summary => Target?.Summary; + public string? Summary => GetFromTarget(static target => target.Summary); /// public OpenApiTagReference? Parent { get { - if (Target is OpenApiTagReference targetRef && (Reference.Id?.Equals(targetRef.Reference.Id, StringComparison.Ordinal) ?? false)) + return GetFromTarget(target => { - return null; - } + if (target is OpenApiTagReference targetRef && (Reference.Id?.Equals(targetRef.Reference.Id, StringComparison.Ordinal) ?? false)) + { + return null; + } - return Target?.Parent; + return target.Parent; + }); } } /// - public string? Kind => Target?.Kind; + public string? Kind => GetFromTarget(static target => target.Kind); /// public override IOpenApiTag CopyReferenceAsTargetElementWithOverrides(IOpenApiTag source) diff --git a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt index 4424e5862..ba435ff4e 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt @@ -2038,3 +2038,252 @@ Microsoft.OpenApi.JsonSchemaReference.Extensions.get -> System.Collections.Gener Microsoft.OpenApi.JsonSchemaReference.Extensions.set -> void Microsoft.OpenApi.OpenApiSchemaReference.Extensions.set -> void override Microsoft.OpenApi.OpenApiSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +const Microsoft.OpenApi.OpenApiConstants.Contains = "contains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MaxContains = "maxContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MinContains = "minContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.Anchor = "$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-jsonschema-$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncoding = "contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-jsonschema-contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaType = "contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-jsonschema-contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchema = "contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-jsonschema-contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContainsExtension = "x-jsonschema-contains" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemas = "dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-jsonschema-dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! +const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-jsonschema-else" -> string! +const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! +const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-jsonschema-if" -> string! +const Microsoft.OpenApi.OpenApiConstants.MaxContainsExtension = "x-jsonschema-maxContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MinContainsExtension = "x-jsonschema-minContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-jsonschema-propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! +const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-jsonschema-then" -> string! +Microsoft.OpenApi.IOpenApiSchemaMissingProperties +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Anchor.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentEncoding.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentMediaType.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.UnevaluatedProperties.get -> bool +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.MaxContains.get -> uint? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Contains.set -> void +Microsoft.OpenApi.OpenApiSchema.MaxContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.MaxContains.set -> void +Microsoft.OpenApi.OpenApiSchema.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.MinContains.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.MaxContains.get -> uint? +Microsoft.OpenApi.OpenApiSchemaReference.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.Anchor.get -> string? +Microsoft.OpenApi.OpenApiSchema.Anchor.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentEncoding.get -> string? +Microsoft.OpenApi.OpenApiSchema.ContentEncoding.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentMediaType.get -> string? +Microsoft.OpenApi.OpenApiSchema.ContentMediaType.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.ContentSchema.set -> void +Microsoft.OpenApi.OpenApiSchema.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.OpenApiSchema.DependentSchemas.set -> void +Microsoft.OpenApi.OpenApiSchema.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Else.set -> void +Microsoft.OpenApi.OpenApiSchema.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.If.set -> void +Microsoft.OpenApi.OpenApiSchema.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.PropertyNames.set -> void +Microsoft.OpenApi.OpenApiSchema.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Then.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Anchor.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentEncoding.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentMediaType.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.OpenApiSchemaReference.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.Anchor.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Anchor.set -> void +Microsoft.OpenApi.JsonSchemaReference.Comment.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Comment.set -> void +Microsoft.OpenApi.JsonSchemaReference.Definitions.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.Definitions.set -> void +Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.get -> string? +Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.set -> void +Microsoft.OpenApi.JsonSchemaReference.DynamicRef.get -> string? +Microsoft.OpenApi.JsonSchemaReference.DynamicRef.set -> void +Microsoft.OpenApi.JsonSchemaReference.Schema.get -> System.Uri? +Microsoft.OpenApi.JsonSchemaReference.Schema.set -> void +Microsoft.OpenApi.JsonSchemaReference.SchemaId.get -> string? +Microsoft.OpenApi.JsonSchemaReference.SchemaId.set -> void +Microsoft.OpenApi.JsonSchemaReference.Vocabulary.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.Vocabulary.set -> void +Microsoft.OpenApi.JsonSchemaReference.AdditionalProperties.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.AdditionalProperties.set -> void +Microsoft.OpenApi.JsonSchemaReference.AdditionalPropertiesAllowed.get -> bool? +Microsoft.OpenApi.JsonSchemaReference.AdditionalPropertiesAllowed.set -> void +Microsoft.OpenApi.JsonSchemaReference.AllOf.get -> System.Collections.Generic.IList? +Microsoft.OpenApi.JsonSchemaReference.AllOf.set -> void +Microsoft.OpenApi.JsonSchemaReference.AnyOf.get -> System.Collections.Generic.IList? +Microsoft.OpenApi.JsonSchemaReference.AnyOf.set -> void +Microsoft.OpenApi.JsonSchemaReference.Const.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Const.set -> void +Microsoft.OpenApi.JsonSchemaReference.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.Contains.set -> void +Microsoft.OpenApi.JsonSchemaReference.ContentEncoding.get -> string? +Microsoft.OpenApi.JsonSchemaReference.ContentEncoding.set -> void +Microsoft.OpenApi.JsonSchemaReference.ContentMediaType.get -> string? +Microsoft.OpenApi.JsonSchemaReference.ContentMediaType.set -> void +Microsoft.OpenApi.JsonSchemaReference.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.ContentSchema.set -> void +Microsoft.OpenApi.JsonSchemaReference.DependentRequired.get -> System.Collections.Generic.IDictionary!>? +Microsoft.OpenApi.JsonSchemaReference.DependentRequired.set -> void +Microsoft.OpenApi.JsonSchemaReference.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.DependentSchemas.set -> void +Microsoft.OpenApi.JsonSchemaReference.Discriminator.get -> Microsoft.OpenApi.OpenApiDiscriminator? +Microsoft.OpenApi.JsonSchemaReference.Discriminator.set -> void +Microsoft.OpenApi.JsonSchemaReference.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.Else.set -> void +Microsoft.OpenApi.JsonSchemaReference.Enum.get -> System.Collections.Generic.IList? +Microsoft.OpenApi.JsonSchemaReference.Enum.set -> void +Microsoft.OpenApi.JsonSchemaReference.Example.get -> System.Text.Json.Nodes.JsonNode? +Microsoft.OpenApi.JsonSchemaReference.Example.set -> void +Microsoft.OpenApi.JsonSchemaReference.ExclusiveMaximum.get -> string? +Microsoft.OpenApi.JsonSchemaReference.ExclusiveMaximum.set -> void +Microsoft.OpenApi.JsonSchemaReference.ExclusiveMinimum.get -> string? +Microsoft.OpenApi.JsonSchemaReference.ExclusiveMinimum.set -> void +Microsoft.OpenApi.JsonSchemaReference.ExternalDocs.get -> Microsoft.OpenApi.OpenApiExternalDocs? +Microsoft.OpenApi.JsonSchemaReference.ExternalDocs.set -> void +Microsoft.OpenApi.JsonSchemaReference.Format.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Format.set -> void +Microsoft.OpenApi.JsonSchemaReference.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.If.set -> void +Microsoft.OpenApi.JsonSchemaReference.Items.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.Items.set -> void +Microsoft.OpenApi.JsonSchemaReference.MaxContains.get -> uint? +Microsoft.OpenApi.JsonSchemaReference.MaxContains.set -> void +Microsoft.OpenApi.JsonSchemaReference.Maximum.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Maximum.set -> void +Microsoft.OpenApi.JsonSchemaReference.MaxItems.get -> int? +Microsoft.OpenApi.JsonSchemaReference.MaxItems.set -> void +Microsoft.OpenApi.JsonSchemaReference.MaxLength.get -> int? +Microsoft.OpenApi.JsonSchemaReference.MaxLength.set -> void +Microsoft.OpenApi.JsonSchemaReference.MaxProperties.get -> int? +Microsoft.OpenApi.JsonSchemaReference.MaxProperties.set -> void +Microsoft.OpenApi.JsonSchemaReference.MinContains.get -> uint? +Microsoft.OpenApi.JsonSchemaReference.MinContains.set -> void +Microsoft.OpenApi.JsonSchemaReference.Minimum.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Minimum.set -> void +Microsoft.OpenApi.JsonSchemaReference.MinItems.get -> int? +Microsoft.OpenApi.JsonSchemaReference.MinItems.set -> void +Microsoft.OpenApi.JsonSchemaReference.MinLength.get -> int? +Microsoft.OpenApi.JsonSchemaReference.MinLength.set -> void +Microsoft.OpenApi.JsonSchemaReference.MinProperties.get -> int? +Microsoft.OpenApi.JsonSchemaReference.MinProperties.set -> void +Microsoft.OpenApi.JsonSchemaReference.MultipleOf.get -> decimal? +Microsoft.OpenApi.JsonSchemaReference.MultipleOf.set -> void +Microsoft.OpenApi.JsonSchemaReference.Not.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.Not.set -> void +Microsoft.OpenApi.JsonSchemaReference.OneOf.get -> System.Collections.Generic.IList? +Microsoft.OpenApi.JsonSchemaReference.OneOf.set -> void +Microsoft.OpenApi.JsonSchemaReference.Pattern.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Pattern.set -> void +Microsoft.OpenApi.JsonSchemaReference.PatternProperties.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.PatternProperties.set -> void +Microsoft.OpenApi.JsonSchemaReference.Properties.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.Properties.set -> void +Microsoft.OpenApi.JsonSchemaReference.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.PropertyNames.set -> void +Microsoft.OpenApi.JsonSchemaReference.Required.get -> System.Collections.Generic.ISet? +Microsoft.OpenApi.JsonSchemaReference.Required.set -> void +Microsoft.OpenApi.JsonSchemaReference.SchemaType.get -> Microsoft.OpenApi.JsonSchemaType? +Microsoft.OpenApi.JsonSchemaReference.SchemaType.set -> void +Microsoft.OpenApi.JsonSchemaReference.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.Then.set -> void +Microsoft.OpenApi.JsonSchemaReference.UnevaluatedProperties.get -> bool? +Microsoft.OpenApi.JsonSchemaReference.UnevaluatedProperties.set -> void +Microsoft.OpenApi.JsonSchemaReference.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.JsonSchemaReference.UnevaluatedPropertiesSchema.set -> void +Microsoft.OpenApi.JsonSchemaReference.UniqueItems.get -> bool? +Microsoft.OpenApi.JsonSchemaReference.UniqueItems.set -> void +Microsoft.OpenApi.JsonSchemaReference.UnrecognizedKeywords.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.UnrecognizedKeywords.set -> void +Microsoft.OpenApi.JsonSchemaReference.Xml.get -> Microsoft.OpenApi.OpenApiXml? +Microsoft.OpenApi.JsonSchemaReference.Xml.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.AdditionalProperties.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.AdditionalPropertiesAllowed.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.AllOf.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Anchor.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.AnyOf.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Comment.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Const.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Contains.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.ContentEncoding.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.ContentMediaType.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.ContentSchema.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Definitions.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.DependentRequired.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.DependentSchemas.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Discriminator.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.DynamicAnchor.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.DynamicRef.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Else.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Enum.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Example.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.ExclusiveMaximum.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.ExclusiveMinimum.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.ExternalDocs.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Format.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Id.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.If.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Items.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Maximum.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MaxContains.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MaxItems.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MaxLength.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MaxProperties.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Minimum.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MinContains.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MinItems.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MinLength.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MinProperties.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.MultipleOf.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Not.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.OneOf.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Pattern.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.PatternProperties.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Properties.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.PropertyNames.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Required.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Schema.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Then.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Type.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.UnevaluatedProperties.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.UnevaluatedPropertiesSchema.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.UniqueItems.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.UnrecognizedKeywords.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Vocabulary.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Xml.set -> void +Microsoft.OpenApi.OpenApiSchemaJsonConverter +Microsoft.OpenApi.OpenApiSchemaJsonConverter.OpenApiSchemaJsonConverter() -> void +Microsoft.OpenApi.OpenApiSchemaJsonConverter.OpenApiSchemaJsonConverter(Microsoft.OpenApi.OpenApiSpecVersion version) -> void +override Microsoft.OpenApi.OpenApiSchemaJsonConverter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type! typeToConvert, System.Text.Json.JsonSerializerOptions! options) -> Microsoft.OpenApi.OpenApiSchema? +override Microsoft.OpenApi.OpenApiSchemaJsonConverter.Write(System.Text.Json.Utf8JsonWriter! writer, Microsoft.OpenApi.OpenApiSchema! value, System.Text.Json.JsonSerializerOptions! options) -> void +override Microsoft.OpenApi.JsonSchemaReference.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +override Microsoft.OpenApi.JsonSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +override Microsoft.OpenApi.OpenApiSchemaReference.Target.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiWorkspace.GetDynamicAnchorCandidates(Microsoft.OpenApi.OpenApiDocument! hostDocument, string! anchorName) -> System.Collections.Generic.IReadOnlyList! +static Microsoft.OpenApi.OpenApiWorkspace.ResolveDynamicAnchorInContext(Microsoft.OpenApi.IOpenApiSchema? contextSchema, string! anchorName) -> Microsoft.OpenApi.IOpenApiSchema? +const Microsoft.OpenApi.OpenApiConstants.JsonSchemaExamplesExtension = "x-jsonschema-examples" -> string! +const Microsoft.OpenApi.OpenApiConstants.OaiLicenseIdentifier = "x-oai-license-identifier" -> string! diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..cc7b76897 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.OpenApi.OpenApiDocument.GetOperationById(string! operationId) -> Microsoft.OpenApi.OpenApiOperation? diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index 3ccfb2116..db285f078 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -42,7 +42,13 @@ public static List CreateListOfAny(this JsonNode? node, ParsingContext throw new OpenApiReaderException("Cannot create a list from this type of node.", context); } - return jsonArray.OfType().ToList(); + var list = new List(jsonArray.Count); + foreach (var element in jsonArray) + { + list.Add(element ?? JsonNullSentinel.JsonNull); + } + + return list; } public static List CreateSimpleList(this JsonNode? node, Func map, OpenApiDocument? openApiDocument, ParsingContext context) @@ -157,6 +163,11 @@ public static Dictionary> CreateArrayMap(this JsonNode? no public static string? GetScalarValue(this JsonNode? node) { + if (node.IsJsonNullSentinel()) + { + return null; + } + var scalarNode = node is JsonValue value ? value : throw new OpenApiException("Expected scalar value."); return Convert.ToString(scalarNode.GetValue(), CultureInfo.InvariantCulture); @@ -167,6 +178,64 @@ public static Dictionary> CreateArrayMap(this JsonNode? no return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null; } + public static void ValidateReferencePointerFormat(string pointer) + { + var hashIndex = pointer.IndexOf('#'); + if (hashIndex < 0) return; + var slashIndex = pointer.IndexOf('/', hashIndex); + if (slashIndex >= 0 && + slashIndex != hashIndex + 1) + { + throw new OpenApiException(string.Format(SRResource.ReferenceHasInvalidFormat, pointer)); + } + } + + /// + /// Returns the value of $dynamicRef if $ref is absent. Used to create a schema reference + /// for bare $dynamicRef schemas (no $ref) so they participate in reference resolution. + /// + public static string? GetDynamicReferencePointer(this JsonObject jsonObject) + { + if (jsonObject.TryGetPropertyValue("$ref", out _)) + return null; + return jsonObject.TryGetPropertyValue("$dynamicRef", out var dynRefNode) ? dynRefNode?.GetScalarValue() : null; + } + + /// + /// Splits a $dynamicRef value on the last '#' and returns the fragment part. + /// For "#meta" returns "meta". For "https://example.com#meta" returns "meta". + /// For values with no '#' returns the original string. Returns null for null/empty input. + /// This is a string operation only — it does not validate or resolve the reference. + /// + public static string? ExtractDynamicAnchorName(string? dynamicRef) + { + if (string.IsNullOrEmpty(dynamicRef)) return null; + var hashIndex = dynamicRef!.LastIndexOf('#'); + return hashIndex >= 0 ? dynamicRef.Substring(hashIndex + 1) : dynamicRef; + } + + /// + /// Determines whether a $dynamicRef value is a fragment-only reference (e.g. "#node") + /// that targets an anchor within the current document, as opposed to an absolute/relative + /// URI reference (e.g. "https://example.com/schema#node") that targets another resource. + /// Per JSON Schema 2020-12, only fragment-only dynamic refs resolve against the local + /// $dynamicAnchor index; URI-based refs require resolving their target resource first. + /// + public static bool IsFragmentOnlyDynamicRef(string? dynamicRef) + => !string.IsNullOrEmpty(dynamicRef) && dynamicRef![0] == '#'; + + /// + /// Extracts the document URI from a $dynamicRef value. Returns null for fragment-only + /// refs (e.g. "#node"). For URI refs (e.g. "https://example.com/external#node"), returns + /// the part before the fragment (e.g. "https://example.com/external"). + /// + public static string? ExtractDocumentUri(string? dynamicRef) + { + if (string.IsNullOrEmpty(dynamicRef)) return null; + var hashIndex = dynamicRef!.IndexOf('#'); + return hashIndex > 0 ? dynamicRef.Substring(0, hashIndex) : null; + } + public static string? GetJsonSchemaIdentifier(this JsonObject jsonObject) { return jsonObject.TryGetPropertyValue("$id", out var idNode) ? idNode?.GetScalarValue() : null; diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs index e020aedc1..5fb2ec88a 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs @@ -7,6 +7,8 @@ using System.Collections.Generic; using System.Globalization; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Reader.V2 { /// diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 421468750..9e181fdcd 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -1,12 +1,14 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System.Text.Json.Nodes; - +using System; using System.Collections.Generic; using System.Globalization; -using System; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +#pragma warning disable CS0618 namespace Microsoft.OpenApi.Reader.V2 { @@ -16,6 +18,8 @@ namespace Microsoft.OpenApi.Reader.V2 /// internal static partial class OpenApiV2Deserializer { + private const string EncounteredNullableExtensionTrueMetadataKey = "encounteredNullable"; + private static readonly FixedFieldMap _openApiSchemaFixedFields = new() { { @@ -210,6 +214,29 @@ internal static partial class OpenApiV2Deserializer "default", (o, n, _, _) => o.Default = n }, + { + OpenApiConstants.NullableExtension, + (o, n, _, _) => + { + if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) + { + if (o.Type is not null && o.Type != 0) + { + o.Type |= JsonSchemaType.Null; + } + else + { + // We mirror the same behavior of OpenAPI 3.0 "nullable" which is a type modifier. + // It only has effect if there is a "Type" set. + // Given that we don't have a Type set yet, we want to keep track of it until the end of deserialization. + // If, at a later point during deserialization, Type was set, we apply nullable to it. + // Otherwise, we throw it away. + o.Metadata ??= new Dictionary(); + o.Metadata[EncounteredNullableExtensionTrueMetadataKey] = true; + } + } + } + }, { "discriminator", (o, n, _, _) => { @@ -242,6 +269,10 @@ internal static partial class OpenApiV2Deserializer "example", (o, n, _, _) => o.Example = n }, + { + OpenApiConstants.JsonSchemaExamplesExtension, + (o, n, _, c) => o.Examples = n.CreateListOfAny(c) + }, { OpenApiConstants.PatternPropertiesExtension, (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) @@ -268,14 +299,13 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + if (schema.Metadata?.TryGetValue(EncounteredNullableExtensionTrueMetadataKey, out var value) == true) { - if (schema.Type.HasValue) + schema.Metadata.Remove(EncounteredNullableExtensionTrueMetadataKey); + if (schema.Type is not null && schema.Type != 0 && value is bool isNullable && isNullable) + { schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - - schema.Extensions.Remove(OpenApiConstants.NullableExtension); + } } return schema; diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSecuritySchemeDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSecuritySchemeDeserializer.cs index ae62bd170..3daf7cf89 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSecuritySchemeDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSecuritySchemeDeserializer.cs @@ -99,7 +99,21 @@ internal static partial class OpenApiV2Deserializer private static readonly PatternFieldMap _securitySchemePatternFields = new() { - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _, c) => o.AddExtension(p, LoadExtension(p, n, c))} + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _, c) => + { + if (p.Equals(OpenApiConstants.OAuth2MetadataUrlExtension, StringComparison.OrdinalIgnoreCase)) + { + var metadataUrl = n.GetScalarValue(); + if (metadataUrl != null) + { + o.OAuth2MetadataUrl = new(metadataUrl, UriKind.RelativeOrAbsolute); + } + } + else + { + o.AddExtension(p, LoadExtension(p, n, c)); + } + }} }; public static IOpenApiSecurityScheme LoadSecurityScheme(JsonNode node, OpenApiDocument hostDocument, ParsingContext context) diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs index 3996d8d7a..ab3d15e94 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -88,7 +88,7 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { var refSegments = pointer.Split('/'); - var refId = refSegments[refSegments.Count() -1]; + var refId = refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = isExternalResource ? $"{refSegments[0]}/{refSegments[1].TrimEnd('#')}" : null; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiParameterDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiParameterDeserializer.cs index 1f589e2ff..435515658 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiParameterDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiParameterDeserializer.cs @@ -5,6 +5,8 @@ using System; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Reader.V3 { /// diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 0ad8e747c..7bb9327b6 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -1,12 +1,15 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System.Text.Json.Nodes; - using System; using System.Collections.Generic; +using System.Data.SqlTypes; using System.Globalization; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +#pragma warning disable CS0618 namespace Microsoft.OpenApi.Reader.V3 { @@ -16,6 +19,8 @@ namespace Microsoft.OpenApi.Reader.V3 /// internal static partial class OpenApiV3Deserializer { + private const string EncounteredNullableTrueMetadataKey = "encounteredNullable"; + private static readonly FixedFieldMap _openApiSchemaFixedFields = new() { { @@ -222,10 +227,20 @@ internal static partial class OpenApiV3Deserializer { if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) { - if (o.Type.HasValue) + if (o.Type is not null && o.Type != 0) + { o.Type |= JsonSchemaType.Null; + } else - o.Type = JsonSchemaType.Null; + { + // "nullable" is a type modifier. + // It only has effect if there is a "Type" set. + // Given that we don't have a Type set yet, we want to keep track of it until the end of deserialization. + // If, at a later point during deserialization, Type was set, we apply nullable to it. + // Otherwise, we throw it away. + o.Metadata ??= new Dictionary(); + o.Metadata[EncounteredNullableTrueMetadataKey] = true; + } } } }, @@ -267,6 +282,10 @@ internal static partial class OpenApiV3Deserializer "example", (o, n, _, _) => o.Example = n }, + { + OpenApiConstants.JsonSchemaExamplesExtension, + (o, n, _, c) => o.Examples = n.CreateListOfAny(c) + }, { "deprecated", (o, n, _, _) => @@ -282,6 +301,86 @@ internal static partial class OpenApiV3Deserializer OpenApiConstants.PatternPropertiesExtension, (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + OpenApiConstants.UnevaluatedPropertiesExtension, + (o, n, t, c) => + { + if (n is JsonValue) + { + var value = n.GetScalarValue(); + if (value is not null) + { + o.UnevaluatedProperties = bool.Parse(value); + } + } + else + { + o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); + } + } + }, + { + OpenApiConstants.AnchorExtension, + (o, n, _, _) => o.Anchor = n.GetScalarValue() + }, + { + OpenApiConstants.ContentEncodingExtension, + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + OpenApiConstants.ContentMediaTypeExtension, + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + OpenApiConstants.ContentSchemaExtension, + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ContainsExtension, + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.MaxContainsExtension, + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + OpenApiConstants.MinContainsExtension, + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, + { + OpenApiConstants.PropertyNamesExtension, + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.DependentSchemasExtension, + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.IfExtension, + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ThenExtension, + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ElseExtension, + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() @@ -305,17 +404,129 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + if (schema.Metadata?.TryGetValue(EncounteredNullableTrueMetadataKey, out var value) == true) { - if (schema.Type.HasValue) + schema.Metadata.Remove(EncounteredNullableTrueMetadataKey); + if (schema.Type is not null && schema.Type != 0 && value is bool isNullable && isNullable) + { schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; + } + } - schema.Extensions.Remove(OpenApiConstants.NullableExtension); + if (schema.Type is null && schema.Enum is { Count: 1 } && + schema.Enum[0].IsJsonNullSentinel()) + { + schema.Enum = null; + schema.Type = JsonSchemaType.Null; + } + + if (schema.Type is null) + { + if (schema.AnyOf is not null && + schema.AnyOf.All(s => s is OpenApiSchema child && DoesSchemaRepresentSingleType(child))) + { + JsonSchemaType types = GetAllTypes(schema.AnyOf); + schema.AnyOf = null; + schema.Type = types; + } + else if (schema.OneOf is not null && + schema.OneOf.All(s => s is OpenApiSchema child && DoesSchemaRepresentSingleType(child))) + { + JsonSchemaType types = GetAllTypes(schema.OneOf); + schema.OneOf = null; + schema.Type = types; + } } return schema; } + + private static JsonSchemaType GetAllTypes(IList schemas) + { + JsonSchemaType types = 0; + foreach (var schema in schemas) + { + types |= schema.Type!.Value; + } + + return types; + } + + private static bool DoesSchemaRepresentSingleType(OpenApiSchema schema) + { + if (schema.Type is not (JsonSchemaType.Null or + JsonSchemaType.Boolean or + JsonSchemaType.Integer or + JsonSchemaType.Number or + JsonSchemaType.String or + JsonSchemaType.Object or + JsonSchemaType.Array)) + { + return false; + } + + // Folding anyOf/oneOf back into a single "type" is only safe when the child + // schema carries nothing but its type. Otherwise any additional keywords + // (format, bounds, enum, nested schemas, etc.) would be silently dropped. + // Metadata is intentionally ignored as it only holds internal bookkeeping. + return schema.Title is null && + schema.Schema is null && + schema.Id is null && + schema.Comment is null && + schema.Vocabulary is null && + schema.DynamicRef is null && + schema.DynamicAnchor is null && + schema.Definitions is null && + schema.Anchor is null && + schema.Format is null && + schema.Description is null && + schema.Maximum is null && + schema.Minimum is null && + schema.ExclusiveMaximum is null && + schema.ExclusiveMinimum is null && + schema.MaxLength is null && + schema.MinLength is null && + schema.Pattern is null && + schema.MultipleOf is null && + schema.Default is null && + !schema.ReadOnly && + !schema.WriteOnly && + schema.AllOf is null && + schema.OneOf is null && + schema.AnyOf is null && + schema.Not is null && + schema.Required is null && + schema.Items is null && + schema.MaxItems is null && + schema.MinItems is null && + schema.UniqueItems is null && + schema.Contains is null && + schema.MaxContains is null && + schema.MinContains is null && + schema.Properties is null && + schema.PatternProperties is null && + schema.MaxProperties is null && + schema.MinProperties is null && + schema.AdditionalPropertiesAllowed && + schema.AdditionalProperties is null && + schema.Discriminator is null && + schema.Enum is null && + schema.UnevaluatedProperties && + schema.UnevaluatedPropertiesSchema is null && + schema.ContentEncoding is null && + schema.ContentMediaType is null && + schema.ContentSchema is null && + schema.PropertyNames is null && + schema.DependentSchemas is null && + schema.DependentRequired is null && + schema.If is null && + schema.Then is null && + schema.Else is null && + schema.ExternalDocs is null && + !schema.Deprecated && + schema.Xml is null && + schema.Extensions is null && + schema.UnrecognizedKeywords is null; + } } } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSecuritySchemeDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSecuritySchemeDeserializer.cs index 4cb13d995..3f9d8be83 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSecuritySchemeDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSecuritySchemeDeserializer.cs @@ -76,7 +76,15 @@ internal static partial class OpenApiV3Deserializer { {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _, c) => { - if (p.Equals("x-oai-deprecated", StringComparison.OrdinalIgnoreCase)) + if (p.Equals(OpenApiConstants.OAuth2MetadataUrlExtension, StringComparison.OrdinalIgnoreCase)) + { + var metadataUrl = n.GetScalarValue(); + if (metadataUrl != null) + { + o.OAuth2MetadataUrl = new(metadataUrl, UriKind.RelativeOrAbsolute); + } + } + else if (p.Equals("x-oai-deprecated", StringComparison.OrdinalIgnoreCase)) { var deprecated = n.GetScalarValue(); if (deprecated != null) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs index ea499e098..1e281afb0 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Text.Json.Nodes; @@ -152,8 +152,10 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { + JsonNodeHelper.ValidateReferencePointerFormat(pointer); + var refSegments = pointer.Split('/'); - var refId = refSegments[refSegments.Count() -1]; + var refId = refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiParameterDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiParameterDeserializer.cs index 2755e5324..177342fb4 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiParameterDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiParameterDeserializer.cs @@ -1,6 +1,8 @@ using System; using System.Text.Json.Nodes; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Reader.V31 { /// diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 14deab765..52ca0912b 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -1,13 +1,17 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Reader.V31; + internal static partial class OpenApiV31Deserializer { private static readonly FixedFieldMap _openApiSchemaFixedFields = new() @@ -43,6 +47,10 @@ internal static partial class OpenApiV31Deserializer { "$defs", (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.Anchor, + (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { "multipleOf", @@ -144,6 +152,32 @@ internal static partial class OpenApiV31Deserializer } } }, + { + OpenApiConstants.Contains, + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.MaxContains, + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + OpenApiConstants.MinContains, + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, { "unevaluatedProperties", (o, n, t, c) => @@ -164,6 +198,18 @@ internal static partial class OpenApiV31Deserializer } } }, + { + OpenApiConstants.ContentEncoding, + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + OpenApiConstants.ContentMediaType, + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + OpenApiConstants.ContentSchema, + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, { "maxProperties", (o, n, _, _) => @@ -249,6 +295,10 @@ internal static partial class OpenApiV31Deserializer "patternProperties", (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + OpenApiConstants.PropertyNames, + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, { "additionalProperties", (o, n, doc, c) => { @@ -278,24 +328,6 @@ internal static partial class OpenApiV31Deserializer "default", (o, n, _, _) => o.Default = n }, - { - "nullable", - (o, n, _, _) => - { - var value = n.GetScalarValue(); - if (value is not null) - { - var nullable = bool.Parse(value); - if (nullable) // if nullable, convert type into an array of type(s) and null - { - if (o.Type.HasValue) - o.Type |= JsonSchemaType.Null; - else - o.Type = JsonSchemaType.Null; - } - } - } - }, { "discriminator", (o, n, doc, c) => o.Discriminator = LoadDiscriminator(n, doc, c) @@ -356,6 +388,22 @@ internal static partial class OpenApiV31Deserializer o.DependentRequired = n.CreateArrayMap((n2, _) => n2.GetScalarValue()!, doc, c); } }, + { + OpenApiConstants.DependentSchemas, + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.If, + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.Then, + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.Else, + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() @@ -381,6 +429,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context); var pointer = jsonObject.GetReferencePointer(); + var dynamicPointer = jsonObject.GetDynamicReferencePointer(); var identifier = jsonObject.GetJsonSchemaIdentifier(); if (pointer != null) @@ -388,8 +437,42 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var nodeLocation = context.GetLocation(); var reference = GetReferenceIdAndExternalResource(pointer); var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); - result.Reference.SetMetadataFromJsonObject(jsonObject); + var referenceMetadata = new OpenApiSchema(); + jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context, + static (schema, name, value) => + { + if (!string.Equals(name, OpenApiConstants.DollarRef, StringComparison.Ordinal)) + { + schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal); + schema.UnrecognizedKeywords[name] = value; + } + }); + result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject); result.Reference.SetJsonPointerPath(pointer, nodeLocation); + + return result; + } + + if (dynamicPointer != null) + { + var nodeLocation = context.GetLocation(); + var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer); + string? externalResource = JsonNodeHelper.IsFragmentOnlyDynamicRef(dynamicPointer) + ? null + : JsonNodeHelper.ExtractDocumentUri(dynamicPointer); + var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument, externalResource); + var referenceMetadata = new OpenApiSchema(); + jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context, + static (schema, name, value) => + { + if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal)) + { + schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal); + schema.UnrecognizedKeywords[name] = value; + } + }); + result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject); + result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation); return result; } @@ -402,16 +485,6 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) - { - if (schema.Type.HasValue) - schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - } - if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { // register the schema in our registry using the identifier's URL diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSecuritySchemeDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSecuritySchemeDeserializer.cs index 4e02a2bf6..fac4b87c1 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSecuritySchemeDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSecuritySchemeDeserializer.cs @@ -83,7 +83,15 @@ internal static partial class OpenApiV31Deserializer { {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _, c) => { - if (p.Equals("x-oai-deprecated", StringComparison.OrdinalIgnoreCase)) + if (p.Equals(OpenApiConstants.OAuth2MetadataUrlExtension, StringComparison.OrdinalIgnoreCase)) + { + var metadataUrl = n.GetScalarValue(); + if (metadataUrl != null) + { + o.OAuth2MetadataUrl = new(metadataUrl, UriKind.RelativeOrAbsolute); + } + } + else if (p.Equals("x-oai-deprecated", StringComparison.OrdinalIgnoreCase)) { var deprecated = n.GetScalarValue(); if (deprecated != null) diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs index 81a7811ff..617ee9ced 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -152,6 +152,8 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { + JsonNodeHelper.ValidateReferencePointerFormat(pointer); + /* Check whether the reference pointer is a URL * (id keyword allows you to supply a URL for the schema as a target for referencing) * E.g. $ref: 'https://example.com/schemas/resource.json' @@ -159,7 +161,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe * E.g. $ref: '#/components/schemas/pet' */ var refSegments = pointer.Split('/'); - string refId = !pointer.Contains('#') ? pointer : refSegments[refSegments.Count()-1]; + string refId = !pointer.Contains('#') ? pointer : refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiParameterDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiParameterDeserializer.cs index 5eadec1c9..4a8300766 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiParameterDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiParameterDeserializer.cs @@ -1,6 +1,8 @@ using System; using System.Text.Json.Nodes; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Reader.V32 { /// diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index f0e07724f..b038fa6e5 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -1,13 +1,17 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Reader.V32; + internal static partial class OpenApiV32Deserializer { private static readonly FixedFieldMap _openApiSchemaFixedFields = new() @@ -43,6 +47,10 @@ internal static partial class OpenApiV32Deserializer { "$defs", (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.Anchor, + (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { "multipleOf", @@ -144,6 +152,32 @@ internal static partial class OpenApiV32Deserializer } } }, + { + OpenApiConstants.Contains, + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.MaxContains, + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + OpenApiConstants.MinContains, + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, { "unevaluatedProperties", (o, n, t, c) => @@ -164,6 +198,18 @@ internal static partial class OpenApiV32Deserializer } } }, + { + OpenApiConstants.ContentEncoding, + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + OpenApiConstants.ContentMediaType, + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + OpenApiConstants.ContentSchema, + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, { "maxProperties", (o, n, _, _) => @@ -249,6 +295,10 @@ internal static partial class OpenApiV32Deserializer "patternProperties", (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + OpenApiConstants.PropertyNames, + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, { "additionalProperties", (o, n, doc, c) => { @@ -278,24 +328,6 @@ internal static partial class OpenApiV32Deserializer "default", (o, n, _, _) => o.Default = n }, - { - "nullable", - (o, n, _, _) => - { - var value = n.GetScalarValue(); - if (value is not null) - { - var nullable = bool.Parse(value); - if (nullable) // if nullable, convert type into an array of type(s) and null - { - if (o.Type.HasValue) - o.Type |= JsonSchemaType.Null; - else - o.Type = JsonSchemaType.Null; - } - } - } - }, { "discriminator", (o, n, doc, c) => o.Discriminator = LoadDiscriminator(n, doc, c) @@ -356,6 +388,22 @@ internal static partial class OpenApiV32Deserializer o.DependentRequired = n.CreateArrayMap((n2, _) => n2.GetScalarValue()!, doc, c); } }, + { + OpenApiConstants.DependentSchemas, + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.If, + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.Then, + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.Else, + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() @@ -381,6 +429,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context); var pointer = jsonObject.GetReferencePointer(); + var dynamicPointer = jsonObject.GetDynamicReferencePointer(); var identifier = jsonObject.GetJsonSchemaIdentifier(); if (pointer != null) @@ -388,8 +437,42 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var nodeLocation = context.GetLocation(); var reference = GetReferenceIdAndExternalResource(pointer); var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); - result.Reference.SetMetadataFromJsonObject(jsonObject); + var referenceMetadata = new OpenApiSchema(); + jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context, + static (schema, name, value) => + { + if (!string.Equals(name, OpenApiConstants.DollarRef, StringComparison.Ordinal)) + { + schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal); + schema.UnrecognizedKeywords[name] = value; + } + }); + result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject); result.Reference.SetJsonPointerPath(pointer, nodeLocation); + + return result; + } + + if (dynamicPointer != null) + { + var nodeLocation = context.GetLocation(); + var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer); + string? externalResource = JsonNodeHelper.IsFragmentOnlyDynamicRef(dynamicPointer) + ? null + : JsonNodeHelper.ExtractDocumentUri(dynamicPointer); + var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument, externalResource); + var referenceMetadata = new OpenApiSchema(); + jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context, + static (schema, name, value) => + { + if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal)) + { + schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal); + schema.UnrecognizedKeywords[name] = value; + } + }); + result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject); + result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation); return result; } @@ -402,16 +485,6 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) - { - if (schema.Type.HasValue) - schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - } - if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { // register the schema in our registry using the identifier's URL diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs index 4031b45a1..5efed4657 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -152,6 +152,8 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { + JsonNodeHelper.ValidateReferencePointerFormat(pointer); + /* Check whether the reference pointer is a URL * (id keyword allows you to supply a URL for the schema as a target for referencing) * E.g. $ref: 'https://example.com/schemas/resource.json' @@ -159,7 +161,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe * E.g. $ref: '#/components/schemas/pet' */ var refSegments = pointer.Split('/'); - string refId = !pointer.Contains('#') ? pointer : refSegments[refSegments.Count()-1]; + string refId = !pointer.Contains('#') ? pointer : refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index 22128adc2..8872a8983 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.OpenApi.Reader; namespace Microsoft.OpenApi { @@ -16,6 +17,8 @@ public class OpenApiWorkspace private readonly Dictionary _documentsIdRegistry = new(); private readonly Dictionary _artifactsRegistry = new(); private readonly Dictionary _IOpenApiReferenceableRegistry = new(new UriWithFragmentEqualityComparer()); + private Dictionary>>? _dynamicAnchorRegistryByDocument; + private Dictionary>>? _anchorRegistryByDocument; private sealed class UriWithFragmentEqualityComparer : IEqualityComparer { @@ -94,8 +97,10 @@ public void RegisterComponents(OpenApiDocument document) foreach (var item in document.Components.Schemas) { if (item.Value == null) continue; - location = item.Value.Id ?? baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key; + location = baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key; RegisterComponent(location, item.Value); + + RegisterSchemaIdentifiers(document, item.Value, document.BaseUri); } } @@ -208,6 +213,152 @@ public void RegisterComponents(OpenApiDocument document) RegisterComponent(location, item.Value); } } + + RegisterComponentAnchors(document); + RegisterInlineAnchors(document); + } + + /// + /// Registers $dynamicAnchor and $anchor declarations from schemas nested inside reusable + /// components (parameters, responses, requestBodies, headers, callbacks, pathItems, mediaTypes). + /// Uses the same per-element helpers as so anchors are + /// discovered regardless of whether their containing definition is inline or reusable. + /// + private void RegisterComponentAnchors(OpenApiDocument document) + { + var components = document.Components; + if (components is null) return; + + if (components.Parameters is not null) + foreach (var parameter in components.Parameters.Values.Where(p => p is not null)) + RegisterParameterAnchors(document, parameter); + + if (components.Responses is not null) + foreach (var response in components.Responses.Values.Where(r => r is not null)) + RegisterResponseAnchors(document, response); + + if (components.RequestBodies is not null) + foreach (var requestBody in components.RequestBodies.Values.Where(r => r is not null)) + RegisterRequestBodyAnchors(document, requestBody); + + if (components.Headers is not null) + foreach (var header in components.Headers.Values.Where(h => h is not null)) + RegisterHeaderAnchors(document, header); + + if (components.Callbacks is not null) + foreach (var callback in components.Callbacks.Values.Where(c => c is not null)) + RegisterCallbackAnchors(document, callback, new()); + + if (components.PathItems is not null) + foreach (var pathItem in components.PathItems.Values.Where(p => p is not null)) + RegisterPathItemAnchors(document, pathItem, new()); + + RegisterMediaTypeSchemas(document, components.MediaTypes); + } + + /// + /// Registers $dynamicAnchor and $anchor declarations from inline (non-component) schemas: + /// paths and webhooks. + /// + private void RegisterInlineAnchors(OpenApiDocument document) + { + if (document.Paths is not null) + foreach (var pathItem in document.Paths.Values.Where(p => p is not null)) + RegisterPathItemAnchors(document, pathItem, new()); + + if (document.Webhooks is not null) + foreach (var pathItem in document.Webhooks.Values.Where(p => p is not null)) + RegisterPathItemAnchors(document, pathItem, new()); + } + + // The structural walk (pathItem -> operation -> callback -> pathItem) can cycle through + // self- or mutually-referential callbacks, so each of these guards against re-entry via the + // shared `visited` set, mirroring RegisterAnchorsRecursive's schema-cycle guard. + private void RegisterPathItemAnchors(OpenApiDocument document, IOpenApiPathItem pathItem, HashSet visited) + { + if (pathItem is OpenApiPathItemReference) return; + if (!visited.Add(pathItem)) return; + + if (pathItem.Parameters is not null) + foreach (var parameter in pathItem.Parameters.Where(p => p is not null)) + RegisterParameterAnchors(document, parameter); + + if (pathItem.Operations is not null) + foreach (var op in pathItem.Operations.Values.Where(o => o is not null)) + RegisterOperationAnchors(document, op, visited); + } + + private void RegisterCallbackAnchors(OpenApiDocument document, IOpenApiCallback callback, HashSet visited) + { + if (callback is OpenApiCallbackReference) return; + if (!visited.Add(callback)) return; + + if (callback.PathItems is not null) + foreach (var pathItem in callback.PathItems.Values.Where(p => p is not null)) + RegisterPathItemAnchors(document, pathItem, visited); + } + + private void RegisterOperationAnchors(OpenApiDocument document, OpenApiOperation op, HashSet visited) + { + if (!visited.Add(op)) return; + + if (op.Parameters is not null) + foreach (var parameter in op.Parameters.Where(p => p is not null)) + RegisterParameterAnchors(document, parameter); + + if (op.RequestBody is not null) + RegisterRequestBodyAnchors(document, op.RequestBody); + + if (op.Responses is not null) + foreach (var response in op.Responses.Values.Where(r => r is not null)) + RegisterResponseAnchors(document, response); + + if (op.Callbacks is not null) + foreach (var callback in op.Callbacks.Values.Where(c => c is not null)) + RegisterCallbackAnchors(document, callback, visited); + } + + private void RegisterParameterAnchors(OpenApiDocument document, IOpenApiParameter parameter) + { + if (parameter is OpenApiParameterReference) return; + if (parameter.Schema is not null) + RegisterAnchors(document, parameter.Schema); + RegisterMediaTypeSchemas(document, parameter.Content); + } + + private void RegisterRequestBodyAnchors(OpenApiDocument document, IOpenApiRequestBody requestBody) + { + if (requestBody is OpenApiRequestBodyReference) return; + RegisterMediaTypeSchemas(document, requestBody.Content); + } + + private void RegisterResponseAnchors(OpenApiDocument document, IOpenApiResponse response) + { + if (response is OpenApiResponseReference) return; + RegisterMediaTypeSchemas(document, response.Content); + if (response.Headers is not null) + foreach (var header in response.Headers.Values.Where(h => h is not null)) + RegisterHeaderAnchors(document, header); + } + + private void RegisterHeaderAnchors(OpenApiDocument document, IOpenApiHeader header) + { + if (header is OpenApiHeaderReference) return; + if (header.Schema is not null) + RegisterAnchors(document, header.Schema); + RegisterMediaTypeSchemas(document, header.Content); + } + + private void RegisterMediaTypeSchemas(OpenApiDocument document, IDictionary? content) + { + if (content is null) return; + foreach (var mediaType in content.Values.Where(m => m is not null and not OpenApiMediaTypeReference)) + { + if (mediaType.Schema is not null) + RegisterAnchors(document, mediaType.Schema); + if (mediaType.ItemSchema is not null) + RegisterAnchors(document, mediaType.ItemSchema); + } } private static string getBaseUri(OpenApiDocument openApiDocument) @@ -283,6 +434,351 @@ internal bool RegisterComponent(string location, T component) return false; } + private void RegisterSchemaIdentifiers(OpenApiDocument document, IOpenApiSchema schema, Uri baseUri) + { + RegisterSchemaIdentifiers(document, schema, baseUri, new HashSet()); + } + + private void RegisterSchemaIdentifiers(OpenApiDocument document, IOpenApiSchema schema, Uri baseUri, ISet visitedSchemas) + { + if (schema is OpenApiSchemaReference osr) + { + if (osr.Reference.DynamicAnchor is string dynAnchor && dynAnchor.Length > 0) + RegisterAnchor(document, dynAnchor, schema, isDynamic: true); + if (osr.Reference.Anchor is string plainAnchor && plainAnchor.Length > 0) + RegisterAnchor(document, plainAnchor, schema, isDynamic: false); + foreach (var child in EnumerateChildren(osr.Reference)) + RegisterSchemaIdentifiers(document, child, baseUri, visitedSchemas); + return; + } + + if (!visitedSchemas.Add(schema)) + { + return; + } + + var schemaBaseUri = baseUri; + if (!string.IsNullOrEmpty(schema.Id) && + Uri.TryCreate(schema.Id, UriKind.RelativeOrAbsolute, out var schemaIdUri)) + { + schemaBaseUri = schemaIdUri.IsAbsoluteUri ? schemaIdUri : new Uri(baseUri, schemaIdUri); + RegisterComponent(schemaBaseUri.AbsoluteUri, schema); + } + + if (schema is IOpenApiSchemaMissingProperties { Anchor.Length: > 0 } schemaWithAnchor) + { + var anchorUriBuilder = new UriBuilder(schemaBaseUri) + { + Fragment = schemaWithAnchor.Anchor + }; + RegisterComponent(anchorUriBuilder.Uri.AbsoluteUri, schema); + } + + if (schema.DynamicAnchor is string dynAnchorName && dynAnchorName.Length > 0) + RegisterAnchor(document, dynAnchorName, schema, isDynamic: true); + if (schema is IOpenApiSchemaMissingProperties { Anchor.Length: > 0 } schemaWithPlainAnchor) + RegisterAnchor(document, schemaWithPlainAnchor.Anchor, schema, isDynamic: false); + + RegisterSchemaIdentifier(document, schema.Items, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, schema.AdditionalProperties, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, schema.Not, schemaBaseUri, visitedSchemas); + + RegisterSchemaIdentifiers(document, schema.Properties?.Values, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifiers(document, schema.PatternProperties?.Values, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifiers(document, schema.Definitions?.Values, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifiers(document, schema.AllOf, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifiers(document, schema.AnyOf, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifiers(document, schema.OneOf, schemaBaseUri, visitedSchemas); + + if (schema is IOpenApiSchemaMissingProperties missingProperties) + { + RegisterSchemaIdentifier(document, missingProperties.Contains, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, missingProperties.PropertyNames, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, missingProperties.UnevaluatedPropertiesSchema, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, missingProperties.ContentSchema, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, missingProperties.If, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, missingProperties.Then, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifier(document, missingProperties.Else, schemaBaseUri, visitedSchemas); + RegisterSchemaIdentifiers(document, missingProperties.DependentSchemas?.Values, schemaBaseUri, visitedSchemas); + } + } + + private void RegisterSchemaIdentifiers(OpenApiDocument document, IEnumerable? schemas, Uri baseUri, ISet visitedSchemas) + { + if (schemas is null) + { + return; + } + + foreach (var schema in schemas) + { + RegisterSchemaIdentifiers(document, schema, baseUri, visitedSchemas); + } + } + + private void RegisterSchemaIdentifier(OpenApiDocument document, IOpenApiSchema? schema, Uri baseUri, ISet visitedSchemas) + { + if (schema is not null) + { + RegisterSchemaIdentifiers(document, schema, baseUri, visitedSchemas); + } + } + + /// + /// Registers all $dynamicAnchor and $anchor declarations found anywhere within a schema, including + /// nested locations ($defs, properties, items, allOf/anyOf/oneOf, if/then/else, etc.). + /// Anchors are scoped to so that two documents in the same + /// workspace can each declare the same anchor name without interfering. + /// $ref targets are not followed; referenced components are registered independently. + /// + private void RegisterAnchors(OpenApiDocument document, IOpenApiSchema schema) + => RegisterAnchorsRecursive(document, schema, new HashSet()); + + private void RegisterAnchorsRecursive(OpenApiDocument document, IOpenApiSchema? schema, HashSet visited) + { + if (schema is null || !visited.Add(schema)) return; + + // For reference holders, read authored $dynamicAnchor and $anchor siblings from the + // reference object itself — never from the resolved target. Reading IOpenApiSchema.DynamicAnchor + // or .Anchor on a reference falls through to Target, which would duplicate the entry under a + // different object and make single-candidate resolution look ambiguous. + if (schema is OpenApiSchemaReference osr) + { + if (osr.Reference.DynamicAnchor is string dynAnchor && dynAnchor.Length > 0) + RegisterAnchor(document, dynAnchor, schema, isDynamic: true); + if (osr.Reference.Anchor is string plainAnchor && plainAnchor.Length > 0) + RegisterAnchor(document, plainAnchor, schema, isDynamic: false); + } + else + { + if (schema.DynamicAnchor is string dynAnchor && dynAnchor.Length > 0) + RegisterAnchor(document, dynAnchor, schema, isDynamic: true); + if (schema is IOpenApiSchemaMissingProperties mp && mp.Anchor is string plainAnchor && plainAnchor.Length > 0) + RegisterAnchor(document, plainAnchor, schema, isDynamic: false); + } + + // Walk child schemas. For reference holders, read siblings from the reference object + // itself (JsonSchemaReference carries authored siblings like $defs via ApplySchemaMetadata), + // NOT from the resolved target — the target is registered independently as its own + // component and following it would cross document boundaries and duplicate entries. + var children = schema is OpenApiSchemaReference r ? EnumerateChildren(r.Reference) : EnumerateChildren(schema); + foreach (var child in children) + RegisterAnchorsRecursive(document, child, visited); + } + + /// + /// Enumerates all child schemas from an IOpenApiSchema, including properties from + /// IOpenApiSchemaMissingProperties (Contains, If/Then/Else, etc.). + /// + private static IEnumerable EnumerateChildren(IOpenApiSchema s) + { + if (s.Definitions is not null) + foreach (var c in s.Definitions.Values) yield return c; + if (s.AllOf is not null) + foreach (var c in s.AllOf) yield return c; + if (s.OneOf is not null) + foreach (var c in s.OneOf) yield return c; + if (s.AnyOf is not null) + foreach (var c in s.AnyOf) yield return c; + if (s.Not is not null) yield return s.Not; + if (s.Items is not null) yield return s.Items; + if (s.AdditionalProperties is not null) yield return s.AdditionalProperties; + if (s.Properties is not null) + foreach (var c in s.Properties.Values) yield return c; + if (s.PatternProperties is not null) + foreach (var c in s.PatternProperties.Values) yield return c; + if (s is IOpenApiSchemaMissingProperties mp) + foreach (var c in EnumerateMissingPropertiesChildren(mp)) + yield return c; + } + + /// + /// Enumerates child schemas from a JsonSchemaReference's own properties (not the resolved Target). + /// Needed because JsonSchemaReference is not an IOpenApiSchema — reading via the interface + /// on OpenApiSchemaReference would delegate to Target and cross document boundaries. + /// + private static IEnumerable EnumerateChildren(JsonSchemaReference r) + { + if (r.Definitions is not null) + foreach (var c in r.Definitions.Values) yield return c; + if (r.AllOf is not null) + foreach (var c in r.AllOf) yield return c; + if (r.OneOf is not null) + foreach (var c in r.OneOf) yield return c; + if (r.AnyOf is not null) + foreach (var c in r.AnyOf) yield return c; + if (r.Not is not null) yield return r.Not; + if (r.Items is not null) yield return r.Items; + if (r.AdditionalProperties is not null) yield return r.AdditionalProperties; + if (r.Properties is not null) + foreach (var c in r.Properties.Values) yield return c; + if (r.PatternProperties is not null) + foreach (var c in r.PatternProperties.Values) yield return c; + if (r.Contains is not null) yield return r.Contains; + if (r.PropertyNames is not null) yield return r.PropertyNames; + if (r.ContentSchema is not null) yield return r.ContentSchema; + if (r.UnevaluatedPropertiesSchema is not null) yield return r.UnevaluatedPropertiesSchema; + if (r.If is not null) yield return r.If; + if (r.Then is not null) yield return r.Then; + if (r.Else is not null) yield return r.Else; + if (r.DependentSchemas is not null) + foreach (var c in r.DependentSchemas.Values) yield return c; + } + + private static IEnumerable EnumerateMissingPropertiesChildren(IOpenApiSchemaMissingProperties mp) + { + if (mp.Contains is not null) yield return mp.Contains; + if (mp.PropertyNames is not null) yield return mp.PropertyNames; + if (mp.ContentSchema is not null) yield return mp.ContentSchema; + if (mp.UnevaluatedPropertiesSchema is not null) yield return mp.UnevaluatedPropertiesSchema; + if (mp.If is not null) yield return mp.If; + if (mp.Then is not null) yield return mp.Then; + if (mp.Else is not null) yield return mp.Else; + if (mp.DependentSchemas is not null) + foreach (var c in mp.DependentSchemas.Values) yield return c; + } + + private void RegisterAnchor(OpenApiDocument document, string anchorName, IOpenApiSchema schema, bool isDynamic) + { + ref var registry = ref (isDynamic ? ref _dynamicAnchorRegistryByDocument : ref _anchorRegistryByDocument); + registry ??= new(); + if (!registry.TryGetValue(document, out var anchors)) + { + anchors = new(StringComparer.Ordinal); + registry[document] = anchors; + } + if (!anchors.TryGetValue(anchorName, out var list)) + { + list = []; + anchors[anchorName] = list; + } + if (!list.Contains(schema)) + list.Add(schema); + } + + /// + /// Resolves a plain $anchor by name within the scope of . + /// Used as the fallback when $dynamicAnchor resolution finds zero candidates, per JSON Schema 2020-12 §8.2.3.2. + /// Returns the schema when exactly one candidate exists; returns null for zero or multiple. + /// + internal IOpenApiSchema? ResolveAnchor(OpenApiDocument hostDocument, string anchorName) + { + if (_anchorRegistryByDocument is not null && + _anchorRegistryByDocument.TryGetValue(hostDocument, out var anchors) && + anchors.TryGetValue(anchorName, out var candidates)) + return candidates.Count == 1 ? candidates[0] : null; + return null; + } + + /// + /// Finds a registered document by its base URI. Used to resolve cross-document + /// $dynamicRef values that target an external resource. + /// + internal OpenApiDocument? FindDocumentByBaseUri(string documentUri) + { + if (!Uri.TryCreate(documentUri, UriKind.Absolute, out var uri)) return null; + if (_dynamicAnchorRegistryByDocument?.Keys.FirstOrDefault(doc => doc.BaseUri == uri) is { } dynMatch) + return dynMatch; + if (_anchorRegistryByDocument?.Keys.FirstOrDefault(doc => doc.BaseUri == uri) is { } anchorMatch) + return anchorMatch; + return null; + } + + /// + /// Resolves a $dynamicRef value against the workspace's anchor registries. + /// For fragment-only refs (#node), resolves against the host document. + /// For URI refs (absolute or relative), finds the external document and resolves there. + /// Relative URIs are resolved against the host document's BaseUri. + /// + internal IOpenApiSchema? ResolveDynamicRef(OpenApiDocument hostDocument, string dynamicRef) + { + var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicRef); + if (string.IsNullOrEmpty(anchorName)) return null; + + OpenApiDocument? targetDoc = hostDocument; + if (!JsonNodeHelper.IsFragmentOnlyDynamicRef(dynamicRef)) + { + var docUri = JsonNodeHelper.ExtractDocumentUri(dynamicRef); + if (docUri is not null) + { + if (!Uri.IsWellFormedUriString(docUri, UriKind.Absolute) + && hostDocument.BaseUri is not null + && Uri.TryCreate(hostDocument.BaseUri, docUri, out var resolved)) + docUri = resolved.AbsoluteUri; + targetDoc = FindDocumentByBaseUri(docUri); + } + else + { + targetDoc = null; + } + } + + if (targetDoc is null) return null; + + var candidates = GetDynamicAnchorCandidates(targetDoc, anchorName!); + if (candidates.Count == 1) return candidates[0]; + if (candidates.Count == 0 && ResolveAnchor(targetDoc, anchorName!) is { } anchorTarget) + return anchorTarget; + return null; + } + + /// + /// Returns all schemas in the document that declare a $dynamicAnchor matching + /// . A single candidate resolves directly; zero candidates + /// means no dynamic anchor exists (callers may fall back to plain $anchor); multiple + /// candidates indicate an ambiguous anchor whose resolution requires dynamic-scope evaluation + /// that this library does not perform. + /// + /// The document whose anchors to search. + /// The bare anchor name (without leading #). + /// All candidate schemas, or an empty list if none. + public IReadOnlyList GetDynamicAnchorCandidates(OpenApiDocument hostDocument, string anchorName) + { + if (_dynamicAnchorRegistryByDocument is not null && + _dynamicAnchorRegistryByDocument.TryGetValue(hostDocument, out var anchors) && + anchors.TryGetValue(anchorName, out var candidates)) + return candidates.AsReadOnly(); + return []; + } + + /// + /// Resolves a $dynamicAnchor within the context of a specific schema's $defs. + /// This is a context-aware lookup: given the schema that serves as the dynamic-scope entry + /// point (e.g. a response body schema), checks whether it or its $defs entries declare + /// a matching $dynamicAnchor. + /// Consumers that track dynamic scope (e.g. code generators processing an endpoint) call this + /// with their entry-point schema to resolve context-dependent $dynamicRef values. + /// + /// The schema to search (e.g. the response body schema that + /// provides the $defs binding). + /// The bare anchor name (without leading #). + /// The matching schema, or null if not declared in this context. + public static IOpenApiSchema? ResolveDynamicAnchorInContext(IOpenApiSchema? contextSchema, string anchorName) + { + if (contextSchema is null || string.IsNullOrEmpty(anchorName)) return null; + + if (contextSchema is OpenApiSchemaReference osr) + { + if (osr.Reference.DynamicAnchor is string a && a.Equals(anchorName, StringComparison.Ordinal)) + return contextSchema; + return osr.Reference.Definitions?.Values.FirstOrDefault(def => + AuthoredDynamicAnchor(def) is string da && da.Equals(anchorName, StringComparison.Ordinal)); + } + + if (contextSchema.DynamicAnchor is string b && b.Equals(anchorName, StringComparison.Ordinal)) + return contextSchema; + + return contextSchema.Definitions?.Values.FirstOrDefault(def => + AuthoredDynamicAnchor(def) is string da && da.Equals(anchorName, StringComparison.Ordinal)); + } + + // Reads only the authored $dynamicAnchor on the schema itself. For OpenApiSchemaReference, + // IOpenApiSchema.DynamicAnchor falls through to Target when the reference's authored sibling + // is empty, which would resolve an anchor declared on the referenced target rather than on + // the $defs entry. Context-bound resolution must consider only the authored sibling. + private static string? AuthoredDynamicAnchor(IOpenApiSchema schema) + => schema is OpenApiSchemaReference r ? r.Reference.DynamicAnchor : schema.DynamicAnchor; + /// /// Adds a document id to the dictionaries of document locations and their ids. /// @@ -365,6 +861,11 @@ public bool Contains(string location) if (string.IsNullOrEmpty(location) || ToLocationUrl(location) is not Uri uri) return default; + if (!string.IsNullOrEmpty(uri.Fragment) && !uri.Fragment.StartsWith("#/", StringComparison.Ordinal)) + { + return ResolveReference(location); + } + #if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER if (!location.Contains("#/components/schemas/", StringComparison.OrdinalIgnoreCase)) #else diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs index e3c6e1b4f..7473bce91 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs @@ -11,12 +11,19 @@ namespace Microsoft.OpenApi /// The validation rules for . /// [OpenApiRule] - public static class OpenApiComponentsRules + public static partial class OpenApiComponentsRules { /// - /// The key regex. + /// The key regex pattern. /// - internal static readonly Regex KeyRegex = new(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, TimeSpan.FromMilliseconds(100)); + internal const string KeyPattern = @"^[a-zA-Z0-9\.\-_]+$"; + +#if NET8_0_OR_GREATER + [GeneratedRegex(KeyPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex KeyRegex(); +#else + private static readonly Regex KeyRegex = new(KeyPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); +#endif /// /// All the fixed fields declared above are objects @@ -54,12 +61,18 @@ private static void ValidateKeys(IValidationContext context, IEnumerable foreach (var key in keys) { - if (!KeyRegex.IsMatch(key)) +#if NET8_0_OR_GREATER + var isValidKey = KeyRegex().IsMatch(key); +#else + var isValidKey = KeyRegex.IsMatch(key); +#endif + if (!isValidKey) { context.CreateError(nameof(KeyMustBeRegularExpression), - string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, component, KeyRegex.ToString())); + string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, component, KeyPattern)); } } } + } } diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiNonDefaultRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiNonDefaultRules.cs index 2bc019667..b5a398cb1 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiNonDefaultRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiNonDefaultRules.cs @@ -7,6 +7,7 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// Defines a non-default set of rules for validating examples in header, media type and parameter objects against the schema /// diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs index 4f2a122a9..eff687253 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs @@ -8,6 +8,7 @@ namespace Microsoft.OpenApi using System; using System.ComponentModel; using System.Linq; + using System.Runtime.CompilerServices; /// /// The validation rules for . @@ -73,32 +74,17 @@ public static class OpenApiSchemaRules [Browsable(false)] public static bool ValidateChildSchemaAgainstDiscriminator(IOpenApiSchema schema, string? discriminatorName) { - if (discriminatorName is not null) + if (discriminatorName is null) { - if (schema.Required is null || !schema.Required.Contains(discriminatorName)) - { - // recursively check nested schema.OneOf, schema.AnyOf or schema.AllOf and their required fields for the discriminator - if (schema.OneOf is { Count: > 0}) - { - return TraverseSchemaElements(discriminatorName, schema.OneOf); - } - if (schema.AnyOf is { Count: > 0}) - { - return TraverseSchemaElements(discriminatorName, schema.AnyOf); - } - if (schema.AllOf is { Count: > 0}) - { - return TraverseSchemaElements(discriminatorName, schema.AllOf); - } - } - else - { - return true; - } return false; - } + } - return false; + if (schema.Required?.Contains(discriminatorName) == true) + { + return true; + } + + return TraverseSchemaElementsIterative(discriminatorName, GetSchemaCombinators(schema)); } /// @@ -111,24 +97,84 @@ public static bool ValidateChildSchemaAgainstDiscriminator(IOpenApiSchema schema [Obsolete("This method will be made private in future versions.")] [Browsable(false)] public static bool TraverseSchemaElements(string discriminatorName, IList? childSchema) + => TraverseSchemaElementsIterative(discriminatorName, childSchema); + + private static bool TraverseSchemaElementsIterative(string discriminatorName, IEnumerable? childSchemas) { - if (childSchema is null) + if (childSchemas is null) { return false; } - foreach (var childItem in childSchema) + + var schemasToVisit = new Queue(); + var visitedSchemas = new HashSet(SchemaReferenceEqualityComparer.Instance); + + EnqueueSchemas(schemasToVisit, childSchemas); + + while (schemasToVisit.Count > 0) { - if ((!childItem.Properties?.ContainsKey(discriminatorName) ?? false) && - (!childItem.Required?.Contains(discriminatorName) ?? false)) + var childItem = schemasToVisit.Dequeue(); + if (!visitedSchemas.Add(childItem)) { - return ValidateChildSchemaAgainstDiscriminator(childItem, discriminatorName); + continue; } - else + + if (childItem.Properties?.ContainsKey(discriminatorName) == true || + childItem.Required?.Contains(discriminatorName) == true) { return true; } + + EnqueueSchemas(schemasToVisit, GetSchemaCombinators(childItem)); } + return false; } + + private static IEnumerable GetSchemaCombinators(IOpenApiSchema schema) + { + if (schema.OneOf is { Count: > 0 } oneOf) + { + foreach (var childSchema in oneOf) + { + yield return childSchema; + } + } + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + foreach (var childSchema in anyOf) + { + yield return childSchema; + } + } + + if (schema.AllOf is { Count: > 0 } allOf) + { + foreach (var childSchema in allOf) + { + yield return childSchema; + } + } + } + + private static void EnqueueSchemas(Queue schemasToVisit, IEnumerable childSchemas) + { + foreach (var childSchema in childSchemas.Where(childSchema => childSchema is not null)) + { + schemasToVisit.Enqueue(childSchema); + } + } + + private sealed class SchemaReferenceEqualityComparer : IEqualityComparer + { + internal static SchemaReferenceEqualityComparer Instance { get; } = new(); + + public bool Equals(IOpenApiSchema? x, IOpenApiSchema? y) + => ReferenceEquals(x, y); + + public int GetHashCode(IOpenApiSchema obj) + => RuntimeHelpers.GetHashCode(obj); + } } } diff --git a/src/Microsoft.OpenApi/Writers/OpenApiWriterExtensions.cs b/src/Microsoft.OpenApi/Writers/OpenApiWriterExtensions.cs index f346dddc4..1d7a6e2a4 100644 --- a/src/Microsoft.OpenApi/Writers/OpenApiWriterExtensions.cs +++ b/src/Microsoft.OpenApi/Writers/OpenApiWriterExtensions.cs @@ -41,6 +41,7 @@ public static void WriteProperty(this IOpenApiWriter writer, string name, string public static void WriteRequiredProperty(this IOpenApiWriter writer, string name, string? value) { Utils.CheckArgumentNullOrEmpty(name); + writer.WritePropertyName(name); if (value == null) { diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj b/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj index 8e30f7f56..c5adf7adc 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj +++ b/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj @@ -10,15 +10,16 @@ CA2007 true ..\..\src\Microsoft.OpenApi.snk + true - - - - - - + + + + + + diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs index 8f8aa0c8e..6883bab5a 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs +++ b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs @@ -51,7 +51,7 @@ public void ReturnFilteredOpenApiDocumentBasedOnOperationIdsAndTags(string? oper public void ReturnFilteredOpenApiDocumentBasedOnPostmanCollection() { // Arrange - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver2.json"); + var filePath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver2.json"); var fileInput = new FileInfo(filePath); var stream = fileInput.OpenRead(); @@ -158,7 +158,7 @@ public void CreateFilteredDocumentUsingPredicateFromRequestUrl() public void ShouldParseNestedPostmanCollection() { // Arrange - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver3.json"); + var filePath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver3.json"); var fileInput = new FileInfo(filePath); var stream = fileInput.OpenRead(); @@ -175,7 +175,7 @@ public void ShouldParseNestedPostmanCollection() public void ThrowsExceptionWhenUrlsInCollectionAreMissingFromSourceDocument() { // Arrange - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver1.json"); + var filePath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver1.json"); var fileInput = new FileInfo(filePath); var stream = fileInput.OpenRead(); @@ -192,7 +192,7 @@ public void ThrowsExceptionWhenUrlsInCollectionAreMissingFromSourceDocument() public void ContinueProcessingWhenUrlsInCollectionAreMissingFromSourceDocument() { // Arrange - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver4.json"); + var filePath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "postmanCollection_ver4.json"); var fileInput = new FileInfo(filePath); var stream = fileInput.OpenRead(); @@ -291,14 +291,14 @@ public void CreatePredicateMatchesAbsoluteUrlsWhenSourceHasNoServers() public async Task CopiesOverAllReferencedComponentsToTheSubsetDocumentCorrectly() { // Arrange - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "docWithReusableHeadersAndExamples.yaml"); + var filePath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "docWithReusableHeadersAndExamples.yaml"); var operationIds = "getItems"; // Act using var stream = File.OpenRead(filePath); var settings = new OpenApiReaderSettings(); settings.AddYamlReader(); - var doc = (await OpenApiDocument.LoadAsync(stream, "yaml", settings)).Document; + var doc = (await OpenApiDocument.LoadAsync(stream, "yaml", settings, TestContext.Current.CancellationToken)).Document; // validated the tags are read as references var openApiOperationTags = doc?.Paths["/items"].Operations?[HttpMethod.Get].Tags?.ToArray(); @@ -344,7 +344,7 @@ public async Task CopiesOverAllReferencedComponentsToTheSubsetDocumentCorrectly( } }; subsetOpenApiDocument.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var result = outputStringWriter.ToString(); Assert.NotEmpty(result); } diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs index 6342f6703..981a00558 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs +++ b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs @@ -134,13 +134,13 @@ public async Task ShowCommandGeneratesMermaidMarkdownFileWithMermaidDiagramAsync // create a dummy ILogger instance for testing var options = new HidiOptions { - OpenApi = Path.Combine("UtilityFiles", "SampleOpenApi.yml"), - Output = new("sample.md") + OpenApi = Path.Join("UtilityFiles", "SampleOpenApi.yml"), + Output = new($"{nameof(ShowCommandGeneratesMermaidMarkdownFileWithMermaidDiagramAsync)}.md") }; - await OpenApiService.ShowOpenApiDocumentAsync(options, _logger); + await OpenApiService.ShowOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync(options.Output.FullName); + var output = await File.ReadAllTextAsync(options.Output.FullName, TestContext.Current.CancellationToken); Assert.Contains("graph LR", output, StringComparison.Ordinal); } @@ -149,9 +149,9 @@ public async Task ShowCommandGeneratesMermaidHtmlFileWithMermaidDiagramAsync() { var options = new HidiOptions { - OpenApi = Path.Combine("UtilityFiles", "SampleOpenApi.yml") + OpenApi = Path.Join("UtilityFiles", "SampleOpenApi.yml") }; - var filePath = await OpenApiService.ShowOpenApiDocumentAsync(options, _logger); + var filePath = await OpenApiService.ShowOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken); Assert.True(File.Exists(filePath)); } @@ -159,28 +159,28 @@ public async Task ShowCommandGeneratesMermaidHtmlFileWithMermaidDiagramAsync() public Task ThrowIfOpenApiUrlIsNotProvidedWhenValidatingAsync() { return Assert.ThrowsAsync(() => - OpenApiService.ValidateOpenApiDocumentAsync("", _logger)); + OpenApiService.ValidateOpenApiDocumentAsync("", _logger, TestContext.Current.CancellationToken)); } [Fact] public Task ThrowIfURLIsNotResolvableWhenValidatingAsync() { return Assert.ThrowsAsync(() => - OpenApiService.ValidateOpenApiDocumentAsync("https://example.org926F4F21-88E7-4DC5-BF88-6C529BB77844/itdoesnmatter", _logger)); + OpenApiService.ValidateOpenApiDocumentAsync("https://example.org926F4F21-88E7-4DC5-BF88-6C529BB77844/itdoesnmatter", _logger, TestContext.Current.CancellationToken)); } [Fact] public Task ThrowIfFileDoesNotExistWhenValidatingAsync() { return Assert.ThrowsAsync(() => - OpenApiService.ValidateOpenApiDocumentAsync("aFileThatBetterNotExist.fake", _logger)); + OpenApiService.ValidateOpenApiDocumentAsync("aFileThatBetterNotExist.fake", _logger, TestContext.Current.CancellationToken)); } [Fact] public async Task ValidateCommandProcessesOpenApiAsync() { // create a dummy ILogger instance for testing - await OpenApiService.ValidateOpenApiDocumentAsync(Path.Combine("UtilityFiles", "SampleOpenApi.yml"), _logger); + await OpenApiService.ValidateOpenApiDocumentAsync(Path.Join("UtilityFiles", "SampleOpenApi.yml"), _logger, TestContext.Current.CancellationToken); Assert.True(true); } @@ -188,7 +188,7 @@ public async Task ValidateCommandProcessesOpenApiAsync() [Fact] public async Task ValidFileReturnsTrueAsync() { - var isValid = await OpenApiService.ValidateOpenApiDocumentAsync(Path.Combine("UtilityFiles", "SampleOpenApi.yml"), _logger); + var isValid = await OpenApiService.ValidateOpenApiDocumentAsync(Path.Join("UtilityFiles", "SampleOpenApi.yml"), _logger, TestContext.Current.CancellationToken); Assert.True(isValid); } @@ -196,7 +196,7 @@ public async Task ValidFileReturnsTrueAsync() [Fact] public async Task InvalidFileReturnsFalseAsync() { - var isValid = await OpenApiService.ValidateOpenApiDocumentAsync(Path.Combine("UtilityFiles", "InvalidSampleOpenApi.yml"), _logger); + var isValid = await OpenApiService.ValidateOpenApiDocumentAsync(Path.Join("UtilityFiles", "InvalidSampleOpenApi.yml"), _logger, TestContext.Current.CancellationToken); Assert.False(isValid); } @@ -206,7 +206,7 @@ public async Task CancellingValidationReturnsNullAsync() { using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - var isValid = await OpenApiService.ValidateOpenApiDocumentAsync(Path.Combine("UtilityFiles", "SampleOpenApi.yml"), _logger, cts.Token); + var isValid = await OpenApiService.ValidateOpenApiDocumentAsync(Path.Join("UtilityFiles", "SampleOpenApi.yml"), _logger, cts.Token); Assert.Null(isValid); } @@ -216,17 +216,17 @@ public async Task TransformCommandConvertsOpenApiAsync() { var options = new HidiOptions { - OpenApi = Path.Combine("UtilityFiles", "SampleOpenApi.yml"), - Output = new("sample.json"), + OpenApi = Path.Join("UtilityFiles", "SampleOpenApi.yml"), + Output = new($"{nameof(TransformCommandConvertsOpenApiAsync)}.json"), CleanOutput = true, TerseOutput = false, InlineLocal = false, InlineExternal = false, }; // create a dummy ILogger instance for testing - await OpenApiService.TransformOpenApiDocumentAsync(options, _logger); + await OpenApiService.TransformOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync("sample.json"); + var output = await File.ReadAllTextAsync(options.Output.FullName, TestContext.Current.CancellationToken); Assert.NotEmpty(output); } @@ -236,16 +236,17 @@ public async Task TransformCommandConvertsOpenApiWithDefaultOutputNameAsync() { var options = new HidiOptions { - OpenApi = Path.Combine("UtilityFiles", "SampleOpenApi.yml"), + OpenApi = Path.Join("UtilityFiles", "SampleOpenApi.yml"), CleanOutput = true, TerseOutput = false, InlineLocal = false, InlineExternal = false, + Output = new FileInfo($"{nameof(TransformCommandConvertsOpenApiWithDefaultOutputNameAsync)}.yml") }; // create a dummy ILogger instance for testing - await OpenApiService.TransformOpenApiDocumentAsync(options, _logger); + await OpenApiService.TransformOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync("output.yml"); + var output = await File.ReadAllTextAsync(options.Output.FullName, TestContext.Current.CancellationToken); Assert.NotEmpty(output); } @@ -254,7 +255,8 @@ public async Task TransformCommandConvertsOpenApiWithDefaultOutputNameAndSwitchF { var options = new HidiOptions { - OpenApi = Path.Combine("UtilityFiles", "SampleOpenApi.yml"), + OpenApi = Path.Join("UtilityFiles", "SampleOpenApi.yml"), + Output = new($"{nameof(TransformCommandConvertsOpenApiWithDefaultOutputNameAndSwitchFormatAsync)}.yml"), CleanOutput = true, Version = "3.0", OpenApiFormat = OpenApiConstants.Yaml, @@ -263,9 +265,9 @@ public async Task TransformCommandConvertsOpenApiWithDefaultOutputNameAndSwitchF InlineExternal = false, }; // create a dummy ILogger instance for testing - await OpenApiService.TransformOpenApiDocumentAsync(options, _logger); + await OpenApiService.TransformOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync("output.yml"); + var output = await File.ReadAllTextAsync(options.Output.FullName, TestContext.Current.CancellationToken); Assert.NotEmpty(output); } @@ -280,16 +282,17 @@ public Task ThrowTransformCommandIfOpenApiAndCsdlAreEmptyAsync() InlineExternal = false, }; return Assert.ThrowsAsync(() => - OpenApiService.TransformOpenApiDocumentAsync(options, _logger)); + OpenApiService.TransformOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken)); } [Fact] public async Task TransformToPowerShellCompliantOpenApiAsync() { - var settingsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "examplepowershellsettings.json"); + var settingsPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "UtilityFiles", "examplepowershellsettings.json"); var options = new HidiOptions { - OpenApi = Path.Combine("UtilityFiles", "SampleOpenApi.yml"), + OpenApi = Path.Join("UtilityFiles", "SampleOpenApi.yml"), + Output = new($"{nameof(TransformToPowerShellCompliantOpenApiAsync)}.yaml"), CleanOutput = true, Version = "3.0", OpenApiFormat = OpenApiConstants.Yaml, @@ -299,9 +302,9 @@ public async Task TransformToPowerShellCompliantOpenApiAsync() SettingsConfig = SettingsUtilities.GetConfiguration(settingsPath) }; // create a dummy ILogger instance for testing - await OpenApiService.TransformOpenApiDocumentAsync(options, _logger); + await OpenApiService.TransformOpenApiDocumentAsync(options, _logger, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync("output.yaml"); + var output = await File.ReadAllTextAsync(options.Output.FullName, TestContext.Current.CancellationToken); Assert.NotEmpty(output); } @@ -309,14 +312,15 @@ public async Task TransformToPowerShellCompliantOpenApiAsync() public async Task InvokeTransformCommandAsync() { var rootCommand = Program.CreateRootCommand(); - var openapi = Path.Combine(".", "UtilityFiles", "SampleOpenApi.yml"); - var args = new[] { "transform", "-d", openapi, "-o", "sample.json", "--co" }; + var openapi = Path.Join(".", "UtilityFiles", "SampleOpenApi.yml"); + var outputPath = $"{nameof(InvokeTransformCommandAsync)}.json"; + var args = new[] { "transform", "-d", openapi, "-o", outputPath, "--co" }; var parseResult = rootCommand.Parse(args); var handler = Assert.IsType(rootCommand.Subcommands.First(c => c.Name == "transform").Action, exactMatch: false); - await handler.InvokeAsync(parseResult); + await handler.InvokeAsync(parseResult, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync("sample.json"); + var output = await File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); Assert.NotEmpty(output); } @@ -325,14 +329,15 @@ public async Task InvokeTransformCommandAsync() public async Task InvokeShowCommandAsync() { var rootCommand = Program.CreateRootCommand(); - var openApi = Path.Combine(".", "UtilityFiles", "SampleOpenApi.yml"); - var args = new[] { "show", "-d", openApi, "-o", "sample.md" }; + var openApi = Path.Join(".", "UtilityFiles", "SampleOpenApi.yml"); + var outputPath = $"{nameof(InvokeShowCommandAsync)}.md"; + var args = new[] { "show", "-d", openApi, "-o", outputPath }; var parseResult = rootCommand.Parse(args); var handler = Assert.IsType(rootCommand.Subcommands.First(c => c.Name == "show").Action, exactMatch: false); - await handler.InvokeAsync(parseResult); + await handler.InvokeAsync(parseResult, TestContext.Current.CancellationToken); - var output = await File.ReadAllTextAsync("sample.md"); + var output = await File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); Assert.Contains("graph LR", output, StringComparison.Ordinal); } @@ -340,14 +345,15 @@ public async Task InvokeShowCommandAsync() public async Task InvokePluginCommandAsync() { var rootCommand = Program.CreateRootCommand(); - var manifest = Path.Combine(".", "UtilityFiles", "exampleapimanifest.json"); - var args = new[] { "plugin", "-m", manifest, "--of", AppDomain.CurrentDomain.BaseDirectory }; + var manifest = Path.Join(".", "UtilityFiles", "exampleapimanifest.json"); + var outputPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, nameof(InvokePluginCommandAsync)); + var args = new[] { "plugin", "-m", manifest, "--of", outputPath }; var parseResult = rootCommand.Parse(args); var handler = Assert.IsType(rootCommand.Subcommands.First(c => c.Name == "plugin").Action, exactMatch: false); - await handler.InvokeAsync(parseResult); + await handler.InvokeAsync(parseResult, TestContext.Current.CancellationToken); - using var jsDoc = JsonDocument.Parse(await File.ReadAllTextAsync("ai-plugin.json")); + using var jsDoc = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Join(outputPath, "ai-plugin.json"), TestContext.Current.CancellationToken)); var openAiManifest = OpenAIPluginManifest.Load(jsDoc.RootElement); Assert.NotNull(openAiManifest); diff --git a/test/Microsoft.OpenApi.Readers.Tests/DefaultSettingsFixture.cs b/test/Microsoft.OpenApi.Readers.Tests/DefaultSettingsFixture.cs deleted file mode 100644 index ed8072ba6..000000000 --- a/test/Microsoft.OpenApi.Readers.Tests/DefaultSettingsFixture.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -using FluentAssertions; - -namespace Microsoft.OpenApi.Readers.Tests -{ - /// - /// Fixture containing default settings for external libraries. - /// - public class DefaultSettingsFixture - { - /// - /// Initializes an instance of . - /// - public DefaultSettingsFixture() - { - // We need RespectingRuntimeTypes() to ensure equivalence test works property, - // given that there are multiple types that can be used for the declared type OpenApiAny. - // Without this option, properties specific to those types would not be compared. - AssertionOptions.AssertEquivalencyUsing( - o => o - .AllowingInfiniteRecursion() - .RespectingRuntimeTypes() - .WithStrictOrdering()); - } - } -} diff --git a/test/Microsoft.OpenApi.Readers.Tests/DefaultSettingsFixtureCollection.cs b/test/Microsoft.OpenApi.Readers.Tests/DefaultSettingsFixtureCollection.cs deleted file mode 100644 index d17520848..000000000 --- a/test/Microsoft.OpenApi.Readers.Tests/DefaultSettingsFixtureCollection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -using Xunit; - -namespace Microsoft.OpenApi.Readers.Tests -{ - /// - /// Collection dummy class for . - /// - /// - /// This class is needed in xUnit framework to define collection name - /// to be used in unit test classes. - /// - [CollectionDefinition("DefaultSettings")] - public class DefaultSettingsFixtureCollection : ICollectionFixture - { - } -} diff --git a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj index 2c37b7724..ccddceff0 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj +++ b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj @@ -4,6 +4,7 @@ false true ..\..\src\Microsoft.OpenApi.snk + true @@ -15,12 +16,11 @@ - - - - - - + + + + + diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs index ff3f4509e..7268c5304 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs @@ -29,4 +29,19 @@ public void IsAvailableOnSameNamespace() var extensionsNS = typeof(OpenApiReaderSettingsExtensions).Namespace; Assert.Equal(settingsNS, extensionsNS, StringComparer.Ordinal); } + + [Fact] + public void AddsYamlReaderWithPerReaderSettings() + { + var settings = new OpenApiReaderSettings(); + var yamlSettings = new OpenApiYamlReaderSettings + { + MaxAliasExpansionNodeCount = 10, + }; + + settings.AddYamlReader(yamlSettings); + + var yamlReader = Assert.IsType(settings.GetReader(OpenApiConstants.Yaml)); + Assert.Same(yamlReader, settings.GetReader(OpenApiConstants.Yml)); + } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs index 86adcdd5b..bb631927b 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs @@ -16,7 +16,7 @@ public class OpenApiDiagnosticTests [Fact] public async Task DetectedSpecificationVersionShouldBeV2_0() { - var actual = await OpenApiDocument.LoadAsync("V2Tests/Samples/basic.v2.yaml", SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync("V2Tests/Samples/basic.v2.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.NotNull(actual.Diagnostic); Assert.Equal(OpenApiSpecVersion.OpenApi2_0, actual.Diagnostic.SpecificationVersion); @@ -25,7 +25,7 @@ public async Task DetectedSpecificationVersionShouldBeV2_0() [Fact] public async Task DetectedSpecificationVersionShouldBeV3_0() { - var actual = await OpenApiDocument.LoadAsync("V3Tests/Samples/OpenApiDocument/minimalDocument.yaml", SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync("V3Tests/Samples/OpenApiDocument/minimalDocument.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.NotNull(actual.Diagnostic); Assert.Equal(OpenApiSpecVersion.OpenApi3_0, actual.Diagnostic.SpecificationVersion); @@ -44,7 +44,7 @@ public async Task DiagnosticReportMergedForExternalReferenceAsync() settings.AddYamlReader(); ReadResult result; - result = await OpenApiDocument.LoadAsync("OpenApiReaderTests/Samples/OpenApiDiagnosticReportMerged/TodoMain.yaml", settings); + result = await OpenApiDocument.LoadAsync("OpenApiReaderTests/Samples/OpenApiDiagnosticReportMerged/TodoMain.yaml", settings, token: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.Document.Workspace); diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiStreamReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiStreamReaderTests.cs index d2da0d7a4..61fc12e5b 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiStreamReaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiStreamReaderTests.cs @@ -17,10 +17,10 @@ public class OpenApiStreamReaderTests [Fact] public async Task StreamShouldCloseIfLeaveStreamOpenSettingEqualsFalse() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "petStore.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "petStore.yaml")); var settings = new OpenApiReaderSettings { LeaveStreamOpen = false }; settings.AddYamlReader(); - (_, var diagnostic) = await OpenApiDocument.LoadAsync(stream, settings: settings); + (_, var diagnostic) = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); Assert.False(stream.CanRead); Assert.Equal(OpenApiConstants.Yaml, diagnostic.Format); } @@ -28,10 +28,10 @@ public async Task StreamShouldCloseIfLeaveStreamOpenSettingEqualsFalse() [Fact] public async Task StreamShouldNotCloseIfLeaveStreamOpenSettingEqualsTrue() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "petStore.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "petStore.yaml")); var settings = new OpenApiReaderSettings { LeaveStreamOpen = true }; settings.AddYamlReader(); - _ = await OpenApiDocument.LoadAsync(stream, settings: settings); + _ = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); Assert.True(stream.CanRead); } @@ -39,15 +39,15 @@ public async Task StreamShouldNotCloseIfLeaveStreamOpenSettingEqualsTrue() public async Task StreamShouldNotBeDisposedIfLeaveStreamOpenSettingIsTrueAsync() { var memoryStream = new MemoryStream(); - using var fileStream = Resources.GetStream(Path.Combine(SampleFolderPath, "petStore.yaml")); + using var fileStream = Resources.GetStream(Path.Join(SampleFolderPath, "petStore.yaml")); - await fileStream.CopyToAsync(memoryStream); + await fileStream.CopyToAsync(memoryStream, TestContext.Current.CancellationToken); memoryStream.Position = 0; var stream = memoryStream; var settings = new OpenApiReaderSettings { LeaveStreamOpen = true }; settings.AddYamlReader(); - _ = await OpenApiDocument.LoadAsync(stream, settings: settings); + _ = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); stream.Seek(0, SeekOrigin.Begin); // does not throw an object disposed exception Assert.True(stream.CanRead); } @@ -60,12 +60,12 @@ public async Task StreamShouldReadWhenInitializedAsync() BaseAddress = new Uri("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/") }; - var stream = await httpClient.GetStreamAsync("20fe7a7b720a0e48e5842d002ac418b12a8201df/tests/v3.0/pass/petstore.yaml"); + var stream = await httpClient.GetStreamAsync("20fe7a7b720a0e48e5842d002ac418b12a8201df/tests/v3.0/pass/petstore.yaml", TestContext.Current.CancellationToken); // Read V3 as YAML var settings = new OpenApiReaderSettings(); settings.AddYamlReader(); - var result = await OpenApiDocument.LoadAsync(stream, settings: settings); + var result = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result.Document); } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/UnsupportedSpecVersionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/UnsupportedSpecVersionTests.cs index 6c66b9d00..e695c25a4 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/UnsupportedSpecVersionTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/UnsupportedSpecVersionTests.cs @@ -14,7 +14,7 @@ public async Task ThrowOpenApiUnsupportedSpecVersionException() { try { - _ = await OpenApiDocument.LoadAsync("OpenApiReaderTests/Samples/unsupported.v1.yaml", SettingsFixture.ReaderSettings); + _ = await OpenApiDocument.LoadAsync("OpenApiReaderTests/Samples/unsupported.v1.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); } catch (OpenApiUnsupportedSpecVersionException exception) { diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiSchemaInterfaceJsonConverter.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiSchemaInterfaceJsonConverter.cs new file mode 100644 index 000000000..16747954f --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiSchemaInterfaceJsonConverter.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.OpenApi.Readers.Tests +{ + /// + /// Serializes values via the native OpenAPI writer, + /// avoiding reflection on computed getters that create cycles for recursive $dynamicRef schemas. + /// + internal sealed class OpenApiSchemaInterfaceJsonConverter(Action serialize) : JsonConverter + { + public override IOpenApiSchema Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, IOpenApiSchema value, JsonSerializerOptions options) + { + using var tw = new StringWriter(); + var ow = new OpenApiJsonWriter(tw); + serialize(ow, value); + writer.WriteRawValue(tw.ToString(), skipInputValidation: true); + } + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs index 6d5530e3a..34f4f2d45 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs @@ -35,10 +35,10 @@ public async Task LoadingDocumentWithResolveAllReferencesShouldLoadDocumentIntoW """; var wr = new StreamWriter(stream); await wr.WriteAsync(doc); - await wr.FlushAsync(); + await wr.FlushAsync(TestContext.Current.CancellationToken); stream.Position = 0; - var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, settings: settings); + var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, settings: settings, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result.Document.Workspace); } @@ -56,7 +56,7 @@ public async Task LoadDocumentWithExternalReferenceShouldLoadBothDocumentsIntoWo settings.AddYamlReader(); ReadResult result; - result = await OpenApiDocument.LoadAsync("V3Tests/Samples/OpenApiWorkspace/TodoMain.yaml", settings); + result = await OpenApiDocument.LoadAsync("V3Tests/Samples/OpenApiWorkspace/TodoMain.yaml", settings, token: TestContext.Current.CancellationToken); var externalDocBaseUri = result.Document.Workspace.GetDocumentId("./TodoComponents.yaml"); var schemasPath = "#/components/schemas/"; @@ -82,14 +82,14 @@ public async Task LoadDocumentWithExternalReferencesInSubDirectories() settings.AddYamlReader(); // Act - var result = await OpenApiDocument.LoadAsync($"{sampleFolderPath}/Root.yaml", settings); + var result = await OpenApiDocument.LoadAsync($"{sampleFolderPath}/Root.yaml", settings, token: TestContext.Current.CancellationToken); var document = result.Document; var workspace = result.Document.Workspace; // Assert - Assert.True(workspace.Contains($"{Path.Combine(referenceBaseUri, "Directory", "PetsPage.yaml")}#/components/schemas/PetsPage")); - Assert.True(workspace.Contains($"{Path.Combine(referenceBaseUri, "Directory", "Pets.yaml")}#/components/schemas/Pets")); - Assert.True(workspace.Contains($"{Path.Combine(referenceBaseUri, "Directory", "Pets.yaml")}#/components/schemas/Pet")); + Assert.True(workspace.Contains($"{Path.Join(referenceBaseUri, "Directory", "PetsPage.yaml")}#/components/schemas/PetsPage")); + Assert.True(workspace.Contains($"{Path.Join(referenceBaseUri, "Directory", "Pets.yaml")}#/components/schemas/Pets")); + Assert.True(workspace.Contains($"{Path.Join(referenceBaseUri, "Directory", "Pets.yaml")}#/components/schemas/Pet")); var operationResponseSchema = document.Paths["/pets"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; Assert.IsType(operationResponseSchema); diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs index 6d66430da..60376caa9 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -28,20 +29,69 @@ public async Task ReadAsyncParsesDocumentsFromNonMemoryStreams() var result = await reader.ReadAsync(stream, DocumentLocation, SettingsFixture.ReaderSettings, CancellationToken.None); - Assert.NotNull(result.Document); + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); Assert.Equal("Sample API", result.Document.Info.Title); Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); } [Fact] - public void ReadThrowsWhenYamlDoesNotContainADocument() + public async Task ReadAsyncHonorsCancellationForMemoryStreams() + { + var reader = new OpenApiYamlReader(); + await using var stream = CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => reader.ReadAsync( + stream, + DocumentLocation, + SettingsFixture.ReaderSettings, + cancellationSource.Token)); + } + + [Fact] + public async Task ReadAsyncPropagatesCancellationDuringYamlScanning() + { + var reader = new OpenApiYamlReader(); + using var cancellationSource = new CancellationTokenSource(); + await using var stream = new CancelingMemoryStream( + Encoding.UTF8.GetBytes( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """), + cancellationSource); + + await Assert.ThrowsAnyAsync( + () => reader.ReadAsync( + stream, + DocumentLocation, + SettingsFixture.ReaderSettings, + cancellationSource.Token)); + } + + [Fact] + public void ReadReturnsDiagnosticWhenYamlDoesNotContainADocument() { var reader = new OpenApiYamlReader(); using var stream = CreateStream(string.Empty); - var exception = Assert.Throws(() => reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings)); + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); - Assert.Equal("No documents found in the YAML stream.", exception.Message); + Assert.Null(result.Document); + Assert.Single(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); } [Fact] @@ -75,6 +125,398 @@ public void ReadThrowsWhenSettingsIsNull() Assert.Throws(() => reader.Read(stream, DocumentLocation, null!)); } + [Fact] + public async Task ReadAsyncThrowsWhenSettingsIsNullWithoutConsumingTheStream() + { + var reader = new OpenApiYamlReader(); + var inner = CreateStream("openapi: 3.0.1"); + await using var stream = new NonMemoryStream(inner); + + await Assert.ThrowsAsync( + () => reader.ReadAsync(stream, DocumentLocation, null!, CancellationToken.None)); + + Assert.Equal(0, inner.Position); + } + + [Fact] + public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion() + { + // A "billion laughs" YAML bomb must surface as a diagnostic error with no document, + // rather than throwing or exhausting memory. + var reader = new OpenApiYamlReader(); + using var stream = CreateStream( + """ + a: &a ["x","x","x","x","x","x","x","x","x"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + """); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.NotEmpty(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); + } + + [Fact] + public void ReadRejectsAliasBombBelowTotalNodeLimit() + { + const int fanOut = 21; + var yaml = new StringBuilder(); + yaml.AppendLine($"a: &a [{string.Join(",", Enumerable.Repeat("\"x\"", fanOut))}]"); + var previousAnchor = 'a'; + for (var anchor = 'b'; anchor <= 'e'; anchor++) + { + yaml.AppendLine($"{anchor}: &{anchor} [{string.Join(",", Enumerable.Repeat($"*{previousAnchor}", fanOut))}]"); + previousAnchor = anchor; + } + + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml.ToString()); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("expands aliases", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadReturnsDiagnosticForDeepNestingBeforeYamlDomComposition(bool flowStyle) + { + const int depth = 5_000; + var yaml = flowStyle + ? new string('[', depth) + new string(']', depth) + : string.Concat(Enumerable.Repeat("- ", depth)) + "value"; + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum nesting depth", StringComparison.Ordinal)); + } + + [Fact] + public void ReadHonoursMaxDepthAboveTheUnderlyingParserDefault() + { + // SharpYaml applies its own nesting limit, defaulting to 64. Unless the reader forwards + // MaxDepth to it, every configured value above that default silently has no effect. + const int depth = 100; + var yaml = new string('[', depth) + new string(']', depth); + var reader = new OpenApiYamlReader(new OpenApiYamlReaderSettings { MaxDepth = 200 }); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Message.Contains("nesting depth", StringComparison.Ordinal)); + } + + [Fact] + public void ReadHonoursMaxDepthBelowTheUnderlyingParserDefault() + { + const int depth = 40; + var yaml = new string('[', depth) + new string(']', depth); + var reader = new OpenApiYamlReader(new OpenApiYamlReaderSettings { MaxDepth = 32 }); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("nesting depth of 32", StringComparison.Ordinal)); + } + + [Fact] + public void ReadRejectsAliasExpansionThatExceedsMaxDepth() + { + // The anchor and the alias each sit within the depth limit, but expanding the alias grafts + // the anchored subtree onto an equally deep position, producing a tree twice as deep. The + // YAML parser cannot catch this because it sees the alias as a single event. + const int half = 50; + var yaml = + $"a: &d {new string('[', half)}{new string(']', half)}\n" + + $"b: {new string('[', half)}*d{new string(']', half)}\n"; + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("expands an alias", StringComparison.Ordinal)); + } + + [Fact] + public void ReadStopsAfterFirstYamlDocument() + { + const int depth = 5_000; + var yaml = + """ + openapi: 3.0.1 + info: + title: First document + version: 1.0.0 + paths: {} + --- + """ + + Environment.NewLine + + new string('[', depth) + + new string(']', depth); + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); + Assert.Equal("First document", result.Document.Info.Title); + Assert.Empty(result.Diagnostic.Errors); + } + + [Fact] + public void ReadReturnsDiagnosticForCyclicAlias() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream("x-cycle: &cycle [*cycle]"); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("forms a cycle", StringComparison.Ordinal)); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticForAliasExpansion() + { + var reader = new OpenApiYamlReader(new() + { + MaxAliasExpansionNodeCount = 1, + }); + using var stream = CreateStream( + """ + type: &value string + title: *value + description: *value + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + Assert.Single(diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, diagnostic.Format); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticWhenInputExceedsByteLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = 10, + }); + using var stream = CreateStream("type: string"); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + Assert.Contains(diagnostic.Errors, error => error.Message.Contains("maximum supported size", StringComparison.Ordinal)); + Assert.Equal(OpenApiConstants.Yaml, diagnostic.Format); + } + + [Fact] + public void ReadMeasuresOnlyTheRemainingBytesOfAPositionedStream() + { + const string padding = "##################################################"; + const string yaml = + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """; + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = (uint)Encoding.UTF8.GetByteCount(yaml), + }); + using var stream = CreateStream(padding + yaml); + stream.Position = padding.Length; + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); + Assert.Equal("Sample API", result.Document.Info.Title); + } + + [Fact] + public void ReadReturnsDiagnosticWhenInputExceedsByteLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = 10, + }); + using var stream = CreateStream("openapi: 3.0.1"); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported size", StringComparison.Ordinal)); + } + + [Fact] + public async Task ReadAsyncReturnsDiagnosticWhenNonMemoryStreamExceedsByteLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = 10, + }); + await using var stream = new NonMemoryStream(CreateStream("openapi: 3.0.1")); + + var result = await reader.ReadAsync( + stream, + DocumentLocation, + SettingsFixture.ReaderSettings, + CancellationToken.None); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported size", StringComparison.Ordinal)); + } + + [Fact] + public void ReadReturnsDiagnosticWhenScalarExceedsLengthLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxScalarLength = 3, + }); + using var stream = CreateStream("value: 1234"); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported length", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("a: 1\na: 2")] + [InlineData("? [1, 2]: value")] + [InlineData("[")] + [InlineData("# comment only")] + public void ReadReturnsDiagnosticForInvalidYaml(string yaml) + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.NotEmpty(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); + } + + [Fact] + public void ReaderCopiesPerInstanceSettings() + { + var yamlSettings = new OpenApiYamlReaderSettings + { + MaxAliasExpansionNodeCount = 1, + }; + var reader = new OpenApiYamlReader(yamlSettings); + yamlSettings.MaxAliasExpansionNodeCount = 100; + using var stream = CreateStream( + """ + type: &value string + title: *value + description: *value + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + Assert.Single(diagnostic.Errors); + } + + [Theory] + [InlineData(0u, 5_000_000u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(YamlConverter.MaximumAllowedDepth + 1, 5_000_000u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 0u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, YamlConverter.MaximumAllowedNodeCount + 1, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 5_000_000u, 0u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 5_000_000u, 5_000u, 0u, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 5_000_000u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, 0u)] + public void ReaderRejectsInvalidResourceLimits( + uint maxDepth, + uint maxNodeCount, + uint maxAliasExpansionNodeCount, + uint maxInputByteCount, + uint maxScalarLength) + { + var settings = new OpenApiYamlReaderSettings + { + MaxDepth = maxDepth, + MaxNodeCount = maxNodeCount, + MaxAliasExpansionNodeCount = maxAliasExpansionNodeCount, + MaxInputByteCount = maxInputByteCount, + MaxScalarLength = maxScalarLength, + }; + + Assert.Throws(() => new OpenApiYamlReader(settings)); + } + + [Fact] + public void ReaderLimitsDefaultToDocumentedValues() + { + Assert.Equal(134_217_728u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount); + Assert.Equal(65_536u, OpenApiYamlReaderSettings.DefaultMaxScalarLength); + + var settings = new OpenApiYamlReaderSettings(); + + Assert.Equal(YamlConverter.DefaultMaxDepth, settings.MaxDepth); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, settings.MaxNodeCount); + Assert.Equal(YamlConverter.DefaultMaxAliasExpansionNodeCount, settings.MaxAliasExpansionNodeCount); + Assert.Equal(OpenApiYamlReaderSettings.DefaultMaxInputByteCount, settings.MaxInputByteCount); + Assert.Equal(OpenApiYamlReaderSettings.DefaultMaxScalarLength, settings.MaxScalarLength); + } + + [Fact] + public void ReaderAcceptsMaxNodeCountAtTheSafeCeiling() + { + var settings = new OpenApiYamlReaderSettings + { + MaxNodeCount = YamlConverter.MaximumAllowedNodeCount, + }; + var reader = new OpenApiYamlReader(settings); + using var stream = CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); + Assert.Equal("Sample API", result.Document.Info.Title); + } + private static MemoryStream CreateStream(string yaml) { return new MemoryStream(Encoding.UTF8.GetBytes(yaml)); @@ -108,4 +550,32 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + + private sealed class CancelingMemoryStream(byte[] buffer, CancellationTokenSource cancellationSource) : MemoryStream(buffer) + { + private bool _canceled; + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = base.Read(buffer, offset, count); + CancelAfterFirstRead(); + return bytesRead; + } + + public override int Read(Span buffer) + { + var bytesRead = base.Read(buffer); + CancelAfterFirstRead(); + return bytesRead; + } + + private void CancelAfterFirstRead() + { + if (!_canceled) + { + _canceled = true; + cancellationSource.Cancel(); + } + } + } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/ReferenceService/TryLoadReferenceV2Tests.cs b/test/Microsoft.OpenApi.Readers.Tests/ReferenceService/TryLoadReferenceV2Tests.cs index de41cd81c..b175d333d 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/ReferenceService/TryLoadReferenceV2Tests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/ReferenceService/TryLoadReferenceV2Tests.cs @@ -16,7 +16,7 @@ public class TryLoadReferenceV2Tests public async Task LoadParameterReference() { // Arrange - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var reference = new OpenApiParameterReference("skipParam", result.Document); // Assert @@ -41,7 +41,7 @@ public async Task LoadParameterReference() [Fact] public async Task LoadSecuritySchemeReference() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var reference = new OpenApiSecuritySchemeReference("api_key_sample", result.Document); @@ -59,7 +59,7 @@ public async Task LoadSecuritySchemeReference() [Fact] public async Task LoadResponseReference() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var reference = new OpenApiResponseReference("NotFound", result.Document); @@ -80,7 +80,7 @@ public async Task LoadResponseReference() [Fact] public async Task LoadResponseAndSchemaReference() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "multipleReferences.v2.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var reference = new OpenApiResponseReference("GeneralError", result.Document); var expected = new OpenApiResponse diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/ComparisonTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/ComparisonTests.cs index 32b8a4475..db63cbe9c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/ComparisonTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/ComparisonTests.cs @@ -3,8 +3,8 @@ using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Tests; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V2Tests @@ -22,13 +22,12 @@ public async Task EquivalentV2AndV3DocumentsShouldProduceEquivalentObjects(strin { var settings = new OpenApiReaderSettings(); settings.AddYamlReader(); - using var streamV2 = Resources.GetStream(Path.Combine(SampleFolderPath, $"{fileName}.v2.yaml")); - using var streamV3 = Resources.GetStream(Path.Combine(SampleFolderPath, $"{fileName}.v3.yaml")); - var result1 = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, $"{fileName}.v2.yaml"), SettingsFixture.ReaderSettings); - var result2 = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, $"{fileName}.v3.yaml"), SettingsFixture.ReaderSettings); + using var streamV2 = Resources.GetStream(Path.Join(SampleFolderPath, $"{fileName}.v2.yaml")); + using var streamV3 = Resources.GetStream(Path.Join(SampleFolderPath, $"{fileName}.v3.yaml")); + var result1 = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, $"{fileName}.v2.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); + var result2 = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, $"{fileName}.v3.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); - result2.Document.Should().BeEquivalentTo(result1.Document, - options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(result1.Document, result2.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent(result2.Diagnostic.Errors, result1.Diagnostic.Errors); } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs index d7368a83a..9a85d6898 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs @@ -7,8 +7,8 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Tests; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V2Tests @@ -67,7 +67,7 @@ public void ParseDocumentWithDifferentCultureShouldSucceed(string culture) [Fact] public async Task ShouldParseProducesInAnyOrder() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "twoResponses.json")); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "twoResponses.json"), token: TestContext.Current.CancellationToken); var okSchema = new OpenApiSchema { @@ -119,7 +119,7 @@ public async Task ShouldParseProducesInAnyOrder() Schema = new OpenApiSchemaReference("Error", result.Document) }; - result.Document.Should().BeEquivalentTo(new OpenApiDocument + OpenApiTestAssert.Equivalent(new OpenApiDocument { Info = new() { @@ -220,14 +220,14 @@ public async Task ShouldParseProducesInAnyOrder() ["Error"] = errorSchema } } - }, options => options.Excluding(x => x.BaseUri)); + }, result.Document, nameof(OpenApiDocument.BaseUri)); } [Fact] public async Task ShouldAssignSchemaToAllResponses() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "multipleProduces.json")); - var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Json); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "multipleProduces.json")); + var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Json, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(OpenApiSpecVersion.OpenApi2_0, result.Diagnostic.SpecificationVersion); @@ -257,7 +257,7 @@ public async Task ShouldAssignSchemaToAllResponses() public async Task ShouldAllowComponentsThatJustContainAReference() { // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "ComponentRootReference.json"))).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "ComponentRootReference.json"), token: TestContext.Current.CancellationToken)).Document; var schema1 = actual.Components.Schemas["AllPets"]; var schema1Reference = Assert.IsType(schema1); Assert.False(schema1Reference.UnresolvedReference); @@ -279,7 +279,7 @@ public async Task ParseDocumentWithDefaultContentTypeSettingShouldSucceed() }; settings.AddYamlReader(); - var actual = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithEmptyProduces.yaml"), settings); + var actual = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithEmptyProduces.yaml"), settings, token: TestContext.Current.CancellationToken); var mediaType = actual.Document.Paths["/example"].Operations[HttpMethod.Get].Responses["200"].Content; Assert.Contains("application/json", mediaType); } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiHeaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiHeaderTests.cs index b35344a55..965ea9c7e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiHeaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiHeaderTests.cs @@ -3,10 +3,9 @@ using System.Text.Json.Nodes; using System.IO; -using FluentAssertions; -using FluentAssertions.Equivalency; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V2; +using Microsoft.OpenApi.Tests; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V2Tests @@ -21,7 +20,7 @@ public void ParseHeaderWithDefaultShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithDefault.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithDefault.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -30,7 +29,7 @@ public void ParseHeaderWithDefaultShouldSucceed() var header = OpenApiV2Deserializer.LoadHeader(node, new(), new ParsingContext(new())); // Assert - header.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiHeader { Schema = new OpenApiSchema() @@ -40,9 +39,8 @@ public void ParseHeaderWithDefaultShouldSucceed() Default = new JsonNodeExtension(5).Node } }, - options => options - .IgnoringCyclicReferences() - .Excluding(x => x.Schema.Default.Parent)); + header, + nameof(JsonNode.Parent)); } [Fact] @@ -50,7 +48,7 @@ public void ParseHeaderWithEnumShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithEnum.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithEnum.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -59,7 +57,7 @@ public void ParseHeaderWithEnumShouldSucceed() var header = OpenApiV2Deserializer.LoadHeader(node, new(), new ParsingContext(new())); // Assert - header.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiHeader { Schema = new OpenApiSchema() @@ -73,9 +71,7 @@ public void ParseHeaderWithEnumShouldSucceed() new JsonNodeExtension(9).Node ] } - }, options => options.IgnoringCyclicReferences() - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Parent"))); + }, header, nameof(JsonNode.Parent)); } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiOperationTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiOperationTests.cs index 92d50c42e..81ddafd0c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiOperationTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiOperationTests.cs @@ -7,7 +7,6 @@ using System.Text; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V2; using Microsoft.OpenApi.Reader.V3; @@ -121,7 +120,7 @@ public void ParseBasicOperationShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicOperation.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicOperation.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -139,7 +138,7 @@ public async Task ParseBasicOperationTwiceShouldYieldSameObject() // Arrange JsonNode node; using (var stream = new MemoryStream( - Encoding.Default.GetBytes(await _basicOperation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0)))) + Encoding.Default.GetBytes(await _basicOperation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken)))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -156,7 +155,7 @@ public void ParseOperationWithBodyShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "operationWithBody.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "operationWithBody.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -165,7 +164,7 @@ public void ParseOperationWithBodyShouldSucceed() var operation = OpenApiV2Deserializer.LoadOperation(node, new(), new ParsingContext(new())); // Assert - operation.Should().BeEquivalentTo(_operationWithBody, options => options.IgnoringCyclicReferences()); + OpenApiTestAssert.Equivalent(_operationWithBody, operation); } [Fact] @@ -174,7 +173,7 @@ public async Task ParseOperationWithBodyTwiceShouldYieldSameObject() // Arrange JsonNode node; using (var stream = new MemoryStream( - Encoding.Default.GetBytes(await _operationWithBody.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0)))) + Encoding.Default.GetBytes(await _operationWithBody.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken)))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -183,7 +182,7 @@ public async Task ParseOperationWithBodyTwiceShouldYieldSameObject() var operation = OpenApiV2Deserializer.LoadOperation(node, new(), new ParsingContext(new())); // Assert - operation.Should().BeEquivalentTo(_operationWithBody, options => options.IgnoringCyclicReferences()); + OpenApiTestAssert.Equivalent(_operationWithBody, operation); } [Fact] @@ -191,7 +190,7 @@ public void ParseOperationWithResponseExamplesShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "operationWithResponseExamples.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "operationWithResponseExamples.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -200,7 +199,7 @@ public void ParseOperationWithResponseExamplesShouldSucceed() var operation = OpenApiV2Deserializer.LoadOperation(node, new(), new ParsingContext(new())); // Assert - operation.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiOperation() { Responses = new OpenApiResponses() @@ -243,13 +242,7 @@ public void ParseOperationWithResponseExamplesShouldSucceed() } }} } - }, options => options.IgnoringCyclicReferences() - .Excluding(o => o.Responses["200"].Content["application/json"].Example[0].Parent) - .Excluding(o => o.Responses["200"].Content["application/json"].Example[0].Root) - .Excluding(o => o.Responses["200"].Content["application/json"].Example[1].Parent) - .Excluding(o => o.Responses["200"].Content["application/json"].Example[1].Root) - .Excluding(o => o.Responses["200"].Content["application/json"].Example[2].Parent) - .Excluding(o => o.Responses["200"].Content["application/json"].Example[2].Root)); + }, operation, nameof(JsonNode.Parent), nameof(JsonNode.Root)); } [Fact] @@ -257,7 +250,7 @@ public void ParseOperationWithEmptyProducesArraySetsResponseSchemaIfExists() { // Arrange JsonNode node; - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "operationWithEmptyProducesArrayInResponse.json")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "operationWithEmptyProducesArrayInResponse.json")); node = TestHelper.CreateYamlJsonNode(stream); // Act @@ -293,14 +286,14 @@ public void ParseOperationWithBodyAndEmptyConsumesSetsRequestBodySchemaIfExists( { // Arrange JsonNode node; - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "operationWithBodyAndEmptyConsumes.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "operationWithBodyAndEmptyConsumes.yaml")); node = TestHelper.CreateYamlJsonNode(stream); // Act var operation = OpenApiV2Deserializer.LoadOperation(node, new(), new ParsingContext(new())); // Assert - operation.Should().BeEquivalentTo(_operationWithBody, options => options.IgnoringCyclicReferences()); + OpenApiTestAssert.Equivalent(_operationWithBody, operation); } [Fact] @@ -308,14 +301,14 @@ public async Task ParseV2ResponseWithExamplesExtensionWorks() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "opWithResponseExamplesExtension.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "opWithResponseExamplesExtension.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var operation = OpenApiV2Deserializer.LoadOperation(node, new(), new ParsingContext(new())); - var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert var expected = @"summary: Get all pets @@ -358,14 +351,14 @@ public async Task LoadV3ExamplesInResponseAsExtensionsWorks() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "v3OperationWithResponseExamples.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "v3OperationWithResponseExamples.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var operation = OpenApiV3Deserializer.LoadOperation(node, new(), new ParsingContext(new())); - var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert var expected = @"summary: Get all pets @@ -408,14 +401,14 @@ public async Task LoadV2OperationWithBodyParameterExamplesWorks() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "opWithBodyParameterExamples.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "opWithBodyParameterExamples.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var operation = OpenApiV2Deserializer.LoadOperation(node, new(), new ParsingContext(new())); - var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert var expected = @"summary: Get all pets @@ -459,14 +452,14 @@ public async Task LoadV3ExamplesInRequestBodyParameterAsExtensionsWorks() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "v3OperationWithBodyParameterExamples.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "v3OperationWithBodyParameterExamples.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var operation = OpenApiV3Deserializer.LoadOperation(node, new(), new ParsingContext(new())); - var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await operation.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert var expected = @"summary: Get all pets @@ -551,7 +544,7 @@ public async Task SerializesBodyReferencesWorks() } }); - var actual = await openApiDocument.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await openApiDocument.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); var expected = """ { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiParameterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiParameterTests.cs index 43eefc5c0..7e7f15405 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiParameterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiParameterTests.cs @@ -3,8 +3,6 @@ // Licensed under the MIT license. using System.IO; -using FluentAssertions; -using FluentAssertions.Equivalency; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V2; using Microsoft.OpenApi.Tests; @@ -22,7 +20,7 @@ public void ParseBodyParameterShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "bodyParameter.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "bodyParameter.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -41,7 +39,7 @@ public void ParsePathParameterShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "pathParameter.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "pathParameter.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -70,7 +68,7 @@ public void ParseQueryParameterShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "queryParameter.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "queryParameter.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -105,7 +103,7 @@ public void ParseParameterWithNullLocationShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithNullLocation.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithNullLocation.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -134,7 +132,7 @@ public void ParseParameterWithNoLocationShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithNoLocation.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithNoLocation.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -163,7 +161,7 @@ public void ParseParameterWithNoSchemaShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithNoSchema.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithNoSchema.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -188,7 +186,7 @@ public void ParseParameterWithUnknownLocationShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithUnknownLocation.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithUnknownLocation.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -217,7 +215,7 @@ public void ParseParameterWithDefaultShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithDefault.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithDefault.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -226,7 +224,7 @@ public void ParseParameterWithDefaultShouldSucceed() var parameter = OpenApiV2Deserializer.LoadParameter(node, new(), new ParsingContext(new())); // Assert - parameter.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiParameter { In = ParameterLocation.Path, @@ -239,7 +237,7 @@ public void ParseParameterWithDefaultShouldSucceed() Format = "float", Default = new JsonNodeExtension(5).Node } - }, options => options.IgnoringCyclicReferences().Excluding(x => x.Schema.Default.Parent)); + }, parameter, nameof(JsonNode.Parent)); } [Fact] @@ -247,7 +245,7 @@ public void ParseParameterWithEnumShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithEnum.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithEnum.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -274,10 +272,7 @@ public void ParseParameterWithEnumShouldSucceed() }; // Assert - parameter.Should().BeEquivalentTo(expected, options => options - .IgnoringCyclicReferences() - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Parent"))); + OpenApiTestAssert.Equivalent(expected, parameter, nameof(JsonNode.Parent)); } [Fact] @@ -290,7 +285,7 @@ public void ParseFormDataParameterShouldSucceed() ""format"": ""binary"" }"; JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "formDataParameter.json"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "formDataParameter.json"))) { node = TestHelper.CreateYamlJsonNode(stream); } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiPathItemTests.cs index e8b340bbb..cf9e43272 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiPathItemTests.cs @@ -250,7 +250,7 @@ public class OpenApiPathItemTests public void ParseBasicPathItemWithFormDataShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicPathItemWithFormData.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicPathItemWithFormData.yaml")); var node = TestHelper.CreateYamlJsonNode(stream); // Act @@ -265,7 +265,7 @@ public void ParsePathItemWithFormDataPathParameterShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "pathItemWithFormDataPathParameter.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "pathItemWithFormDataPathParameter.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -284,7 +284,7 @@ public void ParsePathItemBodyDataPathParameterShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "pathItemWithBodyPathParameter.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "pathItemWithBodyPathParameter.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs index 291e3978a..cbbde4b70 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs @@ -1,17 +1,17 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; using System.IO; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Equivalency; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V2; using Microsoft.OpenApi.Tests; using Xunit; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Readers.Tests.V2Tests { [Collection("DefaultSettings")] @@ -24,7 +24,7 @@ public void ParseSchemaWithDefaultShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "schemaWithDefault.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "schemaWithDefault.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -33,12 +33,12 @@ public void ParseSchemaWithDefaultShouldSucceed() var schema = OpenApiV2Deserializer.LoadSchema(node, new(), new ParsingContext(new())); // Assert - schema.Should().BeEquivalentTo(new OpenApiSchema + OpenApiTestAssert.Equivalent(new OpenApiSchema { Type = JsonSchemaType.Number, Format = "float", Default = 5 - }, options => options.IgnoringCyclicReferences().Excluding(x => x.Default.Parent)); + }, schema, nameof(JsonNode.Parent)); } [Fact] @@ -46,7 +46,7 @@ public void ParseSchemaWithExampleShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "schemaWithExample.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "schemaWithExample.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -55,13 +55,13 @@ public void ParseSchemaWithExampleShouldSucceed() var schema = OpenApiV2Deserializer.LoadSchema(node, new(), new ParsingContext(new())); // Assert - schema.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiSchema { Type = JsonSchemaType.Number, Format = "float", Example = 5 - }, options => options.IgnoringCyclicReferences().Excluding(x => x.Example.Parent)); + }, schema, nameof(JsonNode.Parent)); } [Fact] @@ -69,7 +69,7 @@ public void ParseSchemaWithEnumShouldSucceed() { // Arrange JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "schemaWithEnum.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "schemaWithEnum.yaml"))) { node = TestHelper.CreateYamlJsonNode(stream); } @@ -90,10 +90,7 @@ public void ParseSchemaWithEnumShouldSucceed() ] }; - schema.Should().BeEquivalentTo(expected, options => - options.IgnoringCyclicReferences() - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Parent"))); + OpenApiTestAssert.Equivalent(expected, schema, nameof(JsonNode.Parent)); } [Fact] @@ -158,10 +155,10 @@ public async Task SerializeSchemaWithNullableShouldSucceed() var expected = @"type: string x-nullable: true"; - var path = Path.Combine(SampleFolderPath, "schemaWithNullableExtension.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithNullableExtension.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV2(new OpenApiYamlWriter(writer)); @@ -173,13 +170,15 @@ public async Task SerializeSchemaWithNullableShouldSucceed() [Fact] public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { + // NOTE: x-nullable extension has no effect on the schema if type is not specified, so it is omitted in the serialized output. + // Arrange - var expected = @"x-nullable: true"; + var expected = @"{ }"; - var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullableExtension.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithOnlyNullableExtension.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV2(new OpenApiYamlWriter(writer)); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecuritySchemeTests.cs index e108774f1..7289f272e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecuritySchemeTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.Json.Nodes; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V2; using Microsoft.OpenApi.YamlReader; @@ -21,7 +22,7 @@ public class OpenApiSecuritySchemeTests public void ParseHttpSecuritySchemeShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicSecurityScheme.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicSecurityScheme.yaml")); var document = LoadYamlDocument(stream); var diagnostic = new OpenApiDiagnostic(); @@ -46,7 +47,7 @@ public void ParseHttpSecuritySchemeShouldSucceed() public void ParseApiKeySecuritySchemeShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "apiKeySecurityScheme.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "apiKeySecurityScheme.yaml")); var document = LoadYamlDocument(stream); var diagnostic = new OpenApiDiagnostic(); var context = new ParsingContext(diagnostic); @@ -71,7 +72,7 @@ public void ParseApiKeySecuritySchemeShouldSucceed() [Fact] public void ParseOAuth2ImplicitSecuritySchemeShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "oauth2ImplicitSecurityScheme.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "oauth2ImplicitSecurityScheme.yaml")); var document = LoadYamlDocument(stream); var diagnostic = new OpenApiDiagnostic(); var context = new ParsingContext(diagnostic); @@ -107,7 +108,7 @@ public void ParseOAuth2ImplicitSecuritySchemeShouldSucceed() public void ParseOAuth2PasswordSecuritySchemeShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "oauth2PasswordSecurityScheme.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "oauth2PasswordSecurityScheme.yaml")); var document = LoadYamlDocument(stream); var diagnostic = new OpenApiDiagnostic(); var context = new ParsingContext(diagnostic); @@ -142,7 +143,7 @@ public void ParseOAuth2PasswordSecuritySchemeShouldSucceed() public void ParseOAuth2ApplicationSecuritySchemeShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "oauth2ApplicationSecurityScheme.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "oauth2ApplicationSecurityScheme.yaml")); var document = LoadYamlDocument(stream); var diagnostic = new OpenApiDiagnostic(); var context = new ParsingContext(diagnostic); @@ -177,7 +178,7 @@ public void ParseOAuth2ApplicationSecuritySchemeShouldSucceed() public void ParseOAuth2AccessCodeSecuritySchemeShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "oauth2AccessCodeSecurityScheme.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "oauth2AccessCodeSecurityScheme.yaml")); var document = LoadYamlDocument(stream); var diagnostic = new OpenApiDiagnostic(); @@ -209,6 +210,29 @@ public void ParseOAuth2AccessCodeSecuritySchemeShouldSucceed() }, securityScheme); } + [Fact] + public void ParseOAuth2SecuritySchemeWithMetadataUrlExtensionShouldSucceed() + { + var json = + """ + { + "type": "oauth2", + "flow": "application", + "tokenUrl": "https://idp.example.com/oauth/token", + "scopes": { + "scope:one": "Scope one" + }, + "x-oai-oauth2-metadata-url": "https://idp.example.com/.well-known/oauth-authorization-server" + } + """; + + var securityScheme = Assert.IsType( + OpenApiV2Deserializer.LoadSecurityScheme(JsonNode.Parse(json)!, new(), new ParsingContext(new()))); + + Assert.Equal(new Uri("https://idp.example.com/.well-known/oauth-authorization-server"), securityScheme.OAuth2MetadataUrl); + Assert.True(securityScheme.Extensions is null || !securityScheme.Extensions.ContainsKey(OpenApiConstants.OAuth2MetadataUrlExtension)); + } + static YamlDocument LoadYamlDocument(Stream input) { using var reader = new StreamReader(input); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs index 1abb7f7a9..854afad9e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs @@ -18,10 +18,10 @@ public class OpenApiDiscriminatorTests public async Task ParseBasicDiscriminatorShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicDiscriminator.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicDiscriminator.yaml")); // Copy stream to MemoryStream using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream); + await stream.CopyToAsync(memoryStream, TestContext.Current.CancellationToken); memoryStream.Position = 0; // Act diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests .cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests.cs similarity index 91% rename from test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests .cs rename to test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests.cs index dbfe04a0a..afb27144c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests .cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests.cs @@ -21,15 +21,16 @@ public class OpenApiDocumentSerializationTests public async Task Serialize_DoesNotMutateDom(OpenApiSpecVersion version) { // Arrange - var filePath = Path.Combine(SampleFolderPath, "docWith31properties.json"); - var (doc, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + var filePath = Path.Join(SampleFolderPath, "docWith31properties.json"); + var (doc, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Act: Serialize using System.Text.Json var options = new JsonSerializerOptions { Converters = { - new HttpMethodOperationDictionaryConverter() + new HttpMethodOperationDictionaryConverter(), + new OpenApiSchemaInterfaceJsonConverter(static (w, s) => s.SerializeAsV31(w)) }, }; var originalSerialized = JsonSerializer.Serialize(doc, options); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs index 8acf84d09..412037d52 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs @@ -2,7 +2,6 @@ using System.Globalization; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Tests; using Xunit; @@ -10,17 +9,53 @@ using System; using System.Net.Http; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Readers.Tests.V31Tests { public class OpenApiDocumentTests { private const string SampleFolderPath = "V31Tests/Samples/OpenApiDocument/"; + [Fact] + public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic() + { + var result = OpenApiDocument.Parse( + """ + openapi: 3.1.1 + info: + title: test + version: 1.0.0 + paths: + /test: + get: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#components/schemas/Item' + components: + schemas: + Item: + type: object + properties: + id: + type: integer + """, + OpenApiConstants.Yaml, + SettingsFixture.ReaderSettings); + + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message); + } + [Fact] public async Task ParseDocumentWithWebhooksShouldSucceed() { // Arrange and Act - var actual = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "documentWithWebhooks.yaml"), SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "documentWithWebhooks.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var petSchema = new OpenApiSchemaReference("petSchema", actual.Document); var newPetSchema = new OpenApiSchemaReference("newPetSchema", actual.Document); @@ -208,14 +243,14 @@ public async Task ParseDocumentWithWebhooksShouldSucceed() // Assert Assert.Equivalent(new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_1, Format = OpenApiConstants.Yaml }, actual.Diagnostic); - actual.Document.Should().BeEquivalentTo(expected, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(expected, actual.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); } [Fact] public async Task ParseDocumentsWithReusablePathItemInWebhooksSucceeds() { // Arrange && Act - var actual = await OpenApiDocument.LoadAsync("V31Tests/Samples/OpenApiDocument/documentWithReusablePaths.yaml", SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync("V31Tests/Samples/OpenApiDocument/documentWithReusablePaths.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var components = new OpenApiComponents { @@ -410,9 +445,7 @@ public async Task ParseDocumentsWithReusablePathItemInWebhooksSucceeds() }; // Assert - actual.Document.Should().BeEquivalentTo(expected, options => options - .Excluding(x => x.Workspace) - .Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(expected, actual.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent( new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_1, Format = OpenApiConstants.Yaml }, actual.Diagnostic); } @@ -425,7 +458,7 @@ public async Task ParseDocumentWithExampleInSchemaShouldSucceed() var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = false }); // Act - var actual = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithExample.yaml"), SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithExample.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); actual.Document.SerializeAsV31(writer); // Assert @@ -436,7 +469,7 @@ public async Task ParseDocumentWithExampleInSchemaShouldSucceed() public async Task ParseDocumentWithPatternPropertiesInSchemaWorks() { // Arrange and Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithPatternPropertiesInSchema.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithPatternPropertiesInSchema.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var actualSchema = result.Document.Paths["/example"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; var expectedSchema = new OpenApiSchema @@ -482,7 +515,7 @@ public async Task ParseDocumentWithPatternPropertiesInSchemaWorks() prop3: type: string"; - var actualMediaType = await mediaType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actualMediaType = await mediaType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.Equivalent(expectedSchema, actualSchema); @@ -493,7 +526,7 @@ public async Task ParseDocumentWithPatternPropertiesInSchemaWorks() public async Task ParseDocumentWithReferenceByIdGetsResolved() { // Arrange and Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithReferenceById.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithReferenceById.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var responseSchema = result.Document.Paths["/resource"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; var requestBodySchema = result.Document.Paths["/resource"].Operations[HttpMethod.Post].RequestBody.Content["application/json"].Schema; @@ -510,7 +543,7 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo { // Arrange var documentName = "externalRefByJsonPointer.yaml"; - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath, documentName); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath, documentName); var settings = new OpenApiReaderSettings { @@ -520,13 +553,13 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo settings.AddYamlReader(); // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, documentName), settings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, documentName), settings, token: TestContext.Current.CancellationToken); var responseSchema = result.Document.Paths["/resource"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert var externalResourceUri = new Uri( "file://" + - Path.Combine(Path.GetFullPath(SampleFolderPath), + Path.Join(Path.GetFullPath(SampleFolderPath), "externalResource.yaml#/components/schemas/todo")).AbsoluteUri; Assert.True(result.Document.Workspace.Contains(externalResourceUri)); @@ -537,7 +570,7 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo public async Task ParseExternalDocumentDereferenceToOpenApiDocumentByIdWorks() { // Arrange - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { @@ -547,8 +580,8 @@ public async Task ParseExternalDocumentDereferenceToOpenApiDocumentByIdWorks() settings.AddYamlReader(); // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalRefById.yaml"), settings); - var doc2 = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalResource.yaml"), SettingsFixture.ReaderSettings)).Document; + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalRefById.yaml"), settings, token: TestContext.Current.CancellationToken); + var doc2 = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalResource.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var requestBodySchema = result.Document.Paths["/resource"].Operations[HttpMethod.Get].Parameters[0].Schema; result.Document.Workspace.RegisterComponents(doc2); @@ -560,8 +593,8 @@ public async Task ParseExternalDocumentDereferenceToOpenApiDocumentByIdWorks() [Fact] public async Task ParseDocumentWith31PropertiesWorks() { - var path = Path.Combine(SampleFolderPath, "documentWith31Properties.yaml"); - var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings)).Document; + var path = Path.Join(SampleFolderPath, "documentWith31Properties.yaml"); + var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var outputStringWriter = new StringWriter(); doc.SerializeAsV31(new OpenApiYamlWriter(outputStringWriter)); await outputStringWriter.FlushAsync(); @@ -574,16 +607,16 @@ public async Task ParseDocumentWith31PropertiesWorks() [Fact] public async Task ParseDocumentWithEmptyTagsWorks() { - var path = Path.Combine(SampleFolderPath, "documentWithEmptyTags.json"); - var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings)).Document; + var path = Path.Join(SampleFolderPath, "documentWithEmptyTags.json"); + var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; - doc.Paths["/groups"].Operations[HttpMethod.Get].Tags.Should().BeNull("Empty tags are ignored, so we should not have any tags"); + Assert.Null(doc.Paths["/groups"].Operations[HttpMethod.Get].Tags); } [Fact] public async Task DocumentWithSchemaResultsInWarning() { - var path = Path.Combine(SampleFolderPath, "documentWithSchema.json"); - var (doc, diag) = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "documentWithSchema.json"); + var (doc, diag) = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.NotNull(doc); Assert.NotNull(diag); Assert.Empty(diag.Errors); @@ -601,8 +634,8 @@ public void ParseEmptyMemoryStreamThrowsAnArgumentException() public async Task ValidateReferencedExampleInSchemaWorks() { // Arrange && Act - var path = Path.Combine(SampleFolderPath, "docWithReferencedExampleInSchemaWorks.yaml"); - var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "docWithReferencedExampleInSchemaWorks.yaml"); + var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var actualSchemaExample = result.Document.Components.Schemas["DiffCreatedEvent"].Properties["updatedAt"].Example; var targetSchemaExample = result.Document.Components.Schemas["Timestamp"].Example; @@ -616,8 +649,8 @@ public async Task ValidateReferencedExampleInSchemaWorks() public async Task ParseDocumentWithSelfExtensionWorks() { // Arrange && Act - var path = Path.Combine(SampleFolderPath, "documentWithSelfExtension.yaml"); - var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "documentWithSelfExtension.yaml"); + var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(result.Document); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs new file mode 100644 index 000000000..a862c971b --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs @@ -0,0 +1,1745 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Reader.V31; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V31Tests; + +public class OpenApiDynamicRefTests +{ + private static async Task LoadDocumentAsync(string yaml) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + return result.Document; + } + + [Fact] + public void BareDynamicRefDeserializesAsSchemaReference() + { + var json = + """ + { + "$dynamicRef": "#category" + } + """; + + var hostDocument = new OpenApiDocument(); + var jsonNode = JsonNode.Parse(json); + + var result = OpenApiV31Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new())); + + var reference = Assert.IsType(result); + Assert.Equal("#category", reference.Reference.DynamicRef); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Reference.ExternalResource); + } + + [Fact] + public void BareDynamicRefDoesNotEmitRefOnSerialization() + { + var json = + """ + { + "$dynamicRef": "#category" + } + """; + + var hostDocument = new OpenApiDocument(); + var jsonNode = JsonNode.Parse(json); + + var result = OpenApiV31Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new())); + + var sw = new StringWriter(); + var writer = new OpenApiJsonWriter(sw); + result.SerializeAsV31(writer); + + var output = sw.ToString(); + Assert.Contains("$dynamicRef", output); + Assert.DoesNotContain("$ref", output); + } + + [Fact] + public async Task DynamicRefResolvesToDynamicAnchorTarget() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + value: + type: string + children: + type: array + items: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var childrenItems = tree.Properties["children"].Items; + + var reference = Assert.IsType(childrenItems); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(tree, reference.Target); + } + + [Fact] + public async Task DynamicRefResolvesViaDefsAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + $defs: + node: + $dynamicAnchor: node + type: object + properties: + value: + type: string + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var root = doc.Components.Schemas["Root"]; + var nodeDef = root.Definitions["node"]; + var nextSchema = nodeDef.Properties["next"]; + + var reference = Assert.IsType(nextSchema); + Assert.NotNull(reference.Target); + Assert.Same(nodeDef, reference.Target); + } + + [Fact] + public async Task DynamicRefResolvesViaNestedPropertyAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + self: + $dynamicAnchor: node + type: object + properties: + value: + type: string + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var self = tree.Properties["self"]; + + var nextSchema = self.Properties["next"]; + var reference = Assert.IsType(nextSchema); + Assert.NotNull(reference.Target); + Assert.Same(self, reference.Target); + } + + [Fact] + public async Task DynamicRefResolvesViaAllOfAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + allOf: + - $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var root = doc.Components.Schemas["Root"]; + var branch = root.AllOf[0]; + var nextSchema = branch.Properties["next"]; + + var reference = Assert.IsType(nextSchema); + Assert.NotNull(reference.Target); + Assert.Same(branch, reference.Target); + } + + [Fact] + public async Task DynamicRefReturnsNullWhenAnchorIsAmbiguous() + { + // When a single document declares the same $dynamicAnchor name on more than one subschema, + // resolution cannot pick one without dynamic-scope evaluation, so Target returns null. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + type: object + properties: + a: + $dynamicAnchor: node + type: object + b: + $dynamicAnchor: node + type: object + ref: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var root = doc.Components.Schemas["Root"]; + var reference = Assert.IsType(root.Properties["ref"]); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicAnchorRegisteredAcrossAllSubschemaLocations() + { + // Exercises every subschema location the anchor walk descends into (oneOf, anyOf, not, + // items, additionalProperties, patternProperties, contains, propertyNames, contentSchema, + // if/then/else, dependentSchemas, unevaluatedPropertiesSchema). Each declares a distinct + // $dynamicAnchor name; a $dynamicRef to each confirms the walk reached it. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + type: object + oneOf: + - $dynamicAnchor: one + type: object + anyOf: + - $dynamicAnchor: any + type: object + allOf: + - type: object + properties: + nested: + type: array + items: + $dynamicAnchor: itm + type: string + dependentSchemas: + dep: + $dynamicAnchor: depn + type: object + not: + $dynamicAnchor: notn + type: object + contains: + $dynamicAnchor: cont + type: object + propertyNames: + $dynamicAnchor: pn + type: string + contentSchema: + $dynamicAnchor: cs + type: string + unevaluatedProperties: + $dynamicAnchor: up + type: object + patternProperties: + '^x': + $dynamicAnchor: pp + type: object + properties: + child: + $dynamicAnchor: ifn + type: object + additionalProperties: + $dynamicAnchor: ap + type: object + if: + $dynamicAnchor: iftop + type: object + then: + $dynamicAnchor: thentop + type: object + else: + $dynamicAnchor: elsetop + type: object + $defs: + consumer: + type: object + properties: + one: + $dynamicRef: '#one' + any: + $dynamicRef: '#any' + notn: + $dynamicRef: '#notn' + cont: + $dynamicRef: '#cont' + pn: + $dynamicRef: '#pn' + cs: + $dynamicRef: '#cs' + up: + $dynamicRef: '#up' + pp: + $dynamicRef: '#pp' + ifn: + $dynamicRef: '#ifn' + itm: + $dynamicRef: '#itm' + depn: + $dynamicRef: '#depn' + iftop: + $dynamicRef: '#iftop' + thentop: + $dynamicRef: '#thentop' + elsetop: + $dynamicRef: '#elsetop' + ap: + $dynamicRef: '#ap' + """; + + var doc = await LoadDocumentAsync(yaml); + var root = doc.Components.Schemas["Root"]; + var consumer = root.Definitions["consumer"].Properties; + + // Each anchor is unique within the document, so every resolution must succeed. + foreach (var name in new[] { "one", "any", "notn", "cont", "pn", "cs", "up", "pp", "ifn", "itm", "depn", "iftop", "thentop", "elsetop", "ap" }) + { + var reference = Assert.IsType(consumer[name]); + Assert.NotNull(reference.Target); + } + } + + [Fact] + public async Task DynamicAnchorInRefSiblingApplicatorsIsRegistered() + { + // A $ref schema may carry applicator siblings (allOf/oneOf/anyOf/properties/items/...) whose + // subschemas declare $dynamicAnchor. The anchor walk must descend into a reference holder's + // own siblings (read from JsonSchemaReference) to register them. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Base: + type: object + Referencing: + $ref: '#/components/schemas/Base' + oneOf: + - $dynamicAnchor: refOne + type: object + anyOf: + - $dynamicAnchor: refAny + type: object + properties: + child: + $dynamicAnchor: refChild + type: object + patternProperties: + '^x': + $dynamicAnchor: refPP + type: object + items: + $dynamicAnchor: refItem + type: string + dependentSchemas: + dep: + $dynamicAnchor: refDep + type: object + $defs: + consumer: + type: object + properties: + a: + $dynamicRef: '#refOne' + b: + $dynamicRef: '#refAny' + c: + $dynamicRef: '#refChild' + d: + $dynamicRef: '#refItem' + e: + $dynamicRef: '#refPP' + f: + $dynamicRef: '#refDep' + """; + + var doc = await LoadDocumentAsync(yaml); + var referencing = doc.Components.Schemas["Referencing"]; + var consumer = referencing.Definitions["consumer"].Properties; + + // Each $dynamicRef targets an anchor declared in a sibling applicator of the $ref schema. + foreach (var reference in consumer.Values.Select(v => Assert.IsType(v))) + { + Assert.NotNull(reference.Target); + } + } + + [Fact] + public async Task DynamicRefReturnsNullForUnknownAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: object + properties: + bar: + $dynamicRef: '#nonexistent' + """; + + var doc = await LoadDocumentAsync(yaml); + + var foo = doc.Components.Schemas["Foo"]; + var barSchema = foo.Properties["bar"]; + + var reference = Assert.IsType(barSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicRefRoundTripsThroughSerialization() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + children: + type: array + items: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var sw = new StringWriter(); + var writer = new OpenApiYamlWriter(sw); + doc.SerializeAsV31(writer); + var serialized = sw.ToString(); + + var doc2 = await LoadDocumentAsync(serialized); + + var tree2 = doc2.Components.Schemas["Tree"]; + var childrenItems2 = tree2.Properties["children"].Items; + var reference2 = Assert.IsType(childrenItems2); + Assert.True(reference2.Reference.IsDynamicRefOnly); + Assert.NotNull(reference2.Target); + } + + [Fact] + public async Task ExistingRefWithPathStillWorks() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: string + Bar: + $ref: '#/components/schemas/Foo' + """; + + var doc = await LoadDocumentAsync(yaml); + + var bar = doc.Components.Schemas["Bar"]; + var reference = Assert.IsType(bar); + Assert.False(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(doc.Components.Schemas["Foo"], reference.Target); + } + + [Fact] + public async Task DynamicRefWithSiblingsPreservesSiblings() + { + // A $dynamicRef alongside structural schema keywords must not drop the siblings. The object + // is parsed as an OpenApiSchemaReference with siblings preserved via ApplySchemaMetadata, + // surfaced through the Reference-first property getters. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: object + properties: + bar: + $dynamicRef: '#node' + maxProperties: 3 + description: a constrained dynamic ref + """; + + var doc = await LoadDocumentAsync(yaml); + + var foo = doc.Components.Schemas["Foo"]; + var bar = foo.Properties["bar"]; + + // Siblings preserved + Assert.Equal(3, bar.MaxProperties); + Assert.Equal("a constrained dynamic ref", bar.Description); + Assert.Equal("#node", bar.DynamicRef); + } + + [Fact] + public async Task AbsoluteDynamicRefResolvesToLocalAnchorWhenExternalNotLoaded() + { + // A URI-based $dynamicRef (https://example.com/external#node) targets an external resource. + // When the external document is not loaded in the workspace, resolution returns null + // rather than falling back to a local same-named anchor. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task AbsoluteDynamicRefResolvesAcrossDocuments() + { + // Document A references an anchor in Document B via a URI-based $dynamicRef. + // Both documents are in the same workspace. + var yamlA = + """ + openapi: 3.1.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var yamlB = + """ + openapi: 3.1.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main"); + docB.BaseUri = new("https://example.com/external"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + + [Fact] + public async Task DynamicAnchorResolvesPerDocumentInSharedWorkspace() + { + // Two documents in the same workspace each declare $dynamicAnchor: node (the conventional + // name for recursive tree schemas). Per JSON Schema 2020-12, dynamic scope is per-document, + // so each $dynamicRef must resolve to its own document's anchor, not return null as ambiguous. + var yaml = """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: '#node' + """; + + var docA = await LoadDocumentAsync(yaml); + var docB = await LoadDocumentAsync(yaml); + + var workspace = new OpenApiWorkspace(); + docA.Workspace = workspace; + docB.Workspace = workspace; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var treeA = docA.Components.Schemas["Tree"]; + var treeB = docB.Components.Schemas["Tree"]; + + var refA = Assert.IsType(treeA.Properties["next"]); + Assert.NotNull(refA.Target); + Assert.Same(treeA, refA.Target); + + var refB = Assert.IsType(treeB.Properties["next"]); + Assert.NotNull(refB.Target); + Assert.Same(treeB, refB.Target); + } + + [Fact] + public async Task DynamicAnchorInRefSiblingDefsIsRegistered() + { + // A $dynamicAnchor declared inside a $ref schema's $defs sibling must be registered, so a + // $dynamicRef elsewhere in the document resolves to it. (Main's model carries authored + // siblings on JsonSchemaReference, so the anchor walk must descend into them without + // following the $ref target.) + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Base: + type: object + Referencing: + $ref: '#/components/schemas/Base' + $defs: + node: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencing = doc.Components.Schemas["Referencing"]; + var nodeDef = referencing.Definitions["node"]; + var next = nodeDef.Properties["next"]; + + var reference = Assert.IsType(next); + Assert.NotNull(reference.Target); + Assert.Same(nodeDef, reference.Target); + } + + [Fact] + public async Task CreateShallowCopyPreservesValidationSiblings() + { + // CreateShallowCopy routes through the JsonSchemaReference copy constructor. Validation + // keyword siblings carried on a $ref schema (type, minProperties, pattern, allOf, etc.) + // must survive the copy, not just the JSON-Schema metadata siblings. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + type: object + minProperties: 2 + pattern: '^a' + allOf: + - type: object + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencing = doc.Components.Schemas["Referencing"]; + var copy = referencing.CreateShallowCopy(); + + Assert.IsType(copy); + Assert.Equal(JsonSchemaType.Object, copy.Type); + Assert.Equal(2, copy.MinProperties); + Assert.Equal("^a", copy.Pattern); + Assert.NotNull(copy.AllOf); + Assert.Single(copy.AllOf); + } + + [Fact] + public async Task InlineDynamicAnchorInResponseIsRegistered() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /endpoint-a: + get: + responses: + '200': + description: A + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/TypeA' + $ref: '#/components/schemas/Paged' + /endpoint-b: + get: + responses: + '200': + description: B + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/TypeB' + $ref: '#/components/schemas/Paged' + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + TypeA: + type: object + properties: + name: + type: string + TypeB: + type: object + properties: + title: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidates = doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType"); + Assert.Equal(2, candidates.Count); + + var paged = doc.Components.Schemas["Paged"]; + var itemsSchema = paged.Properties["content"].Items; + var itemsRef = Assert.IsType(itemsSchema); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.Null(itemsRef.Target); + + var endpointASchema = doc.Paths["/endpoint-a"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + var resolvedA = OpenApiWorkspace.ResolveDynamicAnchorInContext(endpointASchema, "itemType"); + Assert.NotNull(resolvedA); + var resolvedARef = Assert.IsType(resolvedA); + Assert.NotNull(resolvedARef.Target); + Assert.Same(doc.Components.Schemas["TypeA"], resolvedARef.Target); + + var endpointBSchema = doc.Paths["/endpoint-b"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + var resolvedB = OpenApiWorkspace.ResolveDynamicAnchorInContext(endpointBSchema, "itemType"); + Assert.NotNull(resolvedB); + var resolvedBRef = Assert.IsType(resolvedB); + Assert.NotNull(resolvedBRef.Target); + Assert.Same(doc.Components.Schemas["TypeB"], resolvedBRef.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableResponseIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested under components/responses must be + // registered just like one declared inline under paths, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + ItemType: + type: object + properties: + name: + type: string + responses: + ListResponse: + description: A paged list + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/ItemType' + $ref: '#/components/schemas/Paged' + """; + + var doc = await LoadDocumentAsync(yaml); + + // The anchor is reachable only via components/responses; before the fix it was never indexed. + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.NotNull(itemsRef.Target); + + var resolved = Assert.IsType(itemsRef.Target); + Assert.Same(doc.Components.Schemas["ItemType"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableParameterIsRegistered() + { + // A $dynamicAnchor declared inside a reusable parameter's schema must be registered. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Filter: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + parameters: + filterParam: + name: filter + in: query + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Filter' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var filter = doc.Components.Schemas["Filter"]; + var childRef = Assert.IsType(filter.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableHeaderIsRegistered() + { + // A $dynamicAnchor declared inside a reusable header's schema (components/headers) must + // be registered. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Cursor: + type: object + properties: + next: + $dynamicRef: '#page' + Page: + type: object + properties: + index: + type: integer + headers: + XCursor: + description: Cursor header + schema: + $defs: + page: + $dynamicAnchor: page + $ref: '#/components/schemas/Page' + $ref: '#/components/schemas/Cursor' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "page")); + + var cursor = doc.Components.Schemas["Cursor"]; + var nextRef = Assert.IsType(cursor.Properties["next"]); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + + var resolved = Assert.IsType(nextRef.Target); + Assert.Same(doc.Components.Schemas["Page"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInResponseHeaderSchemaIsRegistered() + { + // A $dynamicAnchor declared inside a response header's schema must be registered. This + // exercises the response.Headers leaf, which (like parameter.Content, header.Content and + // mediaType.ItemSchema) is a schema-bearing location that the anchor walk must cover. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /items: + get: + responses: + '200': + description: OK + headers: + X-Page: + description: Paging header + schema: + $defs: + meta: + $dynamicAnchor: meta + type: object + properties: + total: + type: integer + components: + schemas: + Body: + type: object + properties: + paging: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidate = Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "meta")); + + var body = doc.Components.Schemas["Body"]; + var pagingRef = Assert.IsType(body.Properties["paging"]); + Assert.True(pagingRef.Reference.IsDynamicRefOnly); + Assert.NotNull(pagingRef.Target); + Assert.Same(candidate, pagingRef.Target); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextReturnsNullForNoMatch() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + var foo = doc.Components.Schemas["Foo"]; + + var result = OpenApiWorkspace.ResolveDynamicAnchorInContext(foo, "nonexistent"); + Assert.Null(result); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceHolders() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $defs: + node: + $dynamicAnchor: node + type: object + properties: + name: + type: string + Referrer: + $ref: '#/components/schemas/Target' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referrer = doc.Components.Schemas["Referrer"]; + var refSchema = Assert.IsType(referrer); + + var result = OpenApiWorkspace.ResolveDynamicAnchorInContext(refSchema, "node"); + Assert.Null(result); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceDefsEntries() + { + // A $defs entry that is itself a $ref must contribute its own authored $dynamicAnchor, + // not an anchor declared on the referenced target. The "aliased" def points at a target + // that declares $dynamicAnchor: node, but the def itself does not author it, so the + // context-bound lookup must return null. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $dynamicAnchor: node + type: object + properties: + name: + type: string + Container: + type: object + $defs: + aliased: + $ref: '#/components/schemas/Target' + """; + + var doc = await LoadDocumentAsync(yaml); + + var container = doc.Components.Schemas["Container"]; + var aliased = Assert.IsType(container.Definitions!["aliased"]); + // Sanity: the fall-through property would surface the target's anchor... + Assert.Equal("node", aliased.DynamicAnchor); + // ...but the authored sibling on the def entry is empty. + Assert.Null(aliased.Reference.DynamicAnchor); + + // Both the non-reference context branch (Container) and the reference-holder branch + // (a ref whose Reference.Definitions carries the entry) must ignore the target's anchor. + Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(container, "node")); + + var refWithDefs = new OpenApiSchemaReference("Container", doc) + { + Reference = + { + Definitions = new Dictionary + { + ["aliased"] = aliased + } + } + }; + Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(refWithDefs, "node")); + } + + [Fact] + public async Task DynamicRefFallsBackToPlainAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $anchor: meta + type: object + properties: + value: + type: string + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var target = doc.Components.Schemas["Target"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(target, reference.Target); + } + + [Fact] + public async Task DynamicAnchorTakesPrecedenceOverPlainAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicTarget: + $dynamicAnchor: meta + type: object + properties: + dynamic: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var dynamicTarget = doc.Components.Schemas["DynamicTarget"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.NotNull(reference.Target); + Assert.Same(dynamicTarget, reference.Target); + } + + [Fact] + public async Task DynamicRefDoesNotFallBackToPlainAnchorWhenDynamicAnchorIsAmbiguous() + { + // Two $dynamicAnchor: meta declarations make the dynamic anchor ambiguous. Per §8.2.3.2, + // the spec requires the outermost $dynamicAnchor, which this library cannot compute, so + // Target must be null. The presence of a $anchor: meta must NOT trigger the plain-anchor + // fallback, since that would silently resolve to a different, incorrect target. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicA: + $dynamicAnchor: meta + type: object + properties: + a: + type: boolean + DynamicB: + $dynamicAnchor: meta + type: object + properties: + b: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicAnchorInInlineCallbackIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested in an inline operation callback must be + // registered, matching the treatment of reusable (components/callbacks) callbacks. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /register: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + callbacks: + onUpdate: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task CyclicCallbackDoesNotStackOverflowAndStillRegistersAnchors() + { + // A callback that references itself (via $ref) creates a structural cycle + // pathItem -> operation -> callback -> pathItem. The anchor walk must terminate and still + // register anchors declared inside the cycle. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + callbacks: + Loop: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + callbacks: + self: + $ref: '#/components/callbacks/Loop' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public void DynamicRefResolvesInObjectModelBuiltDocument() + { + var doc = new OpenApiDocument(); + var tree = new OpenApiSchema + { + DynamicAnchor = "node", + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["next"] = new OpenApiSchemaReference("node", doc) + { + Reference = { DynamicRef = "#node" } + } + } + }; + doc.AddComponent("Tree", tree); + doc.Workspace = new OpenApiWorkspace(); + doc.Workspace.RegisterComponents(doc); + + var nextRef = Assert.IsType(tree.Properties["next"]); + nextRef.Reference.SetJsonPointerPath("#node", "#/components/schemas/Tree/properties/next"); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + Assert.Same(tree, nextRef.Target); + } + + [Fact] + public async Task ExternalDynamicRefResolvesToPlainAnchorInExternalDocument() + { + // Document B has only $anchor (no $dynamicAnchor). FindDocumentByBaseUri must search + // both registries so the $anchor fallback works for URI-based $dynamicRef. + var yamlA = + """ + openapi: 3.1.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var yamlB = + """ + openapi: 3.1.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $anchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main"); + docB.BaseUri = new("https://example.com/external"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("https://example.com/external", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + + [Fact] + public async Task ExternalDynamicRefWithRelativeUriResolvesAcrossDocuments() + { + var yamlA = + """ + openapi: 3.1.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'external.yaml#node' + """; + + var yamlB = + """ + openapi: 3.1.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main.yaml"); + docB.BaseUri = new("https://example.com/external.yaml"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("external.yaml", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + + [Fact] + public async Task DynamicRefWithStructuralSiblingsRoundTripsThroughSerialization() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + children: + type: array + items: + $dynamicRef: '#node' + minProperties: 2 + description: constrained child + """; + + var doc = await LoadDocumentAsync(yaml); + + var sw = new StringWriter(); + var writer = new OpenApiYamlWriter(sw); + doc.SerializeAsV31(writer); + var serialized = sw.ToString(); + + var doc2 = await LoadDocumentAsync(serialized); + + var tree2 = doc2.Components.Schemas["Tree"]; + var items2 = tree2.Properties["children"].Items; + var ref2 = Assert.IsType(items2); + Assert.True(ref2.Reference.IsDynamicRefOnly); + Assert.NotNull(ref2.Target); + Assert.Same(tree2, ref2.Target); + Assert.Equal(2, ref2.MinProperties); + Assert.Equal("constrained child", ref2.Description); + } + + [Fact] + public async Task DynamicAnchorInReusableCallbackIsRegistered() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + value: + type: string + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + callbacks: + Webhook: + '{$request.body#/url}': + post: + requestBody: + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + $ref: '#/components/schemas/Paged' + responses: + '200': + description: ok + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.NotNull(itemsRef.Target); + } + + [Fact] + public async Task DynamicAnchorInReusablePathItemIsRegistered() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + value: + type: string + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + pathItems: + SharedPath: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + $ref: '#/components/schemas/Paged' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.NotNull(itemsRef.Target); + } + + [Fact] + public async Task InliningDynamicRefProducesResolvedTargetSchema() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + value: + type: string + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + + var inlined = reference.CopyReferenceAsTargetElementWithOverrides(next); + Assert.NotNull(inlined); + Assert.Equal(JsonSchemaType.Object, inlined.Type); + Assert.NotNull(inlined.Properties); + Assert.True(inlined.Properties.ContainsKey("value")); + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiEncodingTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiEncodingTests.cs index 6a03ea015..9ec9f9199 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiEncodingTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiEncodingTests.cs @@ -17,7 +17,7 @@ public class OpenApiEncodingTests public async Task ParseEncodingWithAllowReservedShouldSucceed() { // Act - var encoding = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "encodingWithAllowReserved.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "encodingWithAllowReserved.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiExampleTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiExampleTests.cs index 1f74aa4b3..7a2baacdb 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiExampleTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiExampleTests.cs @@ -19,10 +19,10 @@ public async Task ParseExampleWithDataValueExtensionShouldSucceed() { // Arrange & Act var example = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "exampleWithDataValue.yaml"), + Path.Join(SampleFolderPath, "exampleWithDataValue.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(example); @@ -37,10 +37,10 @@ public async Task ParseExampleWithSerializedValueExtensionShouldSucceed() { // Arrange & Act var example = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "exampleWithSerializedValue.yaml"), + Path.Join(SampleFolderPath, "exampleWithSerializedValue.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(example); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiHeaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiHeaderTests.cs index a15059ab5..41d65f610 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiHeaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiHeaderTests.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Xunit; @@ -19,10 +18,10 @@ public class OpenApiHeaderTests public async Task ParseBasicHeaderShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicHeader.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicHeader.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -40,10 +39,10 @@ public async Task ParseBasicHeaderShouldSucceed() public async Task ParseHeaderWithContentShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithContent.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -79,10 +78,10 @@ public async Task ParseHeaderWithContentShouldSucceed() public async Task ParseHeaderWithMultipleContentTypesShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithMultipleContentTypes.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithMultipleContentTypes.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -120,10 +119,10 @@ public async Task ParseHeaderWithMultipleContentTypesShouldSucceed() public async Task ParseHeaderWithStyleAndContentShouldPreferContent() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithStyleAndContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithStyleAndContent.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_1, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert // Both content and style can be present, content takes precedence for serialization behavior diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiInfoTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiInfoTests.cs index 1af2aca13..a1bcea365 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiInfoTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiInfoTests.cs @@ -15,7 +15,7 @@ public class OpenApiInfoTests [Fact] public void ParseBasicInfoShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicInfo.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicInfo.yaml")); var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); var yamlNode = yamlStream.Documents[0].RootNode; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiLicenseTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiLicenseTests.cs index 26d841811..33df2da48 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiLicenseTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiLicenseTests.cs @@ -18,7 +18,7 @@ public class OpenApiLicenseTests [Fact] public void ParseLicenseWithSpdxIdentifierShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "licenseWithSpdxIdentifier.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "licenseWithSpdxIdentifier.yaml")); var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); var yamlNode = yamlStream.Documents[0].RootNode; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiMediaTypeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiMediaTypeTests.cs index 39ea5420d..096627e08 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiMediaTypeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiMediaTypeTests.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V31; using Microsoft.OpenApi.Tests; @@ -22,10 +21,10 @@ public class OpenApiMediaTypeTests public async Task ParseMediaTypeWithExampleShouldSucceed() { // Act - var mediaType = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "mediaTypeWithExample.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var mediaType = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "mediaTypeWithExample.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - mediaType.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiMediaType { Example = 5, @@ -34,19 +33,17 @@ public async Task ParseMediaTypeWithExampleShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(m => m.Example.Parent) - ); + }, mediaType, nameof(JsonNode.Parent)); } [Fact] public async Task ParseMediaTypeWithExamplesShouldSucceed() { // Act - var mediaType = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "mediaTypeWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var mediaType = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "mediaTypeWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - mediaType.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiMediaType { Examples = new Dictionary @@ -65,9 +62,7 @@ public async Task ParseMediaTypeWithExamplesShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(m => m.Examples["example1"].Value.Parent) - .Excluding(m => m.Examples["example2"].Value.Parent)); + }, mediaType, nameof(JsonNode.Parent)); } [Fact] @@ -103,14 +98,14 @@ public async Task ParseMediaTypeWithEmptyArrayInExamplesWorks() } "; JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "examplesWithEmptyArray.json"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "examplesWithEmptyArray.json"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var mediaType = OpenApiV31Deserializer.LoadMediaType(node, new(), new ParsingContext(new())); - var serialized = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var serialized = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), serialized.MakeLineBreaksEnvironmentNeutral()); @@ -121,10 +116,10 @@ public async Task ParseMediaTypeWithXOaiItemSchemaShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithXOaiItemSchema.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithXOaiItemSchema.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert var expected = new OpenApiMediaType @@ -147,10 +142,10 @@ public async Task ParseMediaTypeWithXOaiItemEncodingShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithXOaiItemEncoding.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithXOaiItemEncoding.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(mediaType); @@ -166,10 +161,10 @@ public async Task ParseMediaTypeWithXOaiPrefixEncodingShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithXOaiPrefixEncoding.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithXOaiPrefixEncoding.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(mediaType); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs index 48de4d075..0257418a5 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs @@ -17,7 +17,7 @@ public class OpenApiPathItemDeserializerTests public async Task ExtraneousOperationsAreParsedAsExtensionsIn31() { // Arrange & Act - var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var pathItem = result.Document.Paths["/pets"]; // Assert diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index ec66dcbb9..caffb3e3e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -8,12 +8,12 @@ using System.Linq; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Equivalency; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Tests; using Xunit; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Readers.Tests.V31Tests { public class OpenApiSchemaTests @@ -23,7 +23,7 @@ public class OpenApiSchemaTests public static MemoryStream GetMemoryStream(string fileName) { - var filePath = Path.Combine(SampleFolderPath, fileName); + var filePath = Path.Join(SampleFolderPath, fileName); var fileBytes = File.ReadAllBytes(filePath); return new MemoryStream(fileBytes); } @@ -45,7 +45,13 @@ public async Task ParseBasicV31SchemaShouldSucceed() Items = new OpenApiSchema { Type = JsonSchemaType.String - } + }, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String + }, + MinContains = 1, + MaxContains = 5 }, ["vegetables"] = new OpenApiSchema { @@ -100,7 +106,7 @@ public async Task ParseBasicV31SchemaShouldSucceed() // Act var schema = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "jsonSchema.json"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + Path.Join(SampleFolderPath, "jsonSchema.json"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent(expectedObject, schema); @@ -135,13 +141,15 @@ public void ParseSchemaWithTypeArrayWorks() [Theory] [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")] [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")] - public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson) + public void ParseSchemaWithNullableBeforeOrAfterTypeDoesNotPreserveNullFlag(string schemaJson) { + // "nullable" is only for 3.0. + // Act var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_1, new(), out _, "json", SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -182,10 +190,10 @@ public void TestSchemaCopyConstructorWithTypeArrayWorks() [Fact] public async Task ParseV31SchemaShouldSucceed() { - var path = Path.Combine(SampleFolderPath, "schema.yaml"); + var path = Path.Join(SampleFolderPath, "schema.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expectedSchema = new OpenApiSchema { Type = JsonSchemaType.Object, @@ -207,8 +215,8 @@ public async Task ParseV31SchemaShouldSucceed() public async Task ParseAdvancedV31SchemaShouldSucceed() { // Arrange and Act - var path = Path.Combine(SampleFolderPath, "advancedSchema.yaml"); - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "advancedSchema.yaml"); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expectedSchema = new OpenApiSchema { @@ -282,10 +290,7 @@ public async Task ParseAdvancedV31SchemaShouldSucceed() }; // Assert - schema.Should().BeEquivalentTo(expectedSchema, options => options - .IgnoringCyclicReferences() - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Parent"))); + OpenApiTestAssert.Equivalent(expectedSchema, schema, nameof(JsonNode.Parent)); } [Fact] @@ -432,10 +437,10 @@ public async Task SerializeV31SchemaWithMultipleTypesAsV3Works() var expected = @"type: string nullable: true"; - var path = Path.Combine(SampleFolderPath, "schemaWithTypeArray.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithTypeArray.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV3(new OpenApiYamlWriter(writer)); @@ -451,10 +456,10 @@ public async Task SerializeV31SchemaWithMultipleTypesAsV2Works() var expected = @"type: string x-nullable: true"; - var path = Path.Combine(SampleFolderPath, "schemaWithTypeArray.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithTypeArray.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV2(new OpenApiYamlWriter(writer)); @@ -471,10 +476,10 @@ public async Task SerializeV3SchemaWithNullableAsV31Works() - 'null' - string"; - var path = Path.Combine(SampleFolderPath, "schemaWithNullable.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithNullable.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV31(new OpenApiYamlWriter(writer)); @@ -491,10 +496,10 @@ public async Task SerializeV2SchemaWithNullableExtensionAsV31Works() - 'null' - string"; - var path = Path.Combine(SampleFolderPath, "schemaWithNullableExtension.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithNullableExtension.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV31(new OpenApiYamlWriter(writer)); @@ -511,7 +516,7 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() - ""int"" nullable: true"; - var expected = @"x-nullable: true"; + var expected = @"{ }"; var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_1, new(), out _, "yaml", SettingsFixture.ReaderSettings); @@ -525,16 +530,18 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() [Theory] [InlineData("schemaWithNullable.yaml")] [InlineData("schemaWithNullableExtension.yaml")] - public async Task LoadSchemaWithNullableExtensionAsV31Works(string filePath) + public async Task LoadSchemaWithNullableExtensionAsV31ShouldNotWork(string filePath) { + // "nullable" is only for 3.0. + // and "x-nullable" is only for 2.0. // Arrange - var path = Path.Combine(SampleFolderPath, filePath); + var path = Path.Join(SampleFolderPath, filePath); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -568,10 +575,10 @@ public async Task SerializeSchemaWithJsonSchemaKeywordsWorks() $dynamicRef: '#addressDef' description: Schema for a person object "; - var path = Path.Combine(SampleFolderPath, "schemaWithJsonSchemaKeywords.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithJsonSchemaKeywords.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // serialization var writer = new StringWriter(); @@ -612,10 +619,10 @@ public async Task ParseSchemaWithConstWorks() } }"; - var path = Path.Combine(SampleFolderPath, "schemaWithConst.json"); + var path = Path.Join(SampleFolderPath, "schemaWithConst.json"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.Equal("active", schema.Properties["status"].Const); Assert.Equal("admin", schema.Properties["user"].Properties["role"].Const); @@ -626,6 +633,55 @@ public async Task ParseSchemaWithConstWorks() Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral()); } + [Fact] + public async Task ParseSchemaWithConstNullWorks() + { + var expected = @"{ + ""$schema"": ""https://json-schema.org/draft/2020-12/schema"", + ""required"": [ + ""status"" + ], + ""type"": ""object"", + ""properties"": { + ""status"": { + ""const"": null, + ""type"": ""string"" + }, + ""user"": { + ""required"": [ + ""role"" + ], + ""type"": ""object"", + ""properties"": { + ""role"": { + ""const"": null, + ""type"": ""string"" + } + } + } + } +}"; + + var path = Path.Join(SampleFolderPath, "schemaWithConstNull.json"); + + // Act + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); + + var statusSchema = Assert.IsType(schema.Properties["status"]); + Assert.Null(statusSchema.Const); + Assert.True(statusSchema.WasConstExplicitlySet); + + var userRoleSchema = Assert.IsType(schema.Properties["user"].Properties["role"]); + Assert.Null(userRoleSchema.Const); + Assert.True(userRoleSchema.WasConstExplicitlySet); + + // serialization + var writer = new StringWriter(); + schema.SerializeAsV31(new OpenApiJsonWriter(writer)); + var schemaString = writer.ToString(); + Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral()); + } + [Fact] public void ParseSchemaWithUnrecognizedKeywordsWorks() { @@ -857,6 +913,50 @@ public void ParseSchemaWithoutUnevaluatedPropertiesDefaultsToTrue() Assert.True(actual.UnevaluatedProperties); // Explicitly verify the default } + [Fact] + public void ParseSchemaWithMissingJsonSchemaProperties() + { + var schema = @"{ + ""$anchor"": ""root"", + ""contentEncoding"": ""base64"", + ""contentMediaType"": ""application/jwt"", + ""contentSchema"": { + ""type"": ""array"" + }, + ""propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""if"": { + ""required"": [""token""] + }, + ""then"": { + ""minProperties"": 1 + }, + ""else"": { + ""maxProperties"": 0 + } +}"; + + var actual = OpenApiModelFactory.Parse(schema, OpenApiSpecVersion.OpenApi3_1, new(), out _); + var missingProperties = Assert.IsAssignableFrom(actual); + + Assert.Equal("root", missingProperties.Anchor); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData("{}")] [InlineData("true")] @@ -885,5 +985,432 @@ public void DeserializeFalseSchemaParsesAsNotEmptySchema() Assert.Empty(schema.Not.AllOf ?? []); Assert.Empty(schema.Not.OneOf ?? []); } + + [Fact] + public async Task ParseSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + description: Sibling description + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert — siblings are preserved on the OpenApiSchemaReference + Assert.IsType(referencing); + Assert.Equal("Sibling description", referencing.Description); + Assert.Equal("anchor", referencing.DynamicAnchor); + Assert.NotNull(referencing.Definitions); + Assert.True(referencing.Definitions.ContainsKey("sibling")); + Assert.Equal("inner", referencing.Definitions["sibling"].DynamicAnchor); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var writer = new StringWriter(); + result.Document.SerializeAsV31(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves $dynamicAnchor and $defs alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(roundTripResult.Document.Components); + Assert.NotNull(roundTripResult.Document.Components.Schemas); + var referencing = roundTripResult.Document.Components.Schemas["Referencing"]; + + Assert.IsType(referencing); + Assert.Equal("anchor", referencing.DynamicAnchor); + Assert.NotNull(referencing.Definitions); + Assert.True(referencing.Definitions.ContainsKey("itemType")); + Assert.Equal("itemType", referencing.Definitions["itemType"].DynamicAnchor); + } + + [Fact] + public async Task ParseSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Scalar sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert + Assert.IsType(referencing); + Assert.Equal("https://example.com/referencing.json", referencing.Id); + Assert.Equal(new Uri("https://json-schema.org/draft/2020-12/schema"), referencing.Schema); + Assert.Equal("A comment sibling", referencing.Comment); + Assert.Equal("myAnchor", ((IOpenApiSchemaMissingProperties)referencing).Anchor); + Assert.Equal("#myAnchor", referencing.DynamicRef); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Scalar round-trip + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var writer = new StringWriter(); + result.Document.SerializeAsV31(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves scalar siblings alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(roundTripResult.Document.Components); + Assert.NotNull(roundTripResult.Document.Components.Schemas); + var referencing = roundTripResult.Document.Components.Schemas["Referencing"]; + + Assert.IsType(referencing); + Assert.Equal("https://example.com/referencing.json", referencing.Id); + Assert.Equal(new Uri("https://json-schema.org/draft/2020-12/schema"), referencing.Schema); + Assert.Equal("A comment sibling", referencing.Comment); + Assert.Equal("myAnchor", ((IOpenApiSchemaMissingProperties)referencing).Anchor); + Assert.Equal("#myAnchor", referencing.DynamicRef); + } + + [Fact] + public async Task ParseSchemaReferencePreservesVocabularySibling() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Vocabulary sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + 'https://json-schema.org/draft/2020-12/vocab/applicator': false + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert + Assert.IsType(referencing); + Assert.NotNull(referencing.Vocabulary); + Assert.Equal(2, referencing.Vocabulary.Count); + Assert.True(referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"]); + Assert.False(referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"]); + } + + [Fact] + public async Task ParseSchemaReferencePreservesDynamicAnchorInsideDefsInAllOf() + { + // Arrange — the allOf-based binding variant: $defs sits inside allOf[0], + // and the nested schema has $ref + $dynamicAnchor (the binding entry). + // This was called out as a real-world pattern that hits the same root cause + // because the inner schema is an OpenApiSchemaReference whose sibling was dropped. + var yaml = """ + openapi: 3.1.0 + info: + title: allOf binding variant + version: 1.0.0 + paths: {} + components: + schemas: + Asset: + type: object + properties: + id: + type: string + Paged: + type: object + properties: + items: + type: array + AssetPaged: + allOf: + - $defs: + contentType: + $dynamicAnchor: contentType + $ref: '#/components/schemas/Asset' + - $ref: '#/components/schemas/Paged' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var assetPaged = result.Document.Components.Schemas["AssetPaged"]; + + // Assert — the binding entry inside $defs/allOf[0] is reachable + // allOf[0] is a regular OpenApiSchema (no $ref at top level), so $defs is parsed normally. + // The nested contentType schema is an OpenApiSchemaReference ($ref: Asset), + // and its $dynamicAnchor sibling must be preserved. + Assert.NotNull(assetPaged.AllOf); + Assert.Equal(2, assetPaged.AllOf.Count); + var defsHolder = assetPaged.AllOf[0]; + Assert.NotNull(defsHolder.Definitions); + Assert.True(defsHolder.Definitions.ContainsKey("contentType")); + var contentType = defsHolder.Definitions["contentType"]; + Assert.IsType(contentType); + Assert.Equal("contentType", contentType.DynamicAnchor); + } + + [Fact] + public async Task EmptySiblingCollectionsFallThroughToTarget() + { + // Arrange — Target has $defs and $vocabulary; Referencing has empty siblings. + // Empty collections must NOT suppress the target's values via the ?? getter. + var yaml = """ + openapi: 3.1.0 + info: + title: Empty sibling fallthrough + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + $defs: + targetDef: + type: string + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + Referencing: + $ref: '#/components/schemas/Target' + $defs: {} + $vocabulary: {} + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert — empty siblings fall through to Target's values + Assert.IsType(referencing); + Assert.NotNull(referencing.Definitions); + Assert.True(referencing.Definitions.ContainsKey("targetDef")); + Assert.NotNull(referencing.Vocabulary); + Assert.True(referencing.Vocabulary.ContainsKey("https://json-schema.org/draft/2020-12/vocab/core")); + } + + [Fact] + public async Task SiblingsOnRefAreDroppedForOpenApi30() + { + // Arrange — 3.0 spec requires $ref siblings to be ignored. + // The fix must not change 3.0 behavior. + var yaml = """ + openapi: 3.0.3 + info: + title: 3.0 version safety + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert — siblings are dropped for 3.0 (per spec: $ref siblings MUST be ignored) + Assert.IsType(referencing); + Assert.Null(referencing.DynamicAnchor); + Assert.Null(referencing.Definitions); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesVocabularySibling() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Vocabulary round-trip + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + 'https://json-schema.org/draft/2020-12/vocab/applicator': false + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var writer = new StringWriter(); + result.Document.SerializeAsV31(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves $vocabulary alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(roundTripResult.Document.Components); + Assert.NotNull(roundTripResult.Document.Components.Schemas); + var referencing = roundTripResult.Document.Components.Schemas["Referencing"]; + + Assert.IsType(referencing); + Assert.NotNull(referencing.Vocabulary); + Assert.Equal(2, referencing.Vocabulary.Count); + Assert.True(referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"]); + Assert.False(referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"]); + } + + [Fact] + public async Task CreateShallowCopyPreservesKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Shallow copy repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + var copy = referencing.CreateShallowCopy(); + + // Assert — CreateShallowCopy preserves sibling values via the JsonSchemaReference copy constructor + Assert.IsType(copy); + Assert.Equal("anchor", copy.DynamicAnchor); + Assert.NotNull(copy.Definitions); + Assert.True(copy.Definitions.ContainsKey("sibling")); + Assert.Equal("inner", copy.Definitions["sibling"].DynamicAnchor); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeReferenceDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeReferenceDeserializerTests.cs index 7767148a5..a456d7b98 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeReferenceDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeReferenceDeserializerTests.cs @@ -1,3 +1,4 @@ +using System; using System.Text.Json.Nodes; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V31; @@ -67,4 +68,30 @@ public void ShouldDeserializeSecuritySchemeWithXOaiDeprecatedExtension() Assert.Equal(ParameterLocation.Header, resultScheme.In); Assert.True(resultScheme.Deprecated); } + + [Fact] + public void ShouldDeserializeSecuritySchemeWithOAuth2MetadataUrlExtension() + { + var json = + """ + { + "type": "oauth2", + "x-oai-oauth2-metadata-url": "https://idp.example.com/.well-known/oauth-authorization-server", + "flows": { + "clientCredentials": { + "tokenUrl": "https://idp.example.com/oauth/token", + "scopes": { + "scope:one": "Scope one" + } + } + } + } + """; + + var result = OpenApiV31Deserializer.LoadSecurityScheme(JsonNode.Parse(json), new OpenApiDocument(), new ParsingContext(new())); + + var resultScheme = Assert.IsType(result); + Assert.Equal(new Uri("https://idp.example.com/.well-known/oauth-authorization-server"), resultScheme.OAuth2MetadataUrl); + Assert.True(resultScheme.Extensions is null || !resultScheme.Extensions.ContainsKey(OpenApiConstants.OAuth2MetadataUrlExtension)); + } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeTests.cs index 75d204097..49720fe3f 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSecuritySchemeTests.cs @@ -17,7 +17,7 @@ public class OpenApiSecuritySchemeTests public async Task ParseHttpSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "httpSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "httpSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -32,7 +32,7 @@ public async Task ParseHttpSecuritySchemeShouldSucceed() public async Task ParseApiKeySecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "apiKeySecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "apiKeySecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -48,7 +48,7 @@ public async Task ParseApiKeySecuritySchemeShouldSucceed() public async Task ParseBearerSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "bearerSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "bearerSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -64,7 +64,7 @@ public async Task ParseBearerSecuritySchemeShouldSucceed() public async Task ParseOAuth2SecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "oauth2SecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "oauth2SecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -90,7 +90,7 @@ public async Task ParseOAuth2SecuritySchemeShouldSucceed() public async Task ParseOpenIdConnectSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "openIdConnectSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "openIdConnectSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -107,10 +107,10 @@ public async Task ParseOAuth2SecuritySchemeWithDeviceAuthorizationUrlShouldSucce { // Act var securityScheme = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "oauth2SecuritySchemeWithDeviceUrl.yaml"), + Path.Join(SampleFolderPath, "oauth2SecuritySchemeWithDeviceUrl.yaml"), OpenApiSpecVersion.OpenApi3_1, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(securityScheme); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index a8b103e21..d829c4d5a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -17,10 +17,10 @@ public class RelativeReferenceTests public async Task ParseInlineLocalReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "inlineLocalReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "inlineLocalReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schemaType = actual.Paths["/item"].Operations[HttpMethod.Get].Parameters[0].Schema.Type; // Assert @@ -40,7 +40,7 @@ public async Task ParseInlineExternalReferenceWorks() } }; - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { @@ -50,7 +50,7 @@ public async Task ParseInlineExternalReferenceWorks() settings.AddYamlReader(); // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "inlineExternalReference.yaml"), settings)).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "inlineExternalReference.yaml"), settings, token: TestContext.Current.CancellationToken)).Document; var exampleValue = actual.Paths["/items"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Examples["item-list"].Value; // Assert @@ -63,7 +63,7 @@ public async Task ParseInlineExternalReferenceWorks() public async Task ParseComponentExternalReferenceWorks() { // Arrange - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { LoadExternalRefs = true, @@ -72,7 +72,7 @@ public async Task ParseComponentExternalReferenceWorks() settings.AddYamlReader(); // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "componentExternalReference.yaml"), settings)).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "componentExternalReference.yaml"), settings, token: TestContext.Current.CancellationToken)).Document; var securitySchemeValue = actual.Components.SecuritySchemes["customapikey"]; // Assert @@ -83,10 +83,10 @@ public async Task ParseComponentExternalReferenceWorks() public async Task ParseRootInlineJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "rootInlineSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "rootInlineSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/item"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert @@ -97,10 +97,10 @@ public async Task ParseRootInlineJsonSchemaReferenceWorks() public async Task ParseSubschemaInlineJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "subschemaInlineSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "subschemaInlineSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/items"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema.Items; // Assert @@ -111,10 +111,10 @@ public async Task ParseSubschemaInlineJsonSchemaReferenceWorks() public async Task ParseRootComponentJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "rootComponentSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "rootComponentSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Components.Schemas["specialitem"]; // Assert @@ -126,10 +126,10 @@ public async Task ParseRootComponentJsonSchemaReferenceWorks() public async Task ParseSubschemaComponentJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "subschemaComponentSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "subschemaComponentSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Components.Schemas["items"].Items; // Assert @@ -140,10 +140,10 @@ public async Task ParseSubschemaComponentJsonSchemaReferenceWorks() public async Task ParseInternalComponentSubschemaJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "internalComponentsSubschemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "internalComponentsSubschemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var addressSchema = actual.Paths["/person/{id}/address"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; var itemsSchema = actual.Paths["/human"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; @@ -156,7 +156,7 @@ public async Task ParseInternalComponentSubschemaJsonSchemaReferenceWorks() public async Task ParseExternalComponentSubschemaJsonSchemaReferenceWorks() { // Arrange - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { LoadExternalRefs = true, @@ -165,37 +165,205 @@ public async Task ParseExternalComponentSubschemaJsonSchemaReferenceWorks() settings.AddYamlReader(); // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalComponentSubschemaReference.yaml"), settings)).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalComponentSubschemaReference.yaml"), settings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert Assert.Equal(JsonSchemaType.Object, schema.Type); } + [Fact] + public async Task ParseExternalInlineSubschemaUsingAnchorWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var externalPath = Path.Join(tempDirectory, "OAS-schemas.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.1.0 +info: + title: T + version: 1.0.0 +paths: + /person/{id}: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'OAS-schemas.yaml#address' +", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(externalPath, + @" +openapi: 3.1.0 +info: + title: OpenAPI document containing reusable components + version: 1.0.0 +components: + schemas: + person: + type: object + properties: + address: + $anchor: address + type: object +", TestContext.Current.CancellationToken); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings, token: TestContext.Current.CancellationToken); + var schema = result.Document.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + + [Fact] + public async Task ExternalJsonSchemaFragmentReferenceDoesNotResolve() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var externalPath = Path.Join(tempDirectory, "OAS-schemas.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.1.0 +info: + title: T + version: 1.0.0 +paths: + /person/{id}: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'OAS-schemas.yaml#/person/properties/address' +", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(externalPath, + @" +person: + type: object + properties: + address: + type: object +", TestContext.Current.CancellationToken); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings, token: TestContext.Current.CancellationToken); + var schema = result.Document.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + var schemaReference = Assert.IsType(schema); + + Assert.True(schemaReference.UnresolvedReference); + Assert.Null(schema.Type); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public async Task ParseReferenceToInternalComponentUsingDollarIdWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "internalComponentReferenceUsingId.yaml"); + var filePath = Path.Join(SampleFolderPath, "internalComponentReferenceUsingId.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert Assert.Equal(JsonSchemaType.Object, schema.Type); } + [Fact] + public async Task ParseReferenceToInternalSubschemaUsingDollarIdWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.1.0 +info: + title: T + version: 1.0.0 +paths: + /person/{id}/address: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'https://schemas.acme.org/address' +components: + schemas: + person: + $id: 'https://schemas.acme.org/person' + type: object + properties: + address: + $id: 'address' + type: object + properties: + street: + type: string +", TestContext.Current.CancellationToken); + + try + { + var result = await OpenApiDocument.LoadAsync(rootPath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); + var schema = result.Document.Paths["/person/{id}/address"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Properties["street"].Type); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public async Task ParseLocalReferenceToJsonSchemaResourceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "localReferenceToJsonSchemaResource.yaml"); + var filePath = Path.Join(SampleFolderPath, "localReferenceToJsonSchemaResource.yaml"); var stringWriter = new StringWriter(); var writer = new OpenApiYamlWriter(stringWriter); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Components.Schemas["a"].Properties["b"].Properties["c"].Properties["b"]; schema.SerializeAsV31(writer); @@ -203,6 +371,75 @@ public async Task ParseLocalReferenceToJsonSchemaResourceWorks() Assert.Equal(JsonSchemaType.Object | JsonSchemaType.Null, schema.Type); } + [Fact] + public async Task ParseExternalSchemaReferencedDirectlyAndReExportedAtRootWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var sharedPath = Path.Join(tempDirectory, "shared.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.1.0 +info: + title: T + version: 1.0.0 +paths: + /a: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + meta: + $ref: './shared.yaml#/Leaf' +components: + schemas: + Leaf: + $ref: './shared.yaml#/Leaf' +", TestContext.Current.CancellationToken); + + await File.WriteAllTextAsync(sharedPath, + @"Leaf: + type: object + properties: + x: + type: string + y: + type: integer +", TestContext.Current.CancellationToken); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings, token: TestContext.Current.CancellationToken); + var responseSchema = result.Document.Paths["/a"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + var metaSchema = responseSchema.Properties["meta"]; + var leafSchema = result.Document.Components.Schemas["Leaf"]; + + Assert.NotNull(result.Document); + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Message.Contains("Circular reference detected while resolving schema", StringComparison.Ordinal)); + Assert.DoesNotContain(result.Diagnostic.Warnings, warning => warning.Message.Contains("Circular reference detected while resolving schema", StringComparison.Ordinal)); + Assert.IsType(metaSchema); + Assert.IsType(leafSchema); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public void ResolveSubSchema_ShouldTraverseKnownKeywords() { @@ -284,10 +521,10 @@ public void ResolveSubSchema_ShouldRecurseIntoAllOfComposition() public async Task ShouldResolveRelativeSubReference() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "relativeSubschemaReference.json"); + var filePath = Path.Join(SampleFolderPath, "relativeSubschemaReference.json"); // Act - var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var fooComponentSchema = actual.Components.Schemas["Foo"]; var seq1Property = fooComponentSchema.Properties["seq1"]; @@ -301,9 +538,9 @@ public async Task ShouldResolveRelativeSubReference() public async Task ShouldResolveRelativeSubReferenceUsingParsingContext() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "relativeSubschemaReference.json"); + var filePath = Path.Join(SampleFolderPath, "relativeSubschemaReference.json"); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); - var jsonNode = await JsonNode.ParseAsync(fs); + var jsonNode = await JsonNode.ParseAsync(fs, cancellationToken: TestContext.Current.CancellationToken); var schemaJsonNode = jsonNode["components"]?["schemas"]?["Foo"]; Assert.NotNull(schemaJsonNode); var diagnostic = new OpenApiDiagnostic(); @@ -394,10 +631,10 @@ public void ShouldResolveAbsoluteSubReferenceFromTheObjectModel() public async Task ShouldResolveRecursiveRelativeSubReference() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "recursiveRelativeSubschemaReference.json"); + var filePath = Path.Join(SampleFolderPath, "recursiveRelativeSubschemaReference.json"); // Act - var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var fooComponentSchema = actual.Components.Schemas["Foo"]; var fooSchemaParentProperty = fooComponentSchema.Properties["parent"]; @@ -415,9 +652,9 @@ public async Task ShouldResolveRecursiveRelativeSubReference() [Fact] public async Task ShouldResolveReferencesInSchemasFromSystemTextJson() { - var filePath = Path.Combine(SampleFolderPath, "STJSchema.json"); + var filePath = Path.Join(SampleFolderPath, "STJSchema.json"); using var fs = File.OpenRead(filePath); - var jsonNode = await JsonNode.ParseAsync(fs); + var jsonNode = await JsonNode.ParseAsync(fs, cancellationToken: TestContext.Current.CancellationToken); var parsingContext = new ParsingContext(new OpenApiDiagnostic()); var document = new OpenApiDocument(); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json index 4a16ab4f5..4ee9fc8fa 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json @@ -8,7 +8,12 @@ "type": "array", "items": { "type": "string" - } + }, + "contains": { + "type": "string" + }, + "minContains": 1, + "maxContains": 5 }, "vegetables": { "type": "array" diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithConstNull.json b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithConstNull.json new file mode 100644 index 000000000..db5290a5c --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithConstNull.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "status": { + "type": "string", + "const": null + }, + "user": { + "type": "object", + "properties": { + "role": { + "type": "string", + "const": null + } + }, + "required": [ "role" ] + } + }, + "required": [ "status" ] +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs index 4b359546e..c571b0ce6 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs @@ -18,10 +18,10 @@ public class OpenApiDiscriminatorTests public async Task ParseBasicDiscriminatorShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicDiscriminator.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicDiscriminator.yaml")); // Copy stream to MemoryStream using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream); + await stream.CopyToAsync(memoryStream, TestContext.Current.CancellationToken); memoryStream.Position = 0; // Act diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentSerializationTests .cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentSerializationTests.cs similarity index 92% rename from test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentSerializationTests .cs rename to test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentSerializationTests.cs index 2888e39e2..d97cb2bf1 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentSerializationTests .cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentSerializationTests.cs @@ -22,15 +22,16 @@ public class OpenApiDocumentSerializationTests public async Task Serialize_DoesNotMutateDom(OpenApiSpecVersion version) { // Arrange - var filePath = Path.Combine(SampleFolderPath, "docWith32properties.json"); - var (doc, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + var filePath = Path.Join(SampleFolderPath, "docWith32properties.json"); + var (doc, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Act: Serialize using System.Text.Json var options = new JsonSerializerOptions { Converters = { - new HttpMethodOperationDictionaryConverter() + new HttpMethodOperationDictionaryConverter(), + new OpenApiSchemaInterfaceJsonConverter(static (w, s) => s.SerializeAsV32(w)) }, }; var originalSerialized = JsonSerializer.Serialize(doc, options); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.ParseDocumentWith32PropertiesWorks.verified.txt b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.ParseDocumentWith32PropertiesWorks.verified.txt index a9a5d5e1b..6dac0f30e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.ParseDocumentWith32PropertiesWorks.verified.txt +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.ParseDocumentWith32PropertiesWorks.verified.txt @@ -113,4 +113,4 @@ webhooks: required: true responses: '200': - description: Webhook processed successfully + description: Webhook processed successfully \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs index 80051f05c..28e213466 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs @@ -2,7 +2,6 @@ using System.Globalization; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Tests; using Xunit; @@ -10,17 +9,53 @@ using System; using System.Net.Http; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Readers.Tests.V32Tests { public class OpenApiDocumentTests { private const string SampleFolderPath = "V32Tests/Samples/OpenApiDocument/"; + [Fact] + public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic() + { + var result = OpenApiDocument.Parse( + """ + openapi: 3.2.0 + info: + title: test + version: 1.0.0 + paths: + /test: + get: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#components/schemas/Item' + components: + schemas: + Item: + type: object + properties: + id: + type: integer + """, + OpenApiConstants.Yaml, + SettingsFixture.ReaderSettings); + + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message); + } + [Fact] public async Task ParseDocumentWithWebhooksShouldSucceed() { // Arrange and Act - var actual = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "documentWithWebhooks.yaml"), SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "documentWithWebhooks.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var petSchema = new OpenApiSchemaReference("petSchema", actual.Document); var newPetSchema = new OpenApiSchemaReference("newPetSchema", actual.Document); @@ -208,14 +243,14 @@ public async Task ParseDocumentWithWebhooksShouldSucceed() // Assert Assert.Equivalent(new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_2, Format = OpenApiConstants.Yaml }, actual.Diagnostic); - actual.Document.Should().BeEquivalentTo(expected, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(expected, actual.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); } [Fact] public async Task ParseDocumentsWithReusablePathItemInWebhooksSucceeds() { // Arrange && Act - var actual = await OpenApiDocument.LoadAsync("V32Tests/Samples/OpenApiDocument/documentWithReusablePaths.yaml", SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync("V32Tests/Samples/OpenApiDocument/documentWithReusablePaths.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var components = new OpenApiComponents { @@ -410,9 +445,7 @@ public async Task ParseDocumentsWithReusablePathItemInWebhooksSucceeds() }; // Assert - actual.Document.Should().BeEquivalentTo(expected, options => options - .Excluding(x => x.Workspace) - .Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(expected, actual.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent( new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_2, Format = OpenApiConstants.Yaml }, actual.Diagnostic); } @@ -425,7 +458,7 @@ public async Task ParseDocumentWithExampleInSchemaShouldSucceed() var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = false }); // Act - var actual = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithExample.yaml"), SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithExample.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); actual.Document.SerializeAsV32(writer); // Assert @@ -436,7 +469,7 @@ public async Task ParseDocumentWithExampleInSchemaShouldSucceed() public async Task ParseDocumentWithPatternPropertiesInSchemaWorks() { // Arrange and Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithPatternPropertiesInSchema.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithPatternPropertiesInSchema.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var actualSchema = result.Document.Paths["/example"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; var expectedSchema = new OpenApiSchema @@ -482,7 +515,7 @@ public async Task ParseDocumentWithPatternPropertiesInSchemaWorks() prop3: type: string"; - var actualMediaType = await mediaType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2); + var actualMediaType = await mediaType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.Equivalent(expectedSchema, actualSchema); @@ -493,7 +526,7 @@ public async Task ParseDocumentWithPatternPropertiesInSchemaWorks() public async Task ParseDocumentWithReferenceByIdGetsResolved() { // Arrange and Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithReferenceById.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithReferenceById.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var responseSchema = result.Document.Paths["/resource"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; var requestBodySchema = result.Document.Paths["/resource"].Operations[HttpMethod.Post].RequestBody.Content["application/json"].Schema; @@ -510,7 +543,7 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo { // Arrange var documentName = "externalRefByJsonPointer.yaml"; - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath, documentName); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath, documentName); var settings = new OpenApiReaderSettings { @@ -520,13 +553,13 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo settings.AddYamlReader(); // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, documentName), settings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, documentName), settings, token: TestContext.Current.CancellationToken); var responseSchema = result.Document.Paths["/resource"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert var externalResourceUri = new Uri( "file://" + - Path.Combine(Path.GetFullPath(SampleFolderPath), + Path.Join(Path.GetFullPath(SampleFolderPath), "externalResource.yaml#/components/schemas/todo")).AbsoluteUri; Assert.True(result.Document.Workspace.Contains(externalResourceUri)); @@ -537,7 +570,7 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo public async Task ParseExternalDocumentDereferenceToOpenApiDocumentByIdWorks() { // Arrange - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { @@ -547,8 +580,8 @@ public async Task ParseExternalDocumentDereferenceToOpenApiDocumentByIdWorks() settings.AddYamlReader(); // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalRefById.yaml"), settings); - var doc2 = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalResource.yaml"), SettingsFixture.ReaderSettings)).Document; + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalRefById.yaml"), settings, token: TestContext.Current.CancellationToken); + var doc2 = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalResource.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var requestBodySchema = result.Document.Paths["/resource"].Operations[HttpMethod.Get].Parameters[0].Schema; result.Document.Workspace.RegisterComponents(doc2); @@ -560,8 +593,8 @@ public async Task ParseExternalDocumentDereferenceToOpenApiDocumentByIdWorks() [Fact] public async Task ParseDocumentWith32PropertiesWorks() { - var path = Path.Combine(SampleFolderPath, "documentWith32Properties.yaml"); - var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings)).Document; + var path = Path.Join(SampleFolderPath, "documentWith32Properties.yaml"); + var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var outputStringWriter = new StringWriter(); doc.SerializeAsV32(new OpenApiYamlWriter(outputStringWriter)); await outputStringWriter.FlushAsync(); @@ -574,16 +607,16 @@ public async Task ParseDocumentWith32PropertiesWorks() [Fact] public async Task ParseDocumentWithEmptyTagsWorks() { - var path = Path.Combine(SampleFolderPath, "documentWithEmptyTags.json"); - var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings)).Document; + var path = Path.Join(SampleFolderPath, "documentWithEmptyTags.json"); + var doc = (await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; - doc.Paths["/groups"].Operations[HttpMethod.Get].Tags.Should().BeNull("Empty tags are ignored, so we should not have any tags"); + Assert.Null(doc.Paths["/groups"].Operations[HttpMethod.Get].Tags); } [Fact] public async Task DocumentWithSchemaResultsInWarning() { - var path = Path.Combine(SampleFolderPath, "documentWithSchema.json"); - var (doc, diag) = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "documentWithSchema.json"); + var (doc, diag) = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.NotNull(doc); Assert.NotNull(diag); Assert.Empty(diag.Errors); @@ -601,8 +634,8 @@ public void ParseEmptyMemoryStreamThrowsAnArgumentException() public async Task ValidateReferencedExampleInSchemaWorks() { // Arrange && Act - var path = Path.Combine(SampleFolderPath, "docWithReferencedExampleInSchemaWorks.yaml"); - var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "docWithReferencedExampleInSchemaWorks.yaml"); + var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var actualSchemaExample = result.Document.Components.Schemas["DiffCreatedEvent"].Properties["updatedAt"].Example; var targetSchemaExample = result.Document.Components.Schemas["Timestamp"].Example; @@ -616,8 +649,8 @@ public async Task ValidateReferencedExampleInSchemaWorks() public async Task ParseDocumentWithSelfPropertyFromYamlWorks() { // Arrange && Act - var path = Path.Combine(SampleFolderPath, "documentWithSelfProperty.yaml"); - var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "documentWithSelfProperty.yaml"); + var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(result.Document); @@ -633,8 +666,8 @@ public async Task ParseDocumentWithSelfPropertyFromYamlWorks() public async Task ParseDocumentWithSelfPropertyFromJsonWorks() { // Arrange && Act - var path = Path.Combine(SampleFolderPath, "documentWithSelfProperty.json"); - var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "documentWithSelfProperty.json"); + var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(result.Document); @@ -665,4 +698,3 @@ public void LoadDocumentWithBooleanSchemaShouldNotThrowNullReferenceException() } } } - diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs new file mode 100644 index 000000000..1e3140931 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs @@ -0,0 +1,1864 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Reader.V32; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V32Tests; + +public class OpenApiDynamicRefTests +{ + private static async Task LoadDocumentAsync(string yaml) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + return result.Document; + } + + [Fact] + public void BareDynamicRefDeserializesAsSchemaReference() + { + var json = + """ + { + "$dynamicRef": "#category" + } + """; + + var hostDocument = new OpenApiDocument(); + var jsonNode = JsonNode.Parse(json); + + var result = OpenApiV32Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new())); + + var reference = Assert.IsType(result); + Assert.Equal("#category", reference.Reference.DynamicRef); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Reference.ExternalResource); + } + + [Fact] + public void BareDynamicRefDoesNotEmitRefOnSerialization() + { + var json = + """ + { + "$dynamicRef": "#category" + } + """; + + var hostDocument = new OpenApiDocument(); + var jsonNode = JsonNode.Parse(json); + + var result = OpenApiV32Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new())); + + var sw = new StringWriter(); + var writer = new OpenApiJsonWriter(sw); + result.SerializeAsV32(writer); + + var output = sw.ToString(); + Assert.Contains("$dynamicRef", output); + Assert.DoesNotContain("$ref", output); + } + + [Fact] + public async Task DynamicRefResolvesToDynamicAnchorTarget() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + children: + type: array + items: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var childrenItems = tree.Properties["children"].Items; + + var reference = Assert.IsType(childrenItems); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(tree, reference.Target); + } + + [Fact] + public async Task DynamicRefReturnsNullForUnknownAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: object + properties: + bar: + $dynamicRef: '#nonexistent' + """; + + var doc = await LoadDocumentAsync(yaml); + + var foo = doc.Components.Schemas["Foo"]; + var barSchema = foo.Properties["bar"]; + + var reference = Assert.IsType(barSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task ExternalDynamicRefWithRelativeUriResolvesAcrossDocuments() + { + var yamlA = + """ + openapi: 3.2.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'external.yaml#node' + """; + + var yamlB = + """ + openapi: 3.2.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main.yaml"); + docB.BaseUri = new("https://example.com/external.yaml"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("external.yaml", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + + [Fact] + public async Task AbsoluteDynamicRefDoesNotResolveToLocalAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicRefWithSiblingsPreservesSiblings() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: object + properties: + bar: + $dynamicRef: '#node' + maxProperties: 3 + description: a constrained dynamic ref + """; + + var doc = await LoadDocumentAsync(yaml); + + var foo = doc.Components.Schemas["Foo"]; + var bar = foo.Properties["bar"]; + + Assert.Equal(3, bar.MaxProperties); + Assert.Equal("a constrained dynamic ref", bar.Description); + Assert.Equal("#node", bar.DynamicRef); + } + + [Fact] + public async Task DynamicRefRoundTripsThroughSerialization() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + children: + type: array + items: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var sw = new StringWriter(); + var writer = new OpenApiYamlWriter(sw); + doc.SerializeAsV32(writer); + var serialized = sw.ToString(); + + var doc2 = await LoadDocumentAsync(serialized); + + var tree2 = doc2.Components.Schemas["Tree"]; + var childrenItems2 = tree2.Properties["children"].Items; + var reference2 = Assert.IsType(childrenItems2); + Assert.True(reference2.Reference.IsDynamicRefOnly); + Assert.NotNull(reference2.Target); + } + + [Fact] + public async Task DynamicRefFallsBackToPlainAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $anchor: meta + type: object + properties: + value: + type: string + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var target = doc.Components.Schemas["Target"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(target, reference.Target); + } + + [Fact] + public async Task DynamicAnchorTakesPrecedenceOverPlainAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicTarget: + $dynamicAnchor: meta + type: object + properties: + dynamic: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var dynamicTarget = doc.Components.Schemas["DynamicTarget"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.NotNull(reference.Target); + Assert.Same(dynamicTarget, reference.Target); + } + + [Fact] + public async Task DynamicRefDoesNotFallBackToPlainAnchorWhenDynamicAnchorIsAmbiguous() + { + // Two $dynamicAnchor: meta declarations make the dynamic anchor ambiguous. Per §8.2.3.2, + // the spec requires the outermost $dynamicAnchor, which this library cannot compute, so + // Target must be null. The presence of a $anchor: meta must NOT trigger the plain-anchor + // fallback, since that would silently resolve to a different, incorrect target. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicA: + $dynamicAnchor: meta + type: object + properties: + a: + type: boolean + DynamicB: + $dynamicAnchor: meta + type: object + properties: + b: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableResponseIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested under components/responses must be + // registered just like one declared inline under paths, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + ItemType: + type: object + properties: + name: + type: string + responses: + ListResponse: + description: A paged list + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/ItemType' + $ref: '#/components/schemas/Paged' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.NotNull(itemsRef.Target); + + var resolved = Assert.IsType(itemsRef.Target); + Assert.Same(doc.Components.Schemas["ItemType"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableParameterIsRegistered() + { + // A $dynamicAnchor declared inside a reusable parameter's schema must be registered. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Filter: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + parameters: + filterParam: + name: filter + in: query + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Filter' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var filter = doc.Components.Schemas["Filter"]; + var childRef = Assert.IsType(filter.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableHeaderIsRegistered() + { + // A $dynamicAnchor declared inside a reusable header's schema (components/headers) must + // be registered. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Cursor: + type: object + properties: + next: + $dynamicRef: '#page' + Page: + type: object + properties: + index: + type: integer + headers: + XCursor: + description: Cursor header + schema: + $defs: + page: + $dynamicAnchor: page + $ref: '#/components/schemas/Page' + $ref: '#/components/schemas/Cursor' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "page")); + + var cursor = doc.Components.Schemas["Cursor"]; + var nextRef = Assert.IsType(cursor.Properties["next"]); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + + var resolved = Assert.IsType(nextRef.Target); + Assert.Same(doc.Components.Schemas["Page"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInResponseHeaderSchemaIsRegistered() + { + // A $dynamicAnchor declared inside a response header's schema must be registered. This + // exercises the response.Headers leaf, which (like parameter.Content and header.Content) + // is a schema-bearing location that the anchor walk must cover. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: + /items: + get: + responses: + '200': + description: OK + headers: + X-Page: + description: Paging header + schema: + $defs: + meta: + $dynamicAnchor: meta + type: object + properties: + total: + type: integer + components: + schemas: + Body: + type: object + properties: + paging: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidate = Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "meta")); + + var body = doc.Components.Schemas["Body"]; + var pagingRef = Assert.IsType(body.Properties["paging"]); + Assert.True(pagingRef.Reference.IsDynamicRefOnly); + Assert.NotNull(pagingRef.Target); + Assert.Same(candidate, pagingRef.Target); + } + + [Fact] + public async Task DynamicAnchorInMediaTypeItemSchemaIsRegistered() + { + // A $dynamicAnchor declared inside a media type's itemSchema (an OAS 3.2 schema-bearing + // leaf distinct from `schema`) must be registered, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Stream: + type: object + properties: + first: + $dynamicRef: '#entry' + Entry: + type: object + properties: + id: + type: string + responses: + StreamResponse: + description: A streaming response + content: + application/x-ndjson: + itemSchema: + $defs: + entry: + $dynamicAnchor: entry + $ref: '#/components/schemas/Entry' + $ref: '#/components/schemas/Stream' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "entry")); + + var stream = doc.Components.Schemas["Stream"]; + var firstRef = Assert.IsType(stream.Properties["first"]); + Assert.True(firstRef.Reference.IsDynamicRefOnly); + Assert.NotNull(firstRef.Target); + + var resolved = Assert.IsType(firstRef.Target); + Assert.Same(doc.Components.Schemas["Entry"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInInlineCallbackIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested in an inline operation callback must be + // registered, matching the treatment of reusable (components/callbacks) callbacks. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: + /register: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + callbacks: + onUpdate: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableMediaTypeIsRegistered() + { + // A $dynamicAnchor declared inside a reusable media type's schema (components/mediaTypes, + // an OAS 3.2 component) must be registered, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Payload: + type: object + properties: + inner: + $dynamicRef: '#widget' + Widget: + type: object + properties: + id: + type: string + mediaTypes: + JsonPayload: + schema: + $defs: + widget: + $dynamicAnchor: widget + $ref: '#/components/schemas/Widget' + $ref: '#/components/schemas/Payload' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "widget")); + + var payload = doc.Components.Schemas["Payload"]; + var innerRef = Assert.IsType(payload.Properties["inner"]); + Assert.True(innerRef.Reference.IsDynamicRefOnly); + Assert.NotNull(innerRef.Target); + + var resolved = Assert.IsType(innerRef.Target); + Assert.Same(doc.Components.Schemas["Widget"], resolved.Target); + } + + [Fact] + public async Task CyclicCallbackDoesNotStackOverflowAndStillRegistersAnchors() + { + // A callback that references itself (via $ref) creates a structural cycle + // pathItem -> operation -> callback -> pathItem. The anchor walk must terminate and still + // register anchors declared inside the cycle. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + callbacks: + Loop: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + callbacks: + self: + $ref: '#/components/callbacks/Loop' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicRefWithStructuralSiblingsRoundTripsThroughSerialization() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + children: + type: array + items: + $dynamicRef: '#node' + minProperties: 2 + description: constrained child + """; + + var doc = await LoadDocumentAsync(yaml); + + var sw = new StringWriter(); + var writer = new OpenApiYamlWriter(sw); + doc.SerializeAsV32(writer); + var serialized = sw.ToString(); + + var doc2 = await LoadDocumentAsync(serialized); + + var tree2 = doc2.Components.Schemas["Tree"]; + var items2 = tree2.Properties["children"].Items; + var ref2 = Assert.IsType(items2); + Assert.True(ref2.Reference.IsDynamicRefOnly); + Assert.NotNull(ref2.Target); + Assert.Same(tree2, ref2.Target); + Assert.Equal(2, ref2.MinProperties); + Assert.Equal("constrained child", ref2.Description); + } + + [Fact] + public async Task DynamicAnchorInReusableCallbackIsRegistered() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + value: + type: string + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + callbacks: + Webhook: + '{$request.body#/url}': + post: + requestBody: + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + $ref: '#/components/schemas/Paged' + responses: + '200': + description: ok + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.NotNull(itemsRef.Target); + } + + [Fact] + public async Task DynamicAnchorInReusablePathItemIsRegistered() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + value: + type: string + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + pathItems: + SharedPath: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + $ref: '#/components/schemas/Paged' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.NotNull(itemsRef.Target); + } + + [Fact] + public async Task InliningDynamicRefProducesResolvedTargetSchema() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + value: + type: string + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + + var inlined = reference.CopyReferenceAsTargetElementWithOverrides(next); + Assert.NotNull(inlined); + Assert.Equal(JsonSchemaType.Object, inlined.Type); + Assert.NotNull(inlined.Properties); + Assert.True(inlined.Properties.ContainsKey("value")); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceDefsEntries() + { + // A $defs entry that is itself a $ref must contribute its own authored $dynamicAnchor, + // not an anchor declared on the referenced target. The "aliased" def points at a target + // that declares $dynamicAnchor: node, but the def itself does not author it, so the + // context-bound lookup must return null. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $dynamicAnchor: node + type: object + properties: + name: + type: string + Container: + type: object + $defs: + aliased: + $ref: '#/components/schemas/Target' + """; + + var doc = await LoadDocumentAsync(yaml); + + var container = doc.Components.Schemas["Container"]; + var aliased = Assert.IsType(container.Definitions!["aliased"]); + // Sanity: the fall-through property would surface the target's anchor... + Assert.Equal("node", aliased.DynamicAnchor); + // ...but the authored sibling on the def entry is empty. + Assert.Null(aliased.Reference.DynamicAnchor); + + // Both the non-reference context branch (Container) and the reference-holder branch + // (a ref whose Reference.Definitions carries the entry) must ignore the target's anchor. + Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(container, "node")); + + var refWithDefs = new OpenApiSchemaReference("Container", doc) + { + Reference = + { + Definitions = new Dictionary + { + ["aliased"] = aliased + } + } + }; + Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(refWithDefs, "node")); + } + + [Fact] + public async Task DynamicRefResolvesViaDefsAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + $defs: + node: + $dynamicAnchor: node + type: object + properties: + value: + type: string + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var root = doc.Components.Schemas["Root"]; + var nodeDef = root.Definitions["node"]; + var nextSchema = nodeDef.Properties["next"]; + + var reference = Assert.IsType(nextSchema); + Assert.NotNull(reference.Target); + Assert.Same(nodeDef, reference.Target); + } + + [Fact] + public async Task DynamicRefResolvesViaNestedPropertyAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + self: + $dynamicAnchor: node + type: object + properties: + value: + type: string + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var self = tree.Properties["self"]; + + var nextSchema = self.Properties["next"]; + var reference = Assert.IsType(nextSchema); + Assert.NotNull(reference.Target); + Assert.Same(self, reference.Target); + } + + [Fact] + public async Task DynamicRefResolvesViaAllOfAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + allOf: + - $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var root = doc.Components.Schemas["Root"]; + var branch = root.AllOf[0]; + var nextSchema = branch.Properties["next"]; + + var reference = Assert.IsType(nextSchema); + Assert.NotNull(reference.Target); + Assert.Same(branch, reference.Target); + } + + [Fact] + public async Task DynamicRefReturnsNullWhenAnchorIsAmbiguous() + { + // When a single document declares the same $dynamicAnchor name on more than one subschema, + // resolution cannot pick one without dynamic-scope evaluation, so Target returns null. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + type: object + properties: + a: + $dynamicAnchor: node + type: object + b: + $dynamicAnchor: node + type: object + ref: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var root = doc.Components.Schemas["Root"]; + var reference = Assert.IsType(root.Properties["ref"]); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicAnchorRegisteredAcrossAllSubschemaLocations() + { + // Exercises every subschema location the anchor walk descends into (oneOf, anyOf, not, + // items, additionalProperties, patternProperties, contains, propertyNames, contentSchema, + // if/then/else, dependentSchemas, unevaluatedPropertiesSchema). Each declares a distinct + // $dynamicAnchor name; a $dynamicRef to each confirms the walk reached it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Root: + type: object + oneOf: + - $dynamicAnchor: one + type: object + anyOf: + - $dynamicAnchor: any + type: object + allOf: + - type: object + properties: + nested: + type: array + items: + $dynamicAnchor: itm + type: string + dependentSchemas: + dep: + $dynamicAnchor: depn + type: object + not: + $dynamicAnchor: notn + type: object + contains: + $dynamicAnchor: cont + type: object + propertyNames: + $dynamicAnchor: pn + type: string + contentSchema: + $dynamicAnchor: cs + type: string + unevaluatedProperties: + $dynamicAnchor: up + type: object + patternProperties: + '^x': + $dynamicAnchor: pp + type: object + properties: + child: + $dynamicAnchor: ifn + type: object + additionalProperties: + $dynamicAnchor: ap + type: object + if: + $dynamicAnchor: iftop + type: object + then: + $dynamicAnchor: thentop + type: object + else: + $dynamicAnchor: elsetop + type: object + $defs: + consumer: + type: object + properties: + one: + $dynamicRef: '#one' + any: + $dynamicRef: '#any' + notn: + $dynamicRef: '#notn' + cont: + $dynamicRef: '#cont' + pn: + $dynamicRef: '#pn' + cs: + $dynamicRef: '#cs' + up: + $dynamicRef: '#up' + pp: + $dynamicRef: '#pp' + ifn: + $dynamicRef: '#ifn' + itm: + $dynamicRef: '#itm' + depn: + $dynamicRef: '#depn' + iftop: + $dynamicRef: '#iftop' + thentop: + $dynamicRef: '#thentop' + elsetop: + $dynamicRef: '#elsetop' + ap: + $dynamicRef: '#ap' + """; + + var doc = await LoadDocumentAsync(yaml); + var root = doc.Components.Schemas["Root"]; + var consumer = root.Definitions["consumer"].Properties; + + // Each anchor is unique within the document, so every resolution must succeed. + foreach (var name in new[] { "one", "any", "notn", "cont", "pn", "cs", "up", "pp", "ifn", "itm", "depn", "iftop", "thentop", "elsetop", "ap" }) + { + var reference = Assert.IsType(consumer[name]); + Assert.NotNull(reference.Target); + } + } + + [Fact] + public async Task DynamicAnchorInRefSiblingApplicatorsIsRegistered() + { + // A $ref schema may carry applicator siblings (allOf/oneOf/anyOf/properties/items/...) whose + // subschemas declare $dynamicAnchor. The anchor walk must descend into a reference holder's + // own siblings (read from JsonSchemaReference) to register them. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Base: + type: object + Referencing: + $ref: '#/components/schemas/Base' + oneOf: + - $dynamicAnchor: refOne + type: object + anyOf: + - $dynamicAnchor: refAny + type: object + properties: + child: + $dynamicAnchor: refChild + type: object + patternProperties: + '^x': + $dynamicAnchor: refPP + type: object + items: + $dynamicAnchor: refItem + type: string + dependentSchemas: + dep: + $dynamicAnchor: refDep + type: object + $defs: + consumer: + type: object + properties: + a: + $dynamicRef: '#refOne' + b: + $dynamicRef: '#refAny' + c: + $dynamicRef: '#refChild' + d: + $dynamicRef: '#refItem' + e: + $dynamicRef: '#refPP' + f: + $dynamicRef: '#refDep' + """; + + var doc = await LoadDocumentAsync(yaml); + var referencing = doc.Components.Schemas["Referencing"]; + var consumer = referencing.Definitions["consumer"].Properties; + + // Each $dynamicRef targets an anchor declared in a sibling applicator of the $ref schema. + foreach (var reference in consumer.Values.Select(v => Assert.IsType(v))) + { + Assert.NotNull(reference.Target); + } + } + + [Fact] + public async Task ExistingRefWithPathStillWorks() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: string + Bar: + $ref: '#/components/schemas/Foo' + """; + + var doc = await LoadDocumentAsync(yaml); + + var bar = doc.Components.Schemas["Bar"]; + var reference = Assert.IsType(bar); + Assert.False(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(doc.Components.Schemas["Foo"], reference.Target); + } + + [Fact] + public async Task AbsoluteDynamicRefResolvesToLocalAnchorWhenExternalNotLoaded() + { + // A URI-based $dynamicRef (https://example.com/external#node) targets an external resource. + // When the external document is not loaded in the workspace, resolution returns null + // rather than falling back to a local same-named anchor. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var tree = doc.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task AbsoluteDynamicRefResolvesAcrossDocuments() + { + // Document A references an anchor in Document B via a URI-based $dynamicRef. + // Both documents are in the same workspace. + var yamlA = + """ + openapi: 3.2.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var yamlB = + """ + openapi: 3.2.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main"); + docB.BaseUri = new("https://example.com/external"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + + [Fact] + public async Task DynamicAnchorResolvesPerDocumentInSharedWorkspace() + { + // Two documents in the same workspace each declare $dynamicAnchor: node (the conventional + // name for recursive tree schemas). Per JSON Schema 2020-12, dynamic scope is per-document, + // so each $dynamicRef must resolve to its own document's anchor, not return null as ambiguous. + var yaml = """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: '#node' + """; + + var docA = await LoadDocumentAsync(yaml); + var docB = await LoadDocumentAsync(yaml); + + var workspace = new OpenApiWorkspace(); + docA.Workspace = workspace; + docB.Workspace = workspace; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var treeA = docA.Components.Schemas["Tree"]; + var treeB = docB.Components.Schemas["Tree"]; + + var refA = Assert.IsType(treeA.Properties["next"]); + Assert.NotNull(refA.Target); + Assert.Same(treeA, refA.Target); + + var refB = Assert.IsType(treeB.Properties["next"]); + Assert.NotNull(refB.Target); + Assert.Same(treeB, refB.Target); + } + + [Fact] + public async Task DynamicAnchorInRefSiblingDefsIsRegistered() + { + // A $dynamicAnchor declared inside a $ref schema's $defs sibling must be registered, so a + // $dynamicRef elsewhere in the document resolves to it. (Main's model carries authored + // siblings on JsonSchemaReference, so the anchor walk must descend into them without + // following the $ref target.) + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Base: + type: object + Referencing: + $ref: '#/components/schemas/Base' + $defs: + node: + $dynamicAnchor: node + type: object + properties: + next: + $dynamicRef: '#node' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencing = doc.Components.Schemas["Referencing"]; + var nodeDef = referencing.Definitions["node"]; + var next = nodeDef.Properties["next"]; + + var reference = Assert.IsType(next); + Assert.NotNull(reference.Target); + Assert.Same(nodeDef, reference.Target); + } + + [Fact] + public async Task CreateShallowCopyPreservesValidationSiblings() + { + // CreateShallowCopy routes through the JsonSchemaReference copy constructor. Validation + // keyword siblings carried on a $ref schema (type, minProperties, pattern, allOf, etc.) + // must survive the copy, not just the JSON-Schema metadata siblings. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + type: object + minProperties: 2 + pattern: '^a' + allOf: + - type: object + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencing = doc.Components.Schemas["Referencing"]; + var copy = referencing.CreateShallowCopy(); + + Assert.IsType(copy); + Assert.Equal(JsonSchemaType.Object, copy.Type); + Assert.Equal(2, copy.MinProperties); + Assert.Equal("^a", copy.Pattern); + Assert.NotNull(copy.AllOf); + Assert.Single(copy.AllOf); + } + + [Fact] + public async Task InlineDynamicAnchorInResponseIsRegistered() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: + /endpoint-a: + get: + responses: + '200': + description: A + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/TypeA' + $ref: '#/components/schemas/Paged' + /endpoint-b: + get: + responses: + '200': + description: B + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/TypeB' + $ref: '#/components/schemas/Paged' + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + TypeA: + type: object + properties: + name: + type: string + TypeB: + type: object + properties: + title: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidates = doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType"); + Assert.Equal(2, candidates.Count); + + var paged = doc.Components.Schemas["Paged"]; + var itemsSchema = paged.Properties["content"].Items; + var itemsRef = Assert.IsType(itemsSchema); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.Null(itemsRef.Target); + + var endpointASchema = doc.Paths["/endpoint-a"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + var resolvedA = OpenApiWorkspace.ResolveDynamicAnchorInContext(endpointASchema, "itemType"); + Assert.NotNull(resolvedA); + var resolvedARef = Assert.IsType(resolvedA); + Assert.NotNull(resolvedARef.Target); + Assert.Same(doc.Components.Schemas["TypeA"], resolvedARef.Target); + + var endpointBSchema = doc.Paths["/endpoint-b"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + var resolvedB = OpenApiWorkspace.ResolveDynamicAnchorInContext(endpointBSchema, "itemType"); + Assert.NotNull(resolvedB); + var resolvedBRef = Assert.IsType(resolvedB); + Assert.NotNull(resolvedBRef.Target); + Assert.Same(doc.Components.Schemas["TypeB"], resolvedBRef.Target); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextReturnsNullForNoMatch() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + var foo = doc.Components.Schemas["Foo"]; + + var result = OpenApiWorkspace.ResolveDynamicAnchorInContext(foo, "nonexistent"); + Assert.Null(result); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceHolders() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $defs: + node: + $dynamicAnchor: node + type: object + properties: + name: + type: string + Referrer: + $ref: '#/components/schemas/Target' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referrer = doc.Components.Schemas["Referrer"]; + var refSchema = Assert.IsType(referrer); + + var result = OpenApiWorkspace.ResolveDynamicAnchorInContext(refSchema, "node"); + Assert.Null(result); + } + + [Fact] + public void DynamicRefResolvesInObjectModelBuiltDocument() + { + var doc = new OpenApiDocument(); + var tree = new OpenApiSchema + { + DynamicAnchor = "node", + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["next"] = new OpenApiSchemaReference("node", doc) + { + Reference = { DynamicRef = "#node" } + } + } + }; + doc.AddComponent("Tree", tree); + doc.Workspace = new OpenApiWorkspace(); + doc.Workspace.RegisterComponents(doc); + + var nextRef = Assert.IsType(tree.Properties["next"]); + nextRef.Reference.SetJsonPointerPath("#node", "#/components/schemas/Tree/properties/next"); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + Assert.Same(tree, nextRef.Target); + } + + [Fact] + public async Task ExternalDynamicRefResolvesToPlainAnchorInExternalDocument() + { + // Document B has only $anchor (no $dynamicAnchor). FindDocumentByBaseUri must search + // both registries so the $anchor fallback works for URI-based $dynamicRef. + var yamlA = + """ + openapi: 3.2.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var yamlB = + """ + openapi: 3.2.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $anchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main"); + docB.BaseUri = new("https://example.com/external"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("https://example.com/external", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiEncodingTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiEncodingTests.cs index 166f6fd12..6f8574421 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiEncodingTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiEncodingTests.cs @@ -18,7 +18,7 @@ public class OpenApiEncodingTests public async Task ParseEncodingWithAllowReservedShouldSucceed() { // Act - var encoding = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "encodingWithAllowReserved.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "encodingWithAllowReserved.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -35,7 +35,7 @@ public async Task ParseEncodingWithAllowReservedShouldSucceed() public async Task ParseEncodingWithNestedEncodingShouldSucceed() { // Act - var encoding = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "encodingWithNestedEncoding.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "encodingWithNestedEncoding.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(encoding); @@ -56,7 +56,7 @@ public async Task ParseEncodingWithNestedEncodingShouldSucceed() public async Task ParseEncodingWithItemAndPrefixEncodingShouldSucceed() { // Act - var encoding = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "encodingWithItemAndPrefixEncoding.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "encodingWithItemAndPrefixEncoding.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(encoding); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiExampleTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiExampleTests.cs index 5d586f469..9a9b5f3ea 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiExampleTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiExampleTests.cs @@ -19,10 +19,10 @@ public async Task ParseExampleWithDataValueShouldSucceed() { // Arrange & Act var example = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "exampleWithDataValue.yaml"), + Path.Join(SampleFolderPath, "exampleWithDataValue.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(example); @@ -37,10 +37,10 @@ public async Task ParseExampleWithSerializedValueShouldSucceed() { // Arrange & Act var example = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "exampleWithSerializedValue.yaml"), + Path.Join(SampleFolderPath, "exampleWithSerializedValue.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(example); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiHeaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiHeaderTests.cs index f1d28eced..3e6b63314 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiHeaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiHeaderTests.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Xunit; @@ -19,10 +18,10 @@ public class OpenApiHeaderTests public async Task ParseBasicHeaderShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicHeader.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicHeader.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -40,10 +39,10 @@ public async Task ParseBasicHeaderShouldSucceed() public async Task ParseHeaderWithContentShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithContent.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -79,10 +78,10 @@ public async Task ParseHeaderWithContentShouldSucceed() public async Task ParseHeaderWithMultipleContentTypesShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithMultipleContentTypes.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithMultipleContentTypes.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -120,10 +119,10 @@ public async Task ParseHeaderWithMultipleContentTypesShouldSucceed() public async Task ParseHeaderWithStyleAndContentShouldPreferContent() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithStyleAndContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithStyleAndContent.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_2, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert // Both content and style can be present, content takes precedence for serialization behavior diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiInfoTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiInfoTests.cs index 6b21cac41..111ba07a0 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiInfoTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiInfoTests.cs @@ -15,7 +15,7 @@ public class OpenApiInfoTests [Fact] public void ParseBasicInfoShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicInfo.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicInfo.yaml")); var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); var yamlNode = yamlStream.Documents[0].RootNode; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiLicenseTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiLicenseTests.cs index 6a7f9a74e..9e940c3f1 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiLicenseTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiLicenseTests.cs @@ -18,7 +18,7 @@ public class OpenApiLicenseTests [Fact] public void ParseLicenseWithSpdxIdentifierShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "licenseWithSpdxIdentifier.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "licenseWithSpdxIdentifier.yaml")); var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); var yamlNode = yamlStream.Documents[0].RootNode; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiMediaTypeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiMediaTypeTests.cs index 903ffd84c..d3716b2d6 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiMediaTypeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiMediaTypeTests.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V32; using Microsoft.OpenApi.Tests; @@ -22,10 +21,10 @@ public class OpenApiMediaTypeTests public async Task ParseMediaTypeWithExampleShouldSucceed() { // Act - var mediaType = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "mediaTypeWithExample.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var mediaType = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "mediaTypeWithExample.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - mediaType.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiMediaType { Example = 5, @@ -34,19 +33,17 @@ public async Task ParseMediaTypeWithExampleShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(m => m.Example.Parent) - ); + }, mediaType, nameof(JsonNode.Parent)); } [Fact] public async Task ParseMediaTypeWithExamplesShouldSucceed() { // Act - var mediaType = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "mediaTypeWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var mediaType = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "mediaTypeWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - mediaType.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiMediaType { Examples = new Dictionary @@ -65,9 +62,7 @@ public async Task ParseMediaTypeWithExamplesShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(m => m.Examples["example1"].Value.Parent) - .Excluding(m => m.Examples["example2"].Value.Parent)); + }, mediaType, nameof(JsonNode.Parent)); } [Fact] @@ -103,14 +98,14 @@ public async Task ParseMediaTypeWithEmptyArrayInExamplesWorks() } "; JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "examplesWithEmptyArray.json"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "examplesWithEmptyArray.json"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var mediaType = OpenApiV32Deserializer.LoadMediaType(node, new(), new ParsingContext(new())); - var serialized = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var serialized = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), serialized.MakeLineBreaksEnvironmentNeutral()); @@ -121,10 +116,10 @@ public async Task ParseMediaTypeWithItemSchemaShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithItemSchema.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithItemSchema.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert var expected = new OpenApiMediaType @@ -147,10 +142,10 @@ public async Task ParseMediaTypeWithItemEncodingShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithItemEncoding.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithItemEncoding.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(mediaType); @@ -166,10 +161,10 @@ public async Task ParseMediaTypeWithPrefixEncodingShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithPrefixEncoding.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithPrefixEncoding.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(mediaType); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs index 66d2364c2..98e32a126 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs @@ -16,7 +16,7 @@ public class OpenApiPathItemDeserializerTests public async Task ParsePathItemWithQueryAndAdditionalOperationsV32Works() { // Arrange & Act - var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var pathItem = result.Document.Paths["/pets"]; // Assert diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs index 621cd156c..184a2e744 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs @@ -6,13 +6,13 @@ using System.Linq; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Equivalency; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Tests; using Xunit; using System; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Readers.Tests.V32Tests { public class OpenApiSchemaTests @@ -22,7 +22,7 @@ public class OpenApiSchemaTests public static MemoryStream GetMemoryStream(string fileName) { - var filePath = Path.Combine(SampleFolderPath, fileName); + var filePath = Path.Join(SampleFolderPath, fileName); var fileBytes = File.ReadAllBytes(filePath); return new MemoryStream(fileBytes); } @@ -44,7 +44,13 @@ public async Task ParseBasicV32SchemaShouldSucceed() Items = new OpenApiSchema { Type = JsonSchemaType.String - } + }, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String + }, + MinContains = 1, + MaxContains = 5 }, ["vegetables"] = new OpenApiSchema { @@ -99,7 +105,7 @@ public async Task ParseBasicV32SchemaShouldSucceed() // Act var schema = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "jsonSchema.json"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + Path.Join(SampleFolderPath, "jsonSchema.json"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent(expectedObject, schema); @@ -134,13 +140,15 @@ public void ParseSchemaWithTypeArrayWorks() [Theory] [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")] [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")] - public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson) + public void ParseSchemaWithNullableBeforeOrAfterTypeDoesNotPreserveNullFlag(string schemaJson) { + // "nullable" is only for 3.0. + // Act var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_2, new(), out _, "json", SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -181,10 +189,10 @@ public void TestSchemaCopyConstructorWithTypeArrayWorks() [Fact] public async Task ParseV32SchemaShouldSucceed() { - var path = Path.Combine(SampleFolderPath, "schema.yaml"); + var path = Path.Join(SampleFolderPath, "schema.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expectedSchema = new OpenApiSchema { Type = JsonSchemaType.Object, @@ -206,8 +214,8 @@ public async Task ParseV32SchemaShouldSucceed() public async Task ParseAdvancedV32SchemaShouldSucceed() { // Arrange and Act - var path = Path.Combine(SampleFolderPath, "advancedSchema.yaml"); - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "advancedSchema.yaml"); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expectedSchema = new OpenApiSchema { @@ -281,10 +289,7 @@ public async Task ParseAdvancedV32SchemaShouldSucceed() }; // Assert - schema.Should().BeEquivalentTo(expectedSchema, options => options - .IgnoringCyclicReferences() - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Parent"))); + OpenApiTestAssert.Equivalent(expectedSchema, schema, nameof(JsonNode.Parent)); } [Fact] @@ -334,10 +339,10 @@ public async Task SerializeV32SchemaWithMultipleTypesAsV3Works() var expected = @"type: string nullable: true"; - var path = Path.Combine(SampleFolderPath, "schemaWithTypeArray.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithTypeArray.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV3(new OpenApiYamlWriter(writer)); @@ -353,10 +358,10 @@ public async Task SerializeV32SchemaWithMultipleTypesAsV2Works() var expected = @"type: string x-nullable: true"; - var path = Path.Combine(SampleFolderPath, "schemaWithTypeArray.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithTypeArray.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV2(new OpenApiYamlWriter(writer)); @@ -373,10 +378,10 @@ public async Task SerializeV3SchemaWithNullableAsV32Works() - 'null' - string"; - var path = Path.Combine(SampleFolderPath, "schemaWithNullable.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithNullable.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV32(new OpenApiYamlWriter(writer)); @@ -393,10 +398,10 @@ public async Task SerializeV2SchemaWithNullableExtensionAsV32Works() - 'null' - string"; - var path = Path.Combine(SampleFolderPath, "schemaWithNullableExtension.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithNullableExtension.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV32(new OpenApiYamlWriter(writer)); @@ -413,7 +418,7 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() - ""int"" nullable: true"; - var expected = @"x-nullable: true"; + var expected = @"{ }"; var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_2, new(), out _, "yaml", SettingsFixture.ReaderSettings); @@ -427,16 +432,19 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() [Theory] [InlineData("schemaWithNullable.yaml")] [InlineData("schemaWithNullableExtension.yaml")] - public async Task LoadSchemaWithNullableExtensionAsV32Works(string filePath) + public async Task LoadSchemaWithNullableExtensionAsV32ShouldNotWork(string filePath) { + // "nullable" is only for 3.0. + // and "x-nullable" is only for 2.0. + // Arrange - var path = Path.Combine(SampleFolderPath, filePath); + var path = Path.Join(SampleFolderPath, filePath); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -470,10 +478,10 @@ public async Task SerializeSchemaWithJsonSchemaKeywordsWorks() $dynamicRef: '#addressDef' description: Schema for a person object "; - var path = Path.Combine(SampleFolderPath, "schemaWithJsonSchemaKeywords.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithJsonSchemaKeywords.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // serialization var writer = new StringWriter(); @@ -514,10 +522,10 @@ public async Task ParseSchemaWithConstWorks() } }"; - var path = Path.Combine(SampleFolderPath, "schemaWithConst.json"); + var path = Path.Join(SampleFolderPath, "schemaWithConst.json"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.Equal("active", schema.Properties["status"].Const); Assert.Equal("admin", schema.Properties["user"].Properties["role"].Const); @@ -528,6 +536,55 @@ public async Task ParseSchemaWithConstWorks() Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral()); } + [Fact] + public async Task ParseSchemaWithConstNullWorks() + { + var expected = @"{ + ""$schema"": ""https://json-schema.org/draft/2020-12/schema"", + ""required"": [ + ""status"" + ], + ""type"": ""object"", + ""properties"": { + ""status"": { + ""const"": null, + ""type"": ""string"" + }, + ""user"": { + ""required"": [ + ""role"" + ], + ""type"": ""object"", + ""properties"": { + ""role"": { + ""const"": null, + ""type"": ""string"" + } + } + } + } +}"; + + var path = Path.Join(SampleFolderPath, "schemaWithConstNull.json"); + + // Act + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); + + var statusSchema = Assert.IsType(schema.Properties["status"]); + Assert.Null(statusSchema.Const); + Assert.True(statusSchema.WasConstExplicitlySet); + + var userRoleSchema = Assert.IsType(schema.Properties["user"].Properties["role"]); + Assert.Null(userRoleSchema.Const); + Assert.True(userRoleSchema.WasConstExplicitlySet); + + // serialization + var writer = new StringWriter(); + schema.SerializeAsV32(new OpenApiJsonWriter(writer)); + var schemaString = writer.ToString(); + Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral()); + } + [Fact] public void ParseSchemaWithUnrecognizedKeywordsWorks() { @@ -708,6 +765,50 @@ public void ParseSchemaWithUnevaluatedPropertiesComplexSchema() Assert.Equivalent(expected, actual); } + [Fact] + public void ParseSchemaWithMissingJsonSchemaProperties() + { + var schema = @"{ + ""$anchor"": ""root"", + ""contentEncoding"": ""base64"", + ""contentMediaType"": ""application/jwt"", + ""contentSchema"": { + ""type"": ""array"" + }, + ""propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""if"": { + ""required"": [""token""] + }, + ""then"": { + ""minProperties"": 1 + }, + ""else"": { + ""maxProperties"": 0 + } +}"; + + var actual = OpenApiModelFactory.Parse(schema, OpenApiSpecVersion.OpenApi3_2, new(), out _); + var missingProperties = Assert.IsAssignableFrom(actual); + + Assert.Equal("root", missingProperties.Anchor); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData("{}")] [InlineData("true")] @@ -736,6 +837,265 @@ public void DeserializeFalseSchemaParsesAsNotEmptySchema() Assert.Empty(schema.Not.AllOf ?? []); Assert.Empty(schema.Not.OneOf ?? []); } + + [Fact] + public async Task ParseSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + description: Sibling description + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert — siblings are preserved on the OpenApiSchemaReference + Assert.IsType(referencing); + Assert.Equal("Sibling description", referencing.Description); + Assert.Equal("anchor", referencing.DynamicAnchor); + Assert.NotNull(referencing.Definitions); + Assert.True(referencing.Definitions.ContainsKey("sibling")); + Assert.Equal("inner", referencing.Definitions["sibling"].DynamicAnchor); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var writer = new StringWriter(); + result.Document.SerializeAsV32(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves $dynamicAnchor and $defs alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(roundTripResult.Document.Components); + Assert.NotNull(roundTripResult.Document.Components.Schemas); + var referencing = roundTripResult.Document.Components.Schemas["Referencing"]; + + Assert.IsType(referencing); + Assert.Equal("anchor", referencing.DynamicAnchor); + Assert.NotNull(referencing.Definitions); + Assert.True(referencing.Definitions.ContainsKey("itemType")); + Assert.Equal("itemType", referencing.Definitions["itemType"].DynamicAnchor); + } + + [Fact] + public async Task ParseSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Scalar sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert + Assert.IsType(referencing); + Assert.Equal("https://example.com/referencing.json", referencing.Id); + Assert.Equal(new Uri("https://json-schema.org/draft/2020-12/schema"), referencing.Schema); + Assert.Equal("A comment sibling", referencing.Comment); + Assert.Equal("myAnchor", ((IOpenApiSchemaMissingProperties)referencing).Anchor); + Assert.Equal("#myAnchor", referencing.DynamicRef); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Scalar round-trip + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + var writer = new StringWriter(); + result.Document.SerializeAsV32(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves scalar siblings alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(roundTripResult.Document.Components); + Assert.NotNull(roundTripResult.Document.Components.Schemas); + var referencing = roundTripResult.Document.Components.Schemas["Referencing"]; + + Assert.IsType(referencing); + Assert.Equal("https://example.com/referencing.json", referencing.Id); + Assert.Equal(new Uri("https://json-schema.org/draft/2020-12/schema"), referencing.Schema); + Assert.Equal("A comment sibling", referencing.Comment); + Assert.Equal("myAnchor", ((IOpenApiSchemaMissingProperties)referencing).Anchor); + Assert.Equal("#myAnchor", referencing.DynamicRef); + } + + [Fact] + public async Task ParseSchemaReferencePreservesVocabularySibling() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Vocabulary sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + 'https://json-schema.org/draft/2020-12/vocab/applicator': false + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var referencing = result.Document.Components.Schemas["Referencing"]; + + // Assert + Assert.IsType(referencing); + Assert.NotNull(referencing.Vocabulary); + Assert.Equal(2, referencing.Vocabulary.Count); + Assert.True(referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"]); + Assert.False(referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"]); + } + + [Fact] + public async Task ParseSchemaReferencePreservesDynamicAnchorInsideDefsInAllOf() + { + // Arrange — the allOf-based binding variant: $defs sits inside allOf[0], + // and the nested schema has $ref + $dynamicAnchor (the binding entry). + var yaml = """ + openapi: 3.2.0 + info: + title: allOf binding variant + version: 1.0.0 + paths: {} + components: + schemas: + Asset: + type: object + properties: + id: + type: string + Paged: + type: object + properties: + items: + type: array + AssetPaged: + allOf: + - $defs: + contentType: + $dynamicAnchor: contentType + $ref: '#/components/schemas/Asset' + - $ref: '#/components/schemas/Paged' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result.Document.Components); + Assert.NotNull(result.Document.Components.Schemas); + var assetPaged = result.Document.Components.Schemas["AssetPaged"]; + + // Assert + Assert.NotNull(assetPaged.AllOf); + Assert.Equal(2, assetPaged.AllOf.Count); + var defsHolder = assetPaged.AllOf[0]; + Assert.NotNull(defsHolder.Definitions); + Assert.True(defsHolder.Definitions.ContainsKey("contentType")); + var contentType = defsHolder.Definitions["contentType"]; + Assert.IsType(contentType); + Assert.Equal("contentType", contentType.DynamicAnchor); + } } } - diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs index 50c836f66..d0f477617 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSecuritySchemeTests.cs @@ -17,7 +17,7 @@ public class OpenApiSecuritySchemeTests public async Task ParseHttpSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "httpSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "httpSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -32,7 +32,7 @@ public async Task ParseHttpSecuritySchemeShouldSucceed() public async Task ParseApiKeySecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "apiKeySecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "apiKeySecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -48,7 +48,7 @@ public async Task ParseApiKeySecuritySchemeShouldSucceed() public async Task ParseBearerSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "bearerSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "bearerSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -64,7 +64,7 @@ public async Task ParseBearerSecuritySchemeShouldSucceed() public async Task ParseOAuth2SecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "oauth2SecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "oauth2SecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -91,10 +91,10 @@ public async Task ParseOAuth2SecuritySchemeWithMetadataUrlShouldSucceed() { // Act var securityScheme = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "oauth2SecuritySchemeWithMetadataUrl.yaml"), + Path.Join(SampleFolderPath, "oauth2SecuritySchemeWithMetadataUrl.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -120,7 +120,7 @@ public async Task ParseOAuth2SecuritySchemeWithMetadataUrlShouldSucceed() public async Task ParseOpenIdConnectSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "openIdConnectSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "openIdConnectSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -137,10 +137,10 @@ public async Task ParseMutualTlsSecuritySchemeShouldSucceed() { // Act var securityScheme = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mutualTlsSecurityScheme.yaml"), + Path.Join(SampleFolderPath, "mutualTlsSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -156,10 +156,10 @@ public async Task ParseOAuth2SecuritySchemeWithDeviceAuthorizationUrlShouldSucce { // Act var securityScheme = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "oauth2SecuritySchemeWithDeviceUrl.yaml"), + Path.Join(SampleFolderPath, "oauth2SecuritySchemeWithDeviceUrl.yaml"), OpenApiSpecVersion.OpenApi3_2, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(securityScheme); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/RelativeReferenceTests.cs index 2171f35aa..87080b921 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/RelativeReferenceTests.cs @@ -17,10 +17,10 @@ public class RelativeReferenceTests public async Task ParseInlineLocalReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "inlineLocalReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "inlineLocalReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schemaType = actual.Paths["/item"].Operations[HttpMethod.Get].Parameters[0].Schema.Type; // Assert @@ -40,7 +40,7 @@ public async Task ParseInlineExternalReferenceWorks() } }; - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { @@ -50,7 +50,7 @@ public async Task ParseInlineExternalReferenceWorks() settings.AddYamlReader(); // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "inlineExternalReference.yaml"), settings)).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "inlineExternalReference.yaml"), settings, token: TestContext.Current.CancellationToken)).Document; var exampleValue = actual.Paths["/items"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Examples["item-list"].Value; // Assert @@ -63,7 +63,7 @@ public async Task ParseInlineExternalReferenceWorks() public async Task ParseComponentExternalReferenceWorks() { // Arrange - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { LoadExternalRefs = true, @@ -72,7 +72,7 @@ public async Task ParseComponentExternalReferenceWorks() settings.AddYamlReader(); // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "componentExternalReference.yaml"), settings)).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "componentExternalReference.yaml"), settings, token: TestContext.Current.CancellationToken)).Document; var securitySchemeValue = actual.Components.SecuritySchemes["customapikey"]; // Assert @@ -83,10 +83,10 @@ public async Task ParseComponentExternalReferenceWorks() public async Task ParseRootInlineJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "rootInlineSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "rootInlineSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/item"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert @@ -97,10 +97,10 @@ public async Task ParseRootInlineJsonSchemaReferenceWorks() public async Task ParseSubschemaInlineJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "subschemaInlineSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "subschemaInlineSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/items"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema.Items; // Assert @@ -111,10 +111,10 @@ public async Task ParseSubschemaInlineJsonSchemaReferenceWorks() public async Task ParseRootComponentJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "rootComponentSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "rootComponentSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Components.Schemas["specialitem"]; // Assert @@ -126,10 +126,10 @@ public async Task ParseRootComponentJsonSchemaReferenceWorks() public async Task ParseSubschemaComponentJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "subschemaComponentSchemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "subschemaComponentSchemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Components.Schemas["items"].Items; // Assert @@ -140,10 +140,10 @@ public async Task ParseSubschemaComponentJsonSchemaReferenceWorks() public async Task ParseInternalComponentSubschemaJsonSchemaReferenceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "internalComponentsSubschemaReference.yaml"); + var filePath = Path.Join(SampleFolderPath, "internalComponentsSubschemaReference.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var addressSchema = actual.Paths["/person/{id}/address"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; var itemsSchema = actual.Paths["/human"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; @@ -156,7 +156,7 @@ public async Task ParseInternalComponentSubschemaJsonSchemaReferenceWorks() public async Task ParseExternalComponentSubschemaJsonSchemaReferenceWorks() { // Arrange - var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath); + var path = Path.Join(Directory.GetCurrentDirectory(), SampleFolderPath); var settings = new OpenApiReaderSettings { LoadExternalRefs = true, @@ -165,37 +165,205 @@ public async Task ParseExternalComponentSubschemaJsonSchemaReferenceWorks() settings.AddYamlReader(); // Act - var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalComponentSubschemaReference.yaml"), settings)).Document; + var actual = (await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalComponentSubschemaReference.yaml"), settings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert Assert.Equal(JsonSchemaType.Object, schema.Type); } + [Fact] + public async Task ParseExternalInlineSubschemaUsingAnchorWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var externalPath = Path.Join(tempDirectory, "OAS-schemas.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.2.0 +info: + title: T + version: 1.0.0 +paths: + /person/{id}: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'OAS-schemas.yaml#address' +", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(externalPath, + @" +openapi: 3.2.0 +info: + title: OpenAPI document containing reusable components + version: 1.0.0 +components: + schemas: + person: + type: object + properties: + address: + $anchor: address + type: object +", TestContext.Current.CancellationToken); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings, token: TestContext.Current.CancellationToken); + var schema = result.Document.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + + [Fact] + public async Task ExternalJsonSchemaFragmentReferenceDoesNotResolve() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var externalPath = Path.Join(tempDirectory, "OAS-schemas.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.2.0 +info: + title: T + version: 1.0.0 +paths: + /person/{id}: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'OAS-schemas.yaml#/person/properties/address' +", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(externalPath, + @" +person: + type: object + properties: + address: + type: object +", TestContext.Current.CancellationToken); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings, token: TestContext.Current.CancellationToken); + var schema = result.Document.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + var schemaReference = Assert.IsType(schema); + + Assert.True(schemaReference.UnresolvedReference); + Assert.Null(schema.Type); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public async Task ParseReferenceToInternalComponentUsingDollarIdWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "internalComponentReferenceUsingId.yaml"); + var filePath = Path.Join(SampleFolderPath, "internalComponentReferenceUsingId.yaml"); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; // Assert Assert.Equal(JsonSchemaType.Object, schema.Type); } + [Fact] + public async Task ParseReferenceToInternalSubschemaUsingDollarIdWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.2.0 +info: + title: T + version: 1.0.0 +paths: + /person/{id}/address: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'https://schemas.acme.org/address' +components: + schemas: + person: + $id: 'https://schemas.acme.org/person' + type: object + properties: + address: + $id: 'address' + type: object + properties: + street: + type: string +", TestContext.Current.CancellationToken); + + try + { + var result = await OpenApiDocument.LoadAsync(rootPath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); + var schema = result.Document.Paths["/person/{id}/address"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Properties["street"].Type); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public async Task ParseLocalReferenceToJsonSchemaResourceWorks() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "localReferenceToJsonSchemaResource.yaml"); + var filePath = Path.Join(SampleFolderPath, "localReferenceToJsonSchemaResource.yaml"); var stringWriter = new StringWriter(); var writer = new OpenApiYamlWriter(stringWriter); // Act - var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document; + var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var schema = actual.Components.Schemas["a"].Properties["b"].Properties["c"].Properties["b"]; schema.SerializeAsV32(writer); @@ -284,10 +452,10 @@ public void ResolveSubSchema_ShouldRecurseIntoAllOfComposition() public async Task ShouldResolveRelativeSubReference() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "relativeSubschemaReference.json"); + var filePath = Path.Join(SampleFolderPath, "relativeSubschemaReference.json"); // Act - var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var fooComponentSchema = actual.Components.Schemas["Foo"]; var seq1Property = fooComponentSchema.Properties["seq1"]; @@ -301,9 +469,9 @@ public async Task ShouldResolveRelativeSubReference() public async Task ShouldResolveRelativeSubReferenceUsingParsingContext() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "relativeSubschemaReference.json"); + var filePath = Path.Join(SampleFolderPath, "relativeSubschemaReference.json"); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); - var jsonNode = await JsonNode.ParseAsync(fs); + var jsonNode = await JsonNode.ParseAsync(fs, cancellationToken: TestContext.Current.CancellationToken); var schemaJsonNode = jsonNode["components"]?["schemas"]?["Foo"]; Assert.NotNull(schemaJsonNode); var diagnostic = new OpenApiDiagnostic(); @@ -394,10 +562,10 @@ public void ShouldResolveAbsoluteSubReferenceFromTheObjectModel() public async Task ShouldResolveRecursiveRelativeSubReference() { // Arrange - var filePath = Path.Combine(SampleFolderPath, "recursiveRelativeSubschemaReference.json"); + var filePath = Path.Join(SampleFolderPath, "recursiveRelativeSubschemaReference.json"); // Act - var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var fooComponentSchema = actual.Components.Schemas["Foo"]; var fooSchemaParentProperty = fooComponentSchema.Properties["parent"]; @@ -415,9 +583,9 @@ public async Task ShouldResolveRecursiveRelativeSubReference() [Fact] public async Task ShouldResolveReferencesInSchemasFromSystemTextJson() { - var filePath = Path.Combine(SampleFolderPath, "STJSchema.json"); + var filePath = Path.Join(SampleFolderPath, "STJSchema.json"); using var fs = File.OpenRead(filePath); - var jsonNode = await JsonNode.ParseAsync(fs); + var jsonNode = await JsonNode.ParseAsync(fs, cancellationToken: TestContext.Current.CancellationToken); var parsingContext = new ParsingContext(new OpenApiDiagnostic()); var document = new OpenApiDocument(); @@ -512,4 +680,3 @@ public void ExitsEarlyOnCyclicalReferences() } } } - diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json index dc55b72c2..c14fee6c1 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json @@ -8,7 +8,12 @@ "type": "array", "items": { "type": "string" - } + }, + "contains": { + "type": "string" + }, + "minContains": 1, + "maxContains": 5 }, "vegetables": { "type": "array" diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/schemaWithConstNull.json b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/schemaWithConstNull.json new file mode 100644 index 000000000..586d2f726 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/schemaWithConstNull.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "status": { + "type": "string", + "const": null + }, + "user": { + "type": "object", + "properties": { + "role": { + "type": "string", + "const": null + } + }, + "required": [ "role" ] + } + }, + "required": [ "status" ] +} + diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiCallbackTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiCallbackTests.cs index e7982160c..e040b6ba5 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiCallbackTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiCallbackTests.cs @@ -19,7 +19,7 @@ public class OpenApiCallbackTests public async Task ParseBasicCallbackShouldSucceed() { // Act - var callback = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "basicCallback.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var callback = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "basicCallback.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -59,10 +59,10 @@ public async Task ParseBasicCallbackShouldSucceed() [Fact] public async Task ParseCallbackWithReferenceShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "callbackWithReference.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "callbackWithReference.yaml")); // Act - var result = await OpenApiModelFactory.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings); + var result = await OpenApiModelFactory.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); // Assert var path = result.Document.Paths.First().Value; @@ -113,7 +113,7 @@ public async Task ParseCallbackWithReferenceShouldSucceed() public async Task ParseMultipleCallbacksWithReferenceShouldSucceed() { // Act - var result = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "multipleCallbacksWithReference.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "multipleCallbacksWithReference.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert var path = result.Document.Paths.First().Value; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDiscriminatorTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDiscriminatorTests.cs index 5e4a36d91..7ef4ffcfe 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDiscriminatorTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDiscriminatorTests.cs @@ -18,10 +18,10 @@ public class OpenApiDiscriminatorTests public async Task ParseBasicDiscriminatorShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicDiscriminator.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicDiscriminator.yaml")); // Copy stream to MemoryStream using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream); + await stream.CopyToAsync(memoryStream, TestContext.Current.CancellationToken); memoryStream.Position = 0; // Act diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs index 2e278ca0e..7034eb46d 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs @@ -13,7 +13,6 @@ using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Tests; using Moq; @@ -55,7 +54,7 @@ public void ParseDocumentFromInlineStringShouldSucceed() paths: {}", OpenApiConstants.Yaml, SettingsFixture.ReaderSettings); - result.Document.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiDocument { Info = new OpenApiInfo @@ -64,7 +63,7 @@ public void ParseDocumentFromInlineStringShouldSucceed() Version = "0.9.1" }, Paths = new OpenApiPaths() - }, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + }, result.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent( new OpenApiDiagnostic() @@ -92,11 +91,11 @@ public void ParseInlineStringWithoutProvidingFormatSucceeds() [Fact] public async Task ParseBasicDocumentWithMultipleServersShouldSucceed() { - var path = Path.Combine(SampleFolderPath, "basicDocumentWithMultipleServers.yaml"); - var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings); + var path = Path.Join(SampleFolderPath, "basicDocumentWithMultipleServers.yaml"); + var result = await OpenApiDocument.LoadAsync(path, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.Empty(result.Diagnostic.Errors); - result.Document.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiDocument { Info = new OpenApiInfo @@ -118,20 +117,20 @@ public async Task ParseBasicDocumentWithMultipleServersShouldSucceed() } ], Paths = new OpenApiPaths() - }, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + }, result.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); } [Fact] public async Task ParseBrokenMinimalDocumentShouldYieldExpectedDiagnostic() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "brokenMinimalDocument.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "brokenMinimalDocument.yaml")); // Copy stream to MemoryStream using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream); + await stream.CopyToAsync(memoryStream, TestContext.Current.CancellationToken); memoryStream.Position = 0; - var result = await OpenApiDocument.LoadAsync(memoryStream, settings: SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(memoryStream, settings: SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); - result.Document.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiDocument { Info = new OpenApiInfo @@ -139,7 +138,7 @@ public async Task ParseBrokenMinimalDocumentShouldYieldExpectedDiagnostic() Version = "0.9" }, Paths = new OpenApiPaths() - }, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + }, result.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent( new OpenApiDiagnostic @@ -153,12 +152,46 @@ public async Task ParseBrokenMinimalDocumentShouldYieldExpectedDiagnostic() }, result.Diagnostic); } + [Fact] + public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic() + { + var result = OpenApiDocument.Parse( + """ + openapi: 3.0.3 + info: + title: test + version: 1.0.0 + paths: + /test: + get: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#components/schemas/Item' + components: + schemas: + Item: + type: object + properties: + id: + type: integer + """, + OpenApiConstants.Yaml, + SettingsFixture.ReaderSettings); + + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message); + } + [Fact] public async Task ParseMinimalDocumentShouldSucceed() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "minimalDocument.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "minimalDocument.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); - result.Document.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiDocument { Info = new OpenApiInfo @@ -167,7 +200,7 @@ public async Task ParseMinimalDocumentShouldSucceed() Version = "0.9.1" }, Paths = new OpenApiPaths() - }, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + }, result.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent( new OpenApiDiagnostic() @@ -180,8 +213,8 @@ public async Task ParseMinimalDocumentShouldSucceed() [Fact] public async Task ParseStandardPetStoreDocumentShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "petStore.yaml")); - var actual = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "petStore.yaml")); + var actual = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); var components = new OpenApiComponents { @@ -557,7 +590,7 @@ public async Task ParseStandardPetStoreDocumentShouldSucceed() Components = components }; - actual.Document.Should().BeEquivalentTo(expectedDoc, options => options.Excluding(x => x.Workspace).Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(expectedDoc, actual.Document, nameof(OpenApiDocument.Workspace), nameof(OpenApiDocument.BaseUri)); Assert.Equivalent( new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_0, Format = OpenApiConstants.Yaml }, actual.Diagnostic); @@ -566,8 +599,8 @@ public async Task ParseStandardPetStoreDocumentShouldSucceed() [Fact] public async Task ParseModifiedPetStoreDocumentWithTagAndSecurityShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "petStoreWithTagAndSecurity.yaml")); - var actual = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "petStoreWithTagAndSecurity.yaml")); + var actual = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); var components = new OpenApiComponents { @@ -1026,12 +1059,10 @@ [new OpenApiSecuritySchemeReference("securitySchemeName2")] = expected.RegisterComponents(); expected.SetReferenceHostDocument(); - actual.Document.Should().BeEquivalentTo(expected, options => options - .IgnoringCyclicReferences() - .Excluding(ctx => ctx.Path.Contains("Paths[\"/pets\"].Operations[HttpMethod.Get].Tags")) - .Excluding(ctx => ctx.Path.Contains("Paths[\"/pets\"].Operations[HttpMethod.Post].Tags")) - .Excluding(x => x.Workspace) - .Excluding(y => y.BaseUri)); + OpenApiTestAssert.Equivalent(expected, actual.Document, path => + path.MemberName is nameof(OpenApiDocument.Workspace) or nameof(OpenApiDocument.BaseUri) + || path.Value.Contains($"{nameof(OpenApiDocument.Paths)}[/pets].{nameof(OpenApiPathItem.Operations)}[{HttpMethod.Get}].{nameof(OpenApiOperation.Tags)}") + || path.Value.Contains($"{nameof(OpenApiDocument.Paths)}[/pets].{nameof(OpenApiPathItem.Operations)}[{HttpMethod.Post}].{nameof(OpenApiOperation.Tags)}")); Assert.Equivalent( new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_0, Format = OpenApiConstants.Yaml }, actual.Diagnostic); @@ -1040,7 +1071,7 @@ [new OpenApiSecuritySchemeReference("securitySchemeName2")] = [Fact] public async Task ParsePetStoreExpandedShouldSucceed() { - var actual = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "petStoreExpanded.yaml"), SettingsFixture.ReaderSettings); + var actual = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "petStoreExpanded.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // TODO: Create the object in memory and compare with the one read from YAML file. @@ -1051,7 +1082,7 @@ public async Task ParsePetStoreExpandedShouldSucceed() [Fact] public async Task GlobalSecurityRequirementShouldReferenceSecurityScheme() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "securedApi.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "securedApi.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var securityRequirement = result.Document.Security[0]; @@ -1061,11 +1092,11 @@ public async Task GlobalSecurityRequirementShouldReferenceSecurityScheme() [Fact] public async Task HeaderParameterShouldAllowExample() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "apiWithFullHeaderComponent.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "apiWithFullHeaderComponent.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var exampleHeader = result.Document.Components?.Headers?["example-header"]; Assert.NotNull(exampleHeader); - exampleHeader.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiHeader() { Description = "Test header with example", @@ -1081,12 +1112,11 @@ public async Task HeaderParameterShouldAllowExample() Type = JsonSchemaType.String, Format = "uuid" }, - }, options => options.IgnoringCyclicReferences() - .Excluding(e => e.Example.Parent)); + }, exampleHeader, nameof(JsonNode.Parent)); var examplesHeader = result.Document.Components?.Headers?["examples-header"]; Assert.NotNull(examplesHeader); - examplesHeader.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiHeader() { Description = "Test header with example", @@ -1114,16 +1144,14 @@ public async Task HeaderParameterShouldAllowExample() Type = JsonSchemaType.String, Format = "uuid" }, - }, options => options.IgnoringCyclicReferences() - .Excluding(e => e.Examples["uuid1"].Value.Parent) - .Excluding(e => e.Examples["uuid2"].Value.Parent)); + }, examplesHeader, nameof(JsonNode.Parent)); } [Fact] public async Task ParseDocumentWithReferencedSecuritySchemeWorks() { // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithSecuritySchemeReference.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithSecuritySchemeReference.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var securityScheme = result.Document.Components.SecuritySchemes["OAuth2"]; // Assert @@ -1134,10 +1162,10 @@ public async Task ParseDocumentWithReferencedSecuritySchemeWorks() public async Task ParseDocumentWithJsonSchemaReferencesWorks() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "docWithJsonSchema.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "docWithJsonSchema.yaml")); // Act - var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken); var actualSchema = result.Document.Paths["/users/{userId}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; @@ -1150,7 +1178,7 @@ public async Task ParseDocumentWithJsonSchemaReferencesWorks() public async Task ValidateExampleShouldNotHaveDataTypeMismatch() { // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "documentWithDateExampleInSchema.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "documentWithDateExampleInSchema.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert var warnings = result.Diagnostic.Warnings; @@ -1215,7 +1243,7 @@ public async Task ParsesDoubleHopReferences() { using var stream = new MemoryStream(Encoding.UTF8.GetBytes(DoubleHopReferenceSerializedDoc)); - var (document, _) = await OpenApiDocument.LoadAsync(stream); + var (document, _) = await OpenApiDocument.LoadAsync(stream, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(document); var petReferenceInResponse = Assert.IsType(document.Paths["/pets"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema); @@ -1297,7 +1325,7 @@ public async Task SerializesDoubleHopeReferences() using var stringWriter = new StringWriter(); var writer = new OpenApiJsonWriter(stringWriter); document.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var serializedDoc = stringWriter.ToString(); @@ -1384,25 +1412,19 @@ public async Task ParseDocWithRefsUsingProxyReferencesSucceeds() default: 10 """; - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "minifiedPetStore.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "minifiedPetStore.yaml")); // Act - var doc = (await OpenApiDocument.LoadAsync(stream, settings: SettingsFixture.ReaderSettings)).Document; + var doc = (await OpenApiDocument.LoadAsync(stream, settings: SettingsFixture.ReaderSettings, cancellationToken: TestContext.Current.CancellationToken)).Document; var actualParam = doc.Paths["/pets"].Operations[HttpMethod.Get].Parameters[0]; - var outputDoc = (await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0)).MakeLineBreaksEnvironmentNeutral(); + var outputDoc = (await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken)).MakeLineBreaksEnvironmentNeutral(); var expectedParam = expected.Paths["/pets"].Operations[HttpMethod.Get].Parameters[0]; var expectedParamReference = Assert.IsType(expectedParam); var actualParamReference = Assert.IsType(actualParam); // Assert - actualParamReference.Should().BeEquivalentTo(expectedParamReference, options => options - .Excluding(x => x.Reference) - .Excluding(x => x.Target) - .Excluding(x => x.RecursiveTarget) - .Excluding(x => x.Schema.Default.Parent) - .Excluding(x => x.Schema.Default.Options) - .IgnoringCyclicReferences()); + OpenApiTestAssert.Equivalent(expectedParamReference, actualParamReference, nameof(OpenApiParameterReference.Reference), nameof(OpenApiParameterReference.Target), nameof(OpenApiParameterReference.RecursiveTarget), nameof(JsonNode.Parent), nameof(JsonNode.Options)); Assert.Equal(expectedSerializedDoc.MakeLineBreaksEnvironmentNeutral(), outputDoc); } @@ -1453,7 +1475,7 @@ public void ParseBasicDocumentWithServerVariableShouldSucceed() Format = OpenApiConstants.Yaml }, result.Diagnostic); - result.Document.Should().BeEquivalentTo(expected, options => options.Excluding(x => x.BaseUri)); + OpenApiTestAssert.Equivalent(expected, result.Document, nameof(OpenApiDocument.BaseUri)); } [Fact] @@ -1479,7 +1501,7 @@ public void ParseBasicDocumentWithServerVariableAndNoDefaultShouldFail() [Fact] public async Task ParseDocumentWithEmptyPathsSucceeds() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithEmptyPaths.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithEmptyPaths.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.Empty(result.Diagnostic.Errors); } @@ -1487,17 +1509,17 @@ public async Task ParseDocumentWithEmptyPathsSucceeds() public async Task ParseDocumentWithExampleReferencesPasses() { // Act & Assert: Ensure no NullReferenceException is thrown - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "docWithExampleReferences.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "docWithExampleReferences.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.Empty(result.Diagnostic.Errors); } [Fact] public async Task ParseDocumentWithNonStandardMIMETypePasses() { - var path = Path.Combine(SampleFolderPath, "basicDocumentWithMultipleServers.yaml"); + var path = Path.Join(SampleFolderPath, "basicDocumentWithMultipleServers.yaml"); using var stream = Resources.GetStream(path); using var streamReader = new StreamReader(stream); - var contentAsString = await streamReader.ReadToEndAsync(); + var contentAsString = await streamReader.ReadToEndAsync(TestContext.Current.CancellationToken); var mockMessageHandler = new Mock(); mockMessageHandler.Protected() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -1511,7 +1533,7 @@ public async Task ParseDocumentWithNonStandardMIMETypePasses() }; settings.AddYamlReader(); // Act & Assert: Ensure NotSupportedException is not thrown for non-standard MIME type: text/x-yaml - var result = await OpenApiDocument.LoadAsync("https://localhost/doesntmatter/foo.bar", settings); + var result = await OpenApiDocument.LoadAsync("https://localhost/doesntmatter/foo.bar", settings, token: TestContext.Current.CancellationToken); Assert.NotNull(result.Document); } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiEncodingTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiEncodingTests.cs index 0c0c4e96f..240a98f3d 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiEncodingTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiEncodingTests.cs @@ -18,7 +18,7 @@ public class OpenApiEncodingTests public async Task ParseBasicEncodingShouldSucceed() { // Act - var encoding = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "basicEncoding.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "basicEncoding.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -31,10 +31,10 @@ public async Task ParseBasicEncodingShouldSucceed() [Fact] public async Task ParseAdvancedEncodingShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "advancedEncoding.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "advancedEncoding.yaml")); // Act - var encoding = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -60,7 +60,7 @@ public async Task ParseAdvancedEncodingShouldSucceed() public async Task ParseEncodingWithAllowReservedShouldSucceed() { // Act - var encoding = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "encodingWithAllowReserved.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var encoding = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "encodingWithAllowReserved.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiExampleTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiExampleTests.cs index e888b3d16..ead7840cd 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiExampleTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiExampleTests.cs @@ -4,8 +4,8 @@ using System.IO; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Tests; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V3Tests @@ -18,7 +18,7 @@ public class OpenApiExampleTests [Fact] public async Task ParseAdvancedExampleShouldSucceed() { - var example = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "advancedExample.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var example = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "advancedExample.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expected = new OpenApiExample { Value = new JsonObject @@ -56,21 +56,13 @@ public async Task ParseAdvancedExampleShouldSucceed() } }; - example.Should().BeEquivalentTo(expected, options => options.IgnoringCyclicReferences() - .Excluding(e => e.Value["versions"][0]["status"].Root) - .Excluding(e => e.Value["versions"][0]["id"].Root) - .Excluding(e => e.Value["versions"][0]["links"][0]["href"].Root) - .Excluding(e => e.Value["versions"][0]["links"][0]["rel"].Root) - .Excluding(e => e.Value["versions"][1]["status"].Root) - .Excluding(e => e.Value["versions"][1]["id"].Root) - .Excluding(e => e.Value["versions"][1]["links"][0]["href"].Root) - .Excluding(e => e.Value["versions"][1]["links"][0]["rel"].Root)); + OpenApiTestAssert.Equivalent(expected, example, nameof(JsonNode.Root)); } [Fact] public async Task ParseExampleForcedStringSucceed() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "explicitString.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "explicitString.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); Assert.Empty(result.Diagnostic.Errors); } @@ -79,10 +71,10 @@ public async Task ParseExampleWithDataValueExtensionShouldSucceed() { // Arrange & Act var example = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "exampleWithDataValue.yaml"), + Path.Join(SampleFolderPath, "exampleWithDataValue.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(example); @@ -97,10 +89,10 @@ public async Task ParseExampleWithSerializedValueExtensionShouldSucceed() { // Arrange & Act var example = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "exampleWithSerializedValue.yaml"), + Path.Join(SampleFolderPath, "exampleWithSerializedValue.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(example); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiHeaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiHeaderTests.cs index 980e3f1ec..38281018c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiHeaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiHeaderTests.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Xunit; @@ -19,10 +18,10 @@ public class OpenApiHeaderTests public async Task ParseBasicHeaderShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicHeader.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicHeader.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -40,10 +39,10 @@ public async Task ParseBasicHeaderShouldSucceed() public async Task ParseHeaderWithContentShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithContent.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -79,10 +78,10 @@ public async Task ParseHeaderWithContentShouldSucceed() public async Task ParseHeaderWithMultipleContentTypesShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithMultipleContentTypes.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithMultipleContentTypes.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -120,10 +119,10 @@ public async Task ParseHeaderWithMultipleContentTypesShouldSucceed() public async Task ParseHeaderWithStyleAndContentShouldPreferContent() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "headerWithStyleAndContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "headerWithStyleAndContent.yaml")); // Act - var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var header = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert // Both content and style can be present, content takes precedence for serialization behavior diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiInfoTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiInfoTests.cs index 726f47e59..a11b044f4 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiInfoTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiInfoTests.cs @@ -6,8 +6,8 @@ using System.IO; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Tests; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V3Tests @@ -21,10 +21,10 @@ public class OpenApiInfoTests public async Task ParseAdvancedInfoShouldSucceed() { // Act - var openApiInfo = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "advancedInfo.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var openApiInfo = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "advancedInfo.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - openApiInfo.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiInfo { Title = "Advanced Info", @@ -61,27 +61,14 @@ public async Task ParseAdvancedInfoShouldSucceed() }), ["x-list"] = new JsonNodeExtension (new JsonArray { "1", "2" }) } - }, options => options.IgnoringCyclicReferences() - .Excluding(i => ((JsonNodeExtension)i.Contact.Extensions["x-twitter"]).Node.Parent) - .Excluding(i => ((JsonNodeExtension)i.License.Extensions["x-disclaimer"]).Node.Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-something"]).Node.Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-contact"]).Node["name"].Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-contact"]).Node["name"].Root) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-contact"]).Node["url"].Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-contact"]).Node["url"].Root) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-contact"]).Node["email"].Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-contact"]).Node["email"].Root) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-list"]).Node[0].Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-list"]).Node[0].Root) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-list"]).Node[1].Parent) - .Excluding(i => ((JsonNodeExtension)i.Extensions["x-list"]).Node[1].Root)); + }, openApiInfo, nameof(JsonNode.Parent), nameof(JsonNode.Root)); } [Fact] public async Task ParseBasicInfoShouldSucceed() { // Act - var openApiInfo = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "basicInfo.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var openApiInfo = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "basicInfo.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -108,10 +95,10 @@ public async Task ParseBasicInfoShouldSucceed() [Fact] public async Task ParseMinimalInfoShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "minimalInfo.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "minimalInfo.yaml")); // Act - var openApiInfo = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var openApiInfo = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiMediaTypeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiMediaTypeTests.cs index be0e5cc95..d533a2078 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiMediaTypeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiMediaTypeTests.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V3; using Microsoft.OpenApi.Tests; @@ -22,10 +21,10 @@ public class OpenApiMediaTypeTests public async Task ParseMediaTypeWithExampleShouldSucceed() { // Act - var mediaType = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "mediaTypeWithExample.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var mediaType = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "mediaTypeWithExample.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - mediaType.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiMediaType { Example = 5, @@ -34,19 +33,17 @@ public async Task ParseMediaTypeWithExampleShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(m => m.Example.Parent) - ); + }, mediaType, nameof(JsonNode.Parent)); } [Fact] public async Task ParseMediaTypeWithExamplesShouldSucceed() { // Act - var mediaType = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "mediaTypeWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var mediaType = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "mediaTypeWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - mediaType.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiMediaType { Examples = new Dictionary @@ -65,9 +62,7 @@ public async Task ParseMediaTypeWithExamplesShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(m => m.Examples["example1"].Value.Parent) - .Excluding(m => m.Examples["example2"].Value.Parent)); + }, mediaType, nameof(JsonNode.Parent)); } [Fact] @@ -103,14 +98,14 @@ public async Task ParseMediaTypeWithEmptyArrayInExamplesWorks() } "; JsonNode node; - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "examplesWithEmptyArray.json"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "examplesWithEmptyArray.json"))) { node = TestHelper.CreateYamlJsonNode(stream); } // Act var mediaType = OpenApiV3Deserializer.LoadMediaType(node, new(), new ParsingContext(new())); - var serialized = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var serialized = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), serialized.MakeLineBreaksEnvironmentNeutral()); @@ -121,10 +116,10 @@ public async Task ParseMediaTypeWithXOaiItemEncodingShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithXOaiItemEncoding.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithXOaiItemEncoding.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(mediaType); @@ -140,10 +135,10 @@ public async Task ParseMediaTypeWithXOaiPrefixEncodingShouldSucceed() { // Act var mediaType = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "mediaTypeWithXOaiPrefixEncoding.yaml"), + Path.Join(SampleFolderPath, "mediaTypeWithXOaiPrefixEncoding.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(mediaType); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiOperationTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiOperationTests.cs index 7b0844a9b..de462ebb2 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiOperationTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiOperationTests.cs @@ -7,7 +7,7 @@ using System.Net.Http; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; +using Microsoft.OpenApi.Tests; using Microsoft.OpenApi.Reader; using Xunit; @@ -20,7 +20,7 @@ public class OpenApiOperationTests [Fact] public async Task OperationWithSecurityRequirementShouldReferenceSecurityScheme() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "securedOperation.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "securedOperation.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var securityScheme = result.Document.Paths["/"].Operations[HttpMethod.Get].Security[0].Keys.First(); Assert.Equivalent(result.Document.Components.SecuritySchemes.First().Value, securityScheme); @@ -34,7 +34,7 @@ public async Task ParseOperationWithParameterWithNoLocationShouldSucceed() Tags = new HashSet { new() { Name = "user" } } }; // Act - var operation = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "operationWithParameterWithNoLocation.json"), OpenApiSpecVersion.OpenApi3_0, openApiDocument, SettingsFixture.ReaderSettings); + var operation = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "operationWithParameterWithNoLocation.json"), OpenApiSpecVersion.OpenApi3_0, openApiDocument, SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expectedOp = new OpenApiOperation { Tags = new HashSet() @@ -71,9 +71,7 @@ public async Task ParseOperationWithParameterWithNoLocationShouldSucceed() }; // Assert - expectedOp.Should().BeEquivalentTo(operation, - options => - options.Excluding(x => x.Tags)); + OpenApiTestAssert.Equivalent(expectedOp, operation, nameof(OpenApiOperation.Tags)); } [Fact] public void DeduplicatesTagReferences() diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiParameterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiParameterTests.cs index b3a58e6d0..764ebac7a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiParameterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiParameterTests.cs @@ -3,10 +3,11 @@ using System.Collections.Generic; using System.IO; -using FluentAssertions; +using System.Text.Json.Nodes; using Microsoft.OpenApi.Reader; using Xunit; using Microsoft.OpenApi.Reader.V3; +using Microsoft.OpenApi.Tests; using System.Threading.Tasks; using System.Net.Http; @@ -21,10 +22,10 @@ public class OpenApiParameterTests public async Task ParsePathParameterShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "pathParameter.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "pathParameter.yaml")); // Act - var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -45,7 +46,7 @@ public async Task ParsePathParameterShouldSucceed() public async Task ParseQueryParameterShouldSucceed() { // Act - var parameter = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "queryParameter.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "queryParameter.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -72,7 +73,7 @@ public async Task ParseQueryParameterShouldSucceed() public async Task ParseQueryParameterWithObjectTypeShouldSucceed() { // Act - var parameter = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "queryParameterWithObjectType.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "queryParameterWithObjectType.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -96,10 +97,10 @@ public async Task ParseQueryParameterWithObjectTypeShouldSucceed() public async Task ParseQueryParameterWithObjectTypeAndContentShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "queryParameterWithObjectTypeAndContent.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "queryParameterWithObjectTypeAndContent.yaml")); // Act - var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -140,7 +141,7 @@ public async Task ParseQueryParameterWithObjectTypeAndContentShouldSucceed() public async Task ParseHeaderParameterShouldSucceed() { // Act - var parameter = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "headerParameter.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "headerParameter.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -168,7 +169,7 @@ public async Task ParseHeaderParameterShouldSucceed() public async Task ParseParameterWithNullLocationShouldSucceed() { // Act - var parameter = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "parameterWithNullLocation.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "parameterWithNullLocation.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -189,10 +190,10 @@ public async Task ParseParameterWithNullLocationShouldSucceed() public async Task ParseParameterWithNoLocationShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithNoLocation.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithNoLocation.yaml")); // Act - var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -213,10 +214,10 @@ public async Task ParseParameterWithNoLocationShouldSucceed() public async Task ParseParameterWithUnknownLocationShouldSucceed() { // Arrange - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithUnknownLocation.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithUnknownLocation.yaml")); // Act - var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(stream, OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -237,10 +238,10 @@ public async Task ParseParameterWithUnknownLocationShouldSucceed() public async Task ParseParameterWithExampleShouldSucceed() { // Act - var parameter = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "parameterWithExample.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "parameterWithExample.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - parameter.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiParameter { In = null, @@ -253,17 +254,17 @@ public async Task ParseParameterWithExampleShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences().Excluding(p => p.Example.Parent)); + }, parameter, nameof(JsonNode.Parent)); } [Fact] public async Task ParseParameterWithExamplesShouldSucceed() { // Act - var parameter = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "parameterWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var parameter = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "parameterWithExamples.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert - parameter.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiParameter { In = null, @@ -286,9 +287,7 @@ public async Task ParseParameterWithExamplesShouldSucceed() Type = JsonSchemaType.Number, Format = "float" } - }, options => options.IgnoringCyclicReferences() - .Excluding(p => p.Examples["example1"].Value.Parent) - .Excluding(p => p.Examples["example2"].Value.Parent)); + }, parameter, nameof(JsonNode.Parent)); } [Fact] @@ -353,7 +352,7 @@ public void ParseParameterWithReferenceWorks() document.Workspace.RegisterComponents(document); - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "parameterWithRef.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "parameterWithRef.yaml")); var node = TestHelper.CreateYamlJsonNode(stream); var expected = document.Components.Parameters["tagsParameter"]; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs index c91f73df5..a47bcdb6d 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs @@ -17,7 +17,7 @@ public class OpenApiPathItemDeserializerTests public async Task ExtraneousOperationsAreParsedAsExtensionsIn30() { // Arrange & Act - var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var pathItem = result.Document.Paths["/pets"]; // Assert diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiResponseTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiResponseTests.cs index 47d017b4b..e29f7740e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiResponseTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiResponseTests.cs @@ -16,7 +16,7 @@ public class OpenApiResponseTests [Fact] public async Task ResponseWithReferencedHeaderShouldReferenceComponent() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "responseWithHeaderReference.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "responseWithHeaderReference.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var response = result.Document.Components.Responses["Test"]; var expected = response.Headers.First().Value; @@ -28,7 +28,7 @@ public async Task ResponseWithReferencedHeaderShouldReferenceComponent() [Fact] public async Task ResponseWithSummaryV32ShouldDeserializeCorrectly() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "responseWithSummary.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "responseWithSummary.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var response = result.Document.Components.Responses["SuccessResponse"] as OpenApiResponse; @@ -40,7 +40,7 @@ public async Task ResponseWithSummaryV32ShouldDeserializeCorrectly() [Fact] public async Task ResponseWithSummaryExtensionV31ShouldDeserializeCorrectly() { - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "responseWithSummaryExtension.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "responseWithSummaryExtension.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var response = result.Document.Components.Responses["SuccessResponse"] as OpenApiResponse; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 9b0f0b94b..f574245e6 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -6,8 +6,6 @@ using System.Net.Http; using System.Text.Json.Nodes; using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Equivalency; using Microsoft.OpenApi.Reader; using Microsoft.OpenApi.Reader.V3; using Microsoft.OpenApi.Tests; @@ -15,6 +13,8 @@ using SharpYaml.Serialization; using Xunit; +#pragma warning disable CS0618 + namespace Microsoft.OpenApi.Readers.Tests.V3Tests { [Collection("DefaultSettings")] @@ -25,7 +25,7 @@ public class OpenApiSchemaTests [Fact] public void ParsePrimitiveSchemaShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "primitiveSchema.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "primitiveSchema.yaml")); var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); var yamlNode = yamlStream.Documents[0].RootNode; @@ -65,12 +65,12 @@ public void ParseExampleStringFragmentShouldSucceed() // Assert Assert.Equivalent(new OpenApiDiagnostic(), diagnostic); - jsonNodeExtension.Should().BeEquivalentTo(new JsonNodeExtension( + OpenApiTestAssert.Equivalent(new JsonNodeExtension( new JsonObject { ["foo"] = "bar", ["baz"] = new JsonArray() { 1, 2 } - }), options => options.IgnoringCyclicReferences()); + }), jsonNodeExtension); } [Fact] @@ -88,12 +88,12 @@ public void ParseEnumFragmentShouldSucceed() // Assert Assert.Equivalent(new OpenApiDiagnostic(), diagnostic); - jsonNodeExtension.Should().BeEquivalentTo(new JsonNodeExtension( + OpenApiTestAssert.Equivalent(new JsonNodeExtension( new JsonArray { "foo", "baz" - }), options => options.IgnoringCyclicReferences()); + }), jsonNodeExtension); } [Fact] @@ -148,7 +148,7 @@ public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string sch [Fact] public void ParseDictionarySchemaShouldSucceed() { - using (var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "dictionarySchema.yaml"))) + using (var stream = Resources.GetStream(Path.Join(SampleFolderPath, "dictionarySchema.yaml"))) { var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); @@ -178,10 +178,64 @@ public void ParseDictionarySchemaShouldSucceed() } } + [Fact] + public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() + { + var schemaJson = @"{ + ""x-jsonschema-$anchor"": ""root"", + ""x-jsonschema-unevaluatedProperties"": false, + ""x-jsonschema-contentEncoding"": ""base64"", + ""x-jsonschema-contentMediaType"": ""application/jwt"", + ""x-jsonschema-contentSchema"": { + ""type"": ""array"" + }, + ""x-jsonschema-contains"": { + ""type"": ""string"" + }, + ""x-jsonschema-maxContains"": 5, + ""x-jsonschema-minContains"": 1, + ""x-jsonschema-propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""x-jsonschema-dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""x-jsonschema-if"": { + ""required"": [""token""] + }, + ""x-jsonschema-then"": { + ""minProperties"": 1 + }, + ""x-jsonschema-else"": { + ""maxProperties"": 0 + } +}"; + + var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_0, new(), out _, "json", SettingsFixture.ReaderSettings); + var missingProperties = Assert.IsAssignableFrom(schema); + + Assert.Equal("root", missingProperties.Anchor); + Assert.False(missingProperties.UnevaluatedProperties); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal(JsonSchemaType.String, missingProperties.Contains?.Type); + Assert.Equal((uint?)5, missingProperties.MaxContains); + Assert.Equal((uint?)1, missingProperties.MinContains); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Fact] public void ParseBasicSchemaWithExampleShouldSucceed() { - using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "basicSchemaWithExample.yaml")); + using var stream = Resources.GetStream(Path.Join(SampleFolderPath, "basicSchemaWithExample.yaml")); var yamlStream = new YamlStream(); yamlStream.Load(new StreamReader(stream)); var yamlNode = yamlStream.Documents[0].RootNode; @@ -198,7 +252,7 @@ public void ParseBasicSchemaWithExampleShouldSucceed() // Assert Assert.Equivalent(new OpenApiDiagnostic(), diagnostic); - schema.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiSchema { Type = JsonSchemaType.Object, @@ -223,19 +277,14 @@ public void ParseBasicSchemaWithExampleShouldSucceed() ["name"] = new JsonNodeExtension("Puma").Node, ["id"] = new JsonNodeExtension(1).Node } - }, options => options - .IgnoringCyclicReferences() - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Parent")) - .Excluding((IMemberInfo memberInfo) => - memberInfo.Path.EndsWith("Root"))); + }, schema, nameof(JsonNode.Parent), nameof(JsonNode.Root)); } [Fact] public async Task ParseBasicSchemaWithReferenceShouldSucceed() { // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "basicSchemaWithReference.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "basicSchemaWithReference.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert var components = result.Document.Components; @@ -302,7 +351,7 @@ public async Task ParseBasicSchemaWithReferenceShouldSucceed() public async Task ParseAdvancedSchemaWithReferenceShouldSucceed() { // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "advancedSchemaWithReference.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "advancedSchemaWithReference.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var expectedComponents = new OpenApiComponents { @@ -388,8 +437,8 @@ public async Task ParseAdvancedSchemaWithReferenceShouldSucceed() }; // We serialize so that we can get rid of the schema BaseUri properties which show up as diffs - var actual = await result.Document.Components.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); - var expected = await expectedComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await result.Document.Components.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = await expectedComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual); @@ -399,7 +448,7 @@ public async Task ParseAdvancedSchemaWithReferenceShouldSucceed() public async Task ParseExternalReferenceSchemaShouldSucceed() { // Act - var result = await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalReferencesSchema.yaml"), SettingsFixture.ReaderSettings); + var result = await OpenApiDocument.LoadAsync(Path.Join(SampleFolderPath, "externalReferencesSchema.yaml"), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert var components = result.Document.Components; @@ -463,10 +512,10 @@ public async Task SerializeSchemaWithNullableShouldSucceed() var expected = @"type: string nullable: true"; - var path = Path.Combine(SampleFolderPath, "schemaWithNullable.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithNullable.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV3(new OpenApiYamlWriter(writer)); @@ -478,13 +527,14 @@ public async Task SerializeSchemaWithNullableShouldSucceed() [Fact] public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { + // NOTE: nullable property has no effect on the schema if type is not specified, so it is omitted in the serialized output. // Arrange - var expected = @"nullable: true"; + var expected = @"{ }"; - var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullable.yaml"); + var path = Path.Join(SampleFolderPath, "schemaWithOnlyNullable.yaml"); // Act - var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); var writer = new StringWriter(); schema.SerializeAsV3(new OpenApiYamlWriter(writer)); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSecuritySchemeTests.cs index f2591e99a..92aac44ae 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSecuritySchemeTests.cs @@ -3,8 +3,10 @@ using System; using System.IO; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Reader.V3; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V3Tests @@ -17,7 +19,7 @@ public class OpenApiSecuritySchemeTests public async Task ParseHttpSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "httpSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "httpSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -32,7 +34,7 @@ public async Task ParseHttpSecuritySchemeShouldSucceed() public async Task ParseApiKeySecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "apiKeySecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "apiKeySecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -48,7 +50,7 @@ public async Task ParseApiKeySecuritySchemeShouldSucceed() public async Task ParseBearerSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "bearerSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "bearerSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -64,7 +66,7 @@ public async Task ParseBearerSecuritySchemeShouldSucceed() public async Task ParseOAuth2SecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "oauth2SecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "oauth2SecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -90,7 +92,7 @@ public async Task ParseOAuth2SecuritySchemeShouldSucceed() public async Task ParseOpenIdConnectSecuritySchemeShouldSucceed() { // Act - var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Combine(SampleFolderPath, "openIdConnectSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings); + var securityScheme = await OpenApiModelFactory.LoadAsync(Path.Join(SampleFolderPath, "openIdConnectSecurityScheme.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.Equivalent( @@ -107,10 +109,10 @@ public async Task ParseOAuth2SecuritySchemeWithDeviceAuthorizationUrlShouldSucce { // Act var securityScheme = await OpenApiModelFactory.LoadAsync( - Path.Combine(SampleFolderPath, "oauth2SecuritySchemeWithDeviceUrl.yaml"), + Path.Join(SampleFolderPath, "oauth2SecuritySchemeWithDeviceUrl.yaml"), OpenApiSpecVersion.OpenApi3_0, new(), - SettingsFixture.ReaderSettings); + SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert Assert.NotNull(securityScheme); @@ -122,5 +124,31 @@ public async Task ParseOAuth2SecuritySchemeWithDeviceAuthorizationUrlShouldSucce Assert.NotNull(securityScheme.Flows.AuthorizationCode.Scopes); Assert.Equal(2, securityScheme.Flows.AuthorizationCode.Scopes.Count); } + + [Fact] + public void ParseOAuth2SecuritySchemeWithMetadataUrlExtensionShouldSucceed() + { + var json = + """ + { + "type": "oauth2", + "x-oai-oauth2-metadata-url": "https://idp.example.com/.well-known/oauth-authorization-server", + "flows": { + "clientCredentials": { + "tokenUrl": "https://idp.example.com/oauth/token", + "scopes": { + "scope:one": "Scope one" + } + } + } + } + """; + + var securityScheme = Assert.IsType( + OpenApiV3Deserializer.LoadSecurityScheme(JsonNode.Parse(json)!, new(), new ParsingContext(new()))); + + Assert.Equal(new Uri("https://idp.example.com/.well-known/oauth-authorization-server"), securityScheme.OAuth2MetadataUrl); + Assert.True(securityScheme.Extensions is null || !securityScheme.Extensions.ContainsKey(OpenApiConstants.OAuth2MetadataUrlExtension)); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiXmlTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiXmlTests.cs index 0c9c78e6b..61f916cf4 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiXmlTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiXmlTests.cs @@ -18,7 +18,7 @@ public class OpenApiXmlTests public async Task ParseBasicXmlShouldSucceed() { // Act - var xml = await OpenApiModelFactory.LoadAsync(Resources.GetStream(Path.Combine(SampleFolderPath, "basicXml.yaml")), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings); + var xml = await OpenApiModelFactory.LoadAsync(Resources.GetStream(Path.Join(SampleFolderPath, "basicXml.yaml")), OpenApiSpecVersion.OpenApi3_0, new(), settings: SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken); // Assert #pragma warning disable CS0618 // Type or member is obsolete diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs new file mode 100644 index 000000000..8c1c979f5 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class YamlConverterGlobalSettingsCollection +{ + public const string Name = "YamlConverterGlobalSettings"; +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs new file mode 100644 index 000000000..6e423cd85 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.YamlReader; +using SharpYaml.Serialization; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +[Collection(YamlConverterGlobalSettingsCollection.Name)] +public class YamlConverterGlobalSettingsTests +{ + [Fact] + public void ConversionLimitsDefaultToDocumentedValues() + { + Assert.Equal(64u, YamlConverter.DefaultMaxDepth); + Assert.Equal(5_000_000u, YamlConverter.DefaultMaxNodeCount); + Assert.Equal(5_000u, YamlConverter.DefaultMaxAliasExpansionNodeCount); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + Assert.Equal(YamlConverter.DefaultMaxAliasExpansionNodeCount, YamlConverter.MaxAliasExpansionNodeCount); + } + + [Fact] + public void SettingMaxDepthToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxDepth = 0); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + } + + [Fact] + public void SettingMaxNodeCountToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxNodeCount = 0); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + } + + [Fact] + public void SettingMaxAliasExpansionNodeCountToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxAliasExpansionNodeCount = 0); + Assert.Equal(YamlConverter.DefaultMaxAliasExpansionNodeCount, YamlConverter.MaxAliasExpansionNodeCount); + } + + [Fact] + public void SettingMaxDepthAboveSafeCeilingThrows() + { + Assert.Throws(() => YamlConverter.MaxDepth = YamlConverter.MaximumAllowedDepth + 1); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + } + + [Fact] + public void SettingMaxNodeCountAboveSafeCeilingThrows() + { + Assert.Throws(() => YamlConverter.MaxNodeCount = YamlConverter.MaximumAllowedNodeCount + 1); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + } + + [Fact] + public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault() + { + const int depth = 70; + YamlNode deeplyNested = new YamlScalarNode("value"); + for (var index = 0; index < depth; index++) + { + deeplyNested = new YamlSequenceNode(deeplyNested); + } + + try + { + YamlConverter.MaxDepth = depth + 10; + + var jsonNode = deeplyNested.ToJsonNode(); + + Assert.IsType(jsonNode); + } + finally + { + YamlConverter.MaxDepth = YamlConverter.DefaultMaxDepth; + } + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs index c246e0898..ce8393696 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -333,6 +333,101 @@ public void RoundTripEmptyStringsValues() Assert.Equal(yamlInput.MakeLineBreaksEnvironmentNeutral(), convertedBackOutput.MakeLineBreaksEnvironmentNeutral()); } + [Fact] + public void ExponentialAliasExpansionIsRejected() + { + // A "billion laughs" YAML bomb: each level references the previous one multiple times, + // so materializing the shared node graph into an independent JSON tree expands + // exponentially. The conversion must fail fast instead of exhausting memory. + var yamlBomb = + """ + a: &a ["x","x","x","x","x","x","x","x","x"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + """; + + Assert.Throws(() => ConvertYamlStringToJsonNode(yamlBomb)); + } + + [Fact] + public void ExcessiveNestingDepthIsRejected() + { + // Deeper than the conversion depth limit (mirrors the System.Text.Json default of 64), + // which protects the recursive converter from stack exhaustion. + const int depth = 70; + var deeplyNested = new string('[', depth) + new string(']', depth); + + Assert.Throws(() => ConvertYamlStringToJsonNode(deeplyNested)); + } + + [Fact] + public void LegitimateAliasesStillConvert() + { + var yamlInput = + """ + a: &val hello + b: *val + """; + + var jsonNode = Assert.IsType(ConvertYamlStringToJsonNode(yamlInput)); + + Assert.Equal("hello", jsonNode["a"]?.GetValue()); + Assert.Equal("hello", jsonNode["b"]?.GetValue()); + } + + [Fact] + public void CyclicYamlNodeGraphIsRejected() + { + var sequence = new YamlSequenceNode(); + sequence.Add(sequence); + + Assert.Throws(() => sequence.ToJsonNode()); + } + + [Fact] + public void ComplexMappingKeyIsRejectedAsReaderException() + { + var mapping = new YamlMappingNode + { + { new YamlSequenceNode(new YamlScalarNode("key")), new YamlScalarNode("value") } + }; + + Assert.Throws(() => mapping.ToJsonNode()); + } + + [Fact] + public void SharedSubtreeGraftedTooDeepIsRejected() + { + // The converter memoizes each YamlNode it has already materialized, then deep-clones the + // result when the same instance reappears. The shared subtree clears the depth check where + // it is first seen, so without charging its height at the reuse site it can be replayed + // from a deeper position to build a tree past the limit. + YamlNode shared = new YamlScalarNode("value"); + for (var index = 0; index < 40; index++) + { + shared = new YamlSequenceNode(shared); + } + + var grafted = shared; + for (var index = 0; index < 30; index++) + { + grafted = new YamlSequenceNode(grafted); + } + + // The shallow element is converted first and memoizes the shared subtree; the deep element + // then reuses it 31 levels down, which would materialize 72 levels against a limit of 64. + var root = new YamlSequenceNode(shared, grafted); + + var exception = Assert.Throws(() => root.ToJsonNode()); + Assert.Contains("expands an alias", exception.Message, StringComparison.Ordinal); + } + private static JsonNode ConvertYamlStringToJsonNode(string yamlInput) { var yamlDocument = new YamlStream(); diff --git a/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs b/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs new file mode 100644 index 000000000..255a1c1ed --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Converters +{ + [Collection("DefaultSettings")] + public class OpenApiSchemaJsonConverterTests + { + private static readonly JsonSerializerOptions _optionsV31 = new() + { + Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi3_1) } + }; + + private static readonly JsonSerializerOptions _optionsV32 = new() + { + Converters = { new OpenApiSchemaJsonConverter() } + }; + + [Fact] + public void Serialize_SimpleStringSchema_ProducesOpenApiWireFormat() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "A simple string" + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.Equal("string", doc.RootElement.GetProperty("type").GetString()); + Assert.Equal("A simple string", doc.RootElement.GetProperty("description").GetString()); + } + + [Fact] + public void Serialize_SchemaWithProperties_ProducesCorrectJson() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.Equal("object", doc.RootElement.GetProperty("type").GetString()); + var props = doc.RootElement.GetProperty("properties"); + Assert.True(props.TryGetProperty("name", out _)); + Assert.True(props.TryGetProperty("age", out _)); + } + + [Fact] + public void Serialize_DefaultConstructor_TargetsV32() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Boolean }; + + var json = JsonSerializer.Serialize(schema, _optionsV32); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("type", out _)); + } + + [Fact] + public void Deserialize_SimpleStringSchema_ReturnsCorrectSchema() + { + const string json = """{"type":"string","description":"A simple string"}"""; + + var schema = JsonSerializer.Deserialize(json, _optionsV31); + + Assert.NotNull(schema); + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal("A simple string", schema.Description); + } + + [Fact] + public void Deserialize_SchemaWithEnum_ReturnsCorrectSchema() + { + const string json = """{"type":"string","enum":["active","inactive"]}"""; + + var schema = JsonSerializer.Deserialize(json, _optionsV31); + + Assert.NotNull(schema); + Assert.Equal(2, schema.Enum?.Count); + } + + [Fact] + public void RoundTrip_ComplexSchema_PreservesData() + { + var original = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Title = "User", + Description = "A user object", + Required = new System.Collections.Generic.HashSet { "name" }, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer | JsonSchemaType.Null } + } + }; + + var json = JsonSerializer.Serialize(original, _optionsV31); + var deserialized = JsonSerializer.Deserialize(json, _optionsV31); + + Assert.NotNull(deserialized); + Assert.Equal("User", deserialized.Title); + Assert.Equal("A user object", deserialized.Description); + Assert.True(deserialized.Properties?.ContainsKey("name")); + Assert.True(deserialized.Properties?.ContainsKey("age")); + } + + [Fact] + public void Serialize_NullSchema_WritesNullLiteral() + { + // System.Text.Json handles null at the serializer level before invoking the converter. + var json = JsonSerializer.Serialize(null!, _optionsV31); + + Assert.Equal("null", json); + } + + [Fact] + public void Serialize_V31Schema_IncludesJsonSchemaKeywords() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Id = "https://example.com/schema" + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("$id", out _), "$id is a v3.1 JSON Schema keyword"); + } + + [Fact] + public void Deserialize_WithV2Version_ThrowsNotSupportedException() + { + const string json = """{"type":"string"}"""; + var optionsV2 = new JsonSerializerOptions + { + Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi2_0) } + }; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, optionsV2)); + } + + [Fact] + public void Serialize_SchemaWithAllOf_ProducesCorrectJson() + { + var schema = new OpenApiSchema + { + AllOf = + [ + new OpenApiSchema { Type = JsonSchemaType.String } + ] + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("allOf", out _)); + } + + [Fact] + public void Serialize_SchemaWithInlineReference_ProducesRefInAllOf() + { + // A schema that uses an OpenApiSchemaReference inside allOf produces a $ref in the output. + var document = new OpenApiDocument(); + document.Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["MySchema"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }; + document.RegisterComponents(); + + var schema = new OpenApiSchema + { + AllOf = [new OpenApiSchemaReference("MySchema", document)] + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("allOf", out var allOf)); + var firstItem = allOf.EnumerateArray().GetEnumerator(); + Assert.True(firstItem.MoveNext()); + Assert.True(firstItem.Current.TryGetProperty("$ref", out _), "allOf item should contain a $ref"); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/DefaultSettingsFixture.cs b/test/Microsoft.OpenApi.Tests/DefaultSettingsFixture.cs deleted file mode 100644 index d2afed0c6..000000000 --- a/test/Microsoft.OpenApi.Tests/DefaultSettingsFixture.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -using FluentAssertions; - -namespace Microsoft.OpenApi.Tests -{ - /// - /// Fixture containing default settings for external libraries. - /// - public class DefaultSettingsFixture - { - /// - /// Initializes an instance of . - /// - public DefaultSettingsFixture() - { - // We need RespectingRuntimeTypes() to ensure equivalence test works property, - // given that there are multiple types that can be used for the declared type OpenApiAny. - // Without this option, properties specific to those types would not be compared. - AssertionOptions.AssertEquivalencyUsing( - o => o - .AllowingInfiniteRecursion() - .RespectingRuntimeTypes() - .WithStrictOrdering()); - } - } -} diff --git a/test/Microsoft.OpenApi.Tests/DefaultSettingsFixtureCollection.cs b/test/Microsoft.OpenApi.Tests/DefaultSettingsFixtureCollection.cs deleted file mode 100644 index 22f562c4d..000000000 --- a/test/Microsoft.OpenApi.Tests/DefaultSettingsFixtureCollection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -using Xunit; - -namespace Microsoft.OpenApi.Tests -{ - /// - /// Collection dummy class for . - /// - /// - /// This class is needed in xUnit framework to define collection name - /// to be used in unit test classes. - /// - [CollectionDefinition("DefaultSettings")] - public class DefaultSettingsFixtureCollection : ICollectionFixture - { - } -} diff --git a/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs b/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs index 08a1debc0..91509184b 100644 --- a/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs +++ b/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs @@ -145,6 +145,28 @@ public void BuildRuntimeExpressionTwiceCreatesNewEquivalentInstances(string expr Assert.Equal(runtimeExpression1, runtimeExpression2); } + [Fact] + public void CompositeRuntimeExpressionPreservesMultilineCaptures() + { + const string expression = "prefix {$request.header.foo\nbar} {$url} suffix"; + + var composite = Assert.IsType(RuntimeExpression.Build(expression)); + + Assert.Equal(expression, composite.Expression); + Assert.Equal(new[] { "$request.header.foo\nbar", "$url" }, + composite.ContainedExpressions.Select(static item => item.Expression)); + } + + [Fact] + public void CompositeRuntimeExpressionPreservesUnterminatedCapture() + { + const string expression = "prefix {$url"; + + var composite = Assert.IsType(RuntimeExpression.Build(expression)); + + Assert.IsType(Assert.Single(composite.ContainedExpressions)); + } + [Fact] public void CompositeRuntimeExpressionContainsExpression() { diff --git a/test/Microsoft.OpenApi.Tests/Extensions/OpenApiSerializableExtensionsTests.cs b/test/Microsoft.OpenApi.Tests/Extensions/OpenApiSerializableExtensionsTests.cs index a1e323ebf..470fb022b 100644 --- a/test/Microsoft.OpenApi.Tests/Extensions/OpenApiSerializableExtensionsTests.cs +++ b/test/Microsoft.OpenApi.Tests/Extensions/OpenApiSerializableExtensionsTests.cs @@ -27,11 +27,11 @@ public async Task UsesTheTerseOutputInformationFromSettingsTrue() }; using var stream = new MemoryStream(); - await parameter.SerializeAsync(stream, OpenApiSpecVersion.OpenApi3_1, OpenApiConstants.Json, settings); + await parameter.SerializeAsync(stream, OpenApiSpecVersion.OpenApi3_1, OpenApiConstants.Json, settings, TestContext.Current.CancellationToken); stream.Position = 0; using var reader = new StreamReader(stream); - var output = await reader.ReadToEndAsync(); + var output = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); Assert.Equal("{\"name\":\"param1\",\"in\":\"query\",\"description\":\"A sample parameter\",\"schema\":{\"type\":\"string\"}}", output); } @@ -57,11 +57,11 @@ public async Task UsesTheTerseOutputInformationFromSettingsFalse() }; using var stream = new MemoryStream(); - await parameter.SerializeAsync(stream, OpenApiSpecVersion.OpenApi3_1, OpenApiConstants.Json, settings); + await parameter.SerializeAsync(stream, OpenApiSpecVersion.OpenApi3_1, OpenApiConstants.Json, settings, TestContext.Current.CancellationToken); stream.Position = 0; using var reader = new StreamReader(stream); - var output = await reader.ReadToEndAsync(); + var output = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); Assert.Equal("{\n \"name\": \"param1\",\n \"in\": \"query\",\n \"description\": \"A sample parameter\",\n \"schema\": {\n \"type\": \"string\"\n }\n}", output); } @@ -82,11 +82,11 @@ public async Task UsesTheTerseOutputInformationFromSettingsNoSettings() }; using var stream = new MemoryStream(); - await parameter.SerializeAsync(stream, OpenApiSpecVersion.OpenApi3_1, OpenApiConstants.Json, null); + await parameter.SerializeAsync(stream, OpenApiSpecVersion.OpenApi3_1, OpenApiConstants.Json, null, TestContext.Current.CancellationToken); stream.Position = 0; using var reader = new StreamReader(stream); - var output = await reader.ReadToEndAsync(); + var output = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); Assert.Equal("{\n \"name\": \"param1\",\n \"in\": \"query\",\n \"description\": \"A sample parameter\",\n \"schema\": {\n \"type\": \"string\"\n }\n}", output); } diff --git a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj index ba1164688..e79f44dba 100644 --- a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj +++ b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj @@ -5,17 +5,17 @@ true Library ..\..\src\Microsoft.OpenApi.snk + true - - - - - - - - + + + + + + + diff --git a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiComponentsSerializationTests.cs b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiComponentsSerializationTests.cs index 144f28500..65b512b21 100644 --- a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiComponentsSerializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiComponentsSerializationTests.cs @@ -26,6 +26,8 @@ public OpenApiComponentsSerializationTests() _components.Responses["200"] = _responseMock.Object; _components.Parameters["limit"] = _parameterMock.Object; _components.Headers["x-rate-limit"] = _headerMock.Object; + _securitySchemeMock.Object.Type = SecuritySchemeType.Http; + _securitySchemeMock.Object.Scheme = "bearer"; _components.SecuritySchemes["api_key"] = _securitySchemeMock.Object; _components.Links["UserRepositories"] = _linkMock.Object; _components.Callbacks["onData"] = _callbackMock.Object; diff --git a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiResponseSerializationTests.cs b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiResponseSerializationTests.cs index 27246fa7e..95a5a6980 100644 --- a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiResponseSerializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiResponseSerializationTests.cs @@ -1,5 +1,7 @@ using System.IO; using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; using Moq; using Xunit; @@ -61,5 +63,25 @@ public void SerializeAsV3_DoesNotCallV31OrV2Serialization() _linkMock.Verify(l => l.SerializeAsV31(It.IsAny()), Times.Never); _linkMock.Verify(l => l.SerializeAsV2(It.IsAny()), Times.Never); } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeResponseWithoutDescriptionAsJsonDoesNotWriteNullDescription(OpenApiSpecVersion specVersion) + { + // Arrange + var response = new OpenApiResponse(); + + // Act + var actual = await response.SerializeAsJsonAsync(specVersion, TestContext.Current.CancellationToken); + + // Assert + var node = JsonNode.Parse(actual); + Assert.NotNull(node); + var responseObject = Assert.IsType(node); + Assert.False(responseObject.ContainsKey(OpenApiConstants.Description)); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs index 8a402fb74..2d9c5f766 100644 --- a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs @@ -45,5 +45,37 @@ public void SerializeAsV3_DoesNotCallV31OrV2Serialization() _xmlMock.Verify(c => c.SerializeAsV2(It.IsAny()), Times.Never, "V2 method should not be called"); _xmlMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never, "V31 method should not be called"); } + + [Fact] + public void SerializeAsV31_UsesV31CallbackForJsonSchemaKeywords() + { + using var stringWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(stringWriter); + var childSchemaMock = new Mock { CallBase = true }; + childSchemaMock.Object.Type = JsonSchemaType.String; + _schema.ContentSchema = childSchemaMock.Object; + + _schema.SerializeAsV31(writer); + + childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.AtLeastOnce); + childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.Never); + childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); + } + + [Fact] + public void SerializeAsV32_UsesV32CallbackForJsonSchemaKeywords() + { + using var stringWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(stringWriter); + var childSchemaMock = new Mock { CallBase = true }; + childSchemaMock.Object.Type = JsonSchemaType.String; + _schema.ContentSchema = childSchemaMock.Object; + + _schema.SerializeAsV32(writer); + + childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.AtLeastOnce); + childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never); + childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiCallbackTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiCallbackTests.cs index 952185498..7d4181147 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiCallbackTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiCallbackTests.cs @@ -103,7 +103,7 @@ public async Task SerializeAdvancedCallbackAsV3JsonWorksAsync(bool produceTerseO // Act AdvancedCallback.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -120,7 +120,7 @@ public async Task SerializeReferencedCallbackAsV3JsonWorksAsync(bool produceTers // Act CallbackProxy.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -137,7 +137,7 @@ public async Task SerializeReferencedCallbackAsV3JsonWithoutReferenceWorksAsync( // Act ReferencedCallback.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsMediaTypesTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsMediaTypesTests.cs index fba65ccf7..a137d86bd 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsMediaTypesTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsMediaTypesTests.cs @@ -74,7 +74,7 @@ public async Task SerializeMediaTypesAsV32JsonWorks() """; // Act - var actual = await ComponentsWithMediaTypes.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await ComponentsWithMediaTypes.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -90,7 +90,7 @@ public async Task SerializeMediaTypesAsV31JsonWorks() """; // Act - var actual = await ComponentsWithMediaTypes.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await ComponentsWithMediaTypes.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -106,7 +106,7 @@ public async Task SerializeMediaTypesAsV30JsonWorks() """; // Act - var actual = await ComponentsWithMediaTypes.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await ComponentsWithMediaTypes.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsTests.cs index 3427605fb..4d03aa04b 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiComponentsTests.cs @@ -267,7 +267,7 @@ public async Task SerializeBasicComponentsAsJsonWorks() var expected = @"{ }"; // Act - var actual = await BasicComponents.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicComponents.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -282,7 +282,7 @@ public async Task SerializeBasicComponentsAsYamlWorks() var expected = @"{ }"; // Act - var actual = await BasicComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -334,7 +334,7 @@ public async Task SerializeAdvancedComponentsAsJsonV3Works() """; // Act - var actual = await AdvancedComponents.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedComponents.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -392,7 +392,7 @@ public async Task SerializeAdvancedComponentsWithReferenceAsJsonV3Works() """; // Act - var actual = await AdvancedComponentsWithReference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedComponentsWithReference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -431,7 +431,7 @@ public async Task SerializeAdvancedComponentsAsYamlV3Works() """; // Act - var actual = await AdvancedComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -473,7 +473,7 @@ public async Task SerializeAdvancedComponentsWithReferenceAsYamlV3Works() """; // Act - var actual = await AdvancedComponentsWithReference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedComponentsWithReference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -510,7 +510,7 @@ public async Task SerializeBrokenComponentsAsJsonV3Works() """; // Act - var actual = await BrokenComponents.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BrokenComponents.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -540,7 +540,7 @@ public async Task SerializeBrokenComponentsAsYamlV3Works() """; // Act - var actual = await BrokenComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BrokenComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -566,7 +566,7 @@ public async Task SerializeTopLevelReferencingComponentsAsYamlV3Works() """; // Act - var actual = await TopLevelReferencingComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await TopLevelReferencingComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -591,7 +591,7 @@ public async Task SerializeTopLevelSelfReferencingWithOtherPropertiesComponentsA type: string"; // Act - var actual = await TopLevelSelfReferencingComponentsWithOtherProperties.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await TopLevelSelfReferencingComponentsWithOtherProperties.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -646,7 +646,7 @@ public async Task SerializeComponentsWithPathItemsAsJsonWorks() } }"; // Act - var actual = await ComponentsWithPathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await ComponentsWithPathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -683,7 +683,7 @@ public async Task SerializeComponentsWithPathItemsAsYamlWorks() type: integer"; // Act - var actual = await ComponentsWithPathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await ComponentsWithPathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiContactTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiContactTests.cs index f699b2f40..81050fcb4 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiContactTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiContactTests.cs @@ -34,7 +34,7 @@ public async Task SerializeBasicContactWorks( string expected) { // Arrange & Act - var actual = await BasicContact.SerializeAsync(version, format); + var actual = await BasicContact.SerializeAsync(version, format, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -59,7 +59,7 @@ public async Task SerializeAdvanceContactAsJsonWorks(OpenApiSpecVersion version) """; // Act - var actual = await AdvanceContact.SerializeAsJsonAsync(version); + var actual = await AdvanceContact.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -82,7 +82,7 @@ public async Task SerializeAdvanceContactAsYamlWorks(OpenApiSpecVersion version) """; // Act - var actual = await AdvanceContact.SerializeAsYamlAsync(version); + var actual = await AdvanceContact.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDiscriminatorTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiDiscriminatorTests.cs new file mode 100644 index 000000000..256a780c4 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDiscriminatorTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models +{ + [Collection("DefaultSettings")] + public class OpenApiDiscriminatorTests + { + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_2, "defaultMapping", "Pet", true)] + [InlineData(OpenApiSpecVersion.OpenApi3_2, "defaultMapping", "#/components/schemas/Pet", true)] + [InlineData(OpenApiSpecVersion.OpenApi3_2, "defaultMapping", "Pet", false)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, "x-oas-default-mapping", "Pet", true)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, "x-oas-default-mapping", "#/components/schemas/Pet", true)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, "x-oas-default-mapping", "Pet", false)] + public async Task SerializeDefaultMappingAsReferenceString(OpenApiSpecVersion specVersion, string propertyName, string referenceId, bool useDocument) + { + // Arrange + var document = useDocument ? new OpenApiDocument() : null; + var discriminator = new OpenApiDiscriminator + { + PropertyName = "pet_type", + DefaultMapping = new OpenApiSchemaReference(referenceId, document) + }; + + // Act + var actual = JsonNode.Parse(await discriminator.SerializeAsJsonAsync(specVersion, TestContext.Current.CancellationToken)); + + // Assert + Assert.NotNull(actual); + Assert.Equal("#/components/schemas/Pet", actual[propertyName]?.GetValue()); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeMappingValuesAsReferenceStrings(OpenApiSpecVersion specVersion) + { + // Arrange + var document = new OpenApiDocument(); + var discriminator = new OpenApiDiscriminator + { + PropertyName = "pet_type", + Mapping = new Dictionary + { + ["simple"] = new OpenApiSchemaReference("Pet", document), + ["explicit"] = new OpenApiSchemaReference("#/components/schemas/Pet", document), + ["noDocument"] = new OpenApiSchemaReference("Pet") + } + }; + + // Act + var actual = JsonNode.Parse(await discriminator.SerializeAsJsonAsync(specVersion, TestContext.Current.CancellationToken)); + + // Assert + Assert.NotNull(actual); + var mapping = Assert.IsAssignableFrom(actual["mapping"]); + AssertMappingValue(mapping, "simple"); + AssertMappingValue(mapping, "explicit"); + AssertMappingValue(mapping, "noDocument"); + } + + private static void AssertMappingValue(JsonObject mapping, string key) + { + var value = Assert.IsAssignableFrom(mapping[key]); + Assert.Equal("#/components/schemas/Pet", value.GetValue()); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDuplicateExtensionsAsV3JsonWorksAsync_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDuplicateExtensionsAsV3JsonWorksAsync_produceTerseOutput=True.verified.txt index b87f4acf5..479557a10 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDuplicateExtensionsAsV3JsonWorksAsync_produceTerseOutput=True.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDuplicateExtensionsAsV3JsonWorksAsync_produceTerseOutput=True.verified.txt @@ -1 +1 @@ -{"openapi":"3.0.4","info":{"title":"Swagger Petstore (Simple)","description":"A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification","version":"1.0.0"},"servers":[{"url":"http://petstore.swagger.io/api"}],"paths":{"/add/{operand1}/{operand2}":{"get":{"operationId":"addByOperand1AndByOperand2","parameters":[{"name":"operand1","in":"path","description":"The first operand","required":true,"schema":{"type":"integer","my-extension":4},"my-extension":4},{"name":"operand2","in":"path","description":"The second operand","required":true,"schema":{"type":"integer","my-extension":4},"my-extension":4}],"responses":{"200":{"description":"pet response","content":{"application/json":{"schema":{"type":"array","items":{"required":["id","name"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}}}}}}}}}},"components":{"schemas":{"pet":{"required":["id","name"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"newPet":{"required":["name"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"errorModel":{"required":["code","message"],"type":"object","properties":{"code":{"type":"integer","format":"int32"},"message":{"type":"string"}}}}}} +{"openapi":"3.0.4","info":{"title":"Swagger Petstore (Simple)","description":"A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification","version":"1.0.0"},"servers":[{"url":"http://petstore.swagger.io/api"}],"paths":{"/add/{operand1}/{operand2}":{"get":{"operationId":"addByOperand1AndByOperand2","parameters":[{"name":"operand1","in":"path","description":"The first operand","required":true,"schema":{"type":"integer","my-extension":4},"my-extension":4},{"name":"operand2","in":"path","description":"The second operand","required":true,"schema":{"type":"integer","my-extension":4},"my-extension":4}],"responses":{"200":{"description":"pet response","content":{"application/json":{"schema":{"type":"array","items":{"required":["id","name"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}}}}}}}}}},"components":{"schemas":{"pet":{"required":["id","name"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"newPet":{"required":["name"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"errorModel":{"required":["code","message"],"type":"object","properties":{"code":{"type":"integer","format":"int32"},"message":{"type":"string"}}}}}} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs index 7a47b8597..8b4629664 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs @@ -1334,7 +1334,7 @@ public async Task SerializeAdvancedDocumentAsV3JsonWorksAsync(bool produceTerseO // Act AdvancedDocument.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1351,7 +1351,7 @@ public async Task SerializeAdvancedDocumentWithReferenceAsV3JsonWorksAsync(bool // Act AdvancedDocumentWithReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1368,7 +1368,7 @@ public async Task SerializeAdvancedDocumentWithServerVariableAsV2JsonWorksAsync( // Act AdvancedDocumentWithServerVariable.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1385,7 +1385,7 @@ public async Task SerializeAdvancedDocumentAsV2JsonWorksAsync(bool produceTerseO // Act AdvancedDocument.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1402,7 +1402,7 @@ public async Task SerializeDuplicateExtensionsAsV3JsonWorksAsync(bool produceTer // Act DuplicateExtensions.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1419,7 +1419,7 @@ public async Task SerializeDuplicateExtensionsAsV2JsonWorksAsync(bool produceTer // Act DuplicateExtensions.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1436,7 +1436,7 @@ public async Task SerializeAdvancedDocumentWithReferenceAsV2JsonWorksAsync(bool // Act AdvancedDocumentWithReference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -1460,7 +1460,7 @@ public async Task SerializeSimpleDocumentWithTopLevelReferencingComponentsAsYaml type: string"; // Act - var actual = await SimpleDocumentWithTopLevelReferencingComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await SimpleDocumentWithTopLevelReferencingComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1479,7 +1479,7 @@ public async Task SerializeSimpleDocumentWithTopLevelSelfReferencingComponentsAs schema1: { }"; // Act - var actual = await SimpleDocumentWithTopLevelSelfReferencingComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await SimpleDocumentWithTopLevelSelfReferencingComponents.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1507,7 +1507,7 @@ public async Task SerializeSimpleDocumentWithTopLevelSelfReferencingWithOtherPro type: string"; // Act - var actual = await SimpleDocumentWithTopLevelSelfReferencingComponentsWithOtherProperties.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await SimpleDocumentWithTopLevelSelfReferencingComponentsWithOtherProperties.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1553,7 +1553,7 @@ public async Task SerializeDocumentWithReferenceButNoComponents() ((OpenApiMediaType)document.Paths["/"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"]).Schema = new OpenApiSchemaReference("test", document); // Act - var actual = await document.SerializeAsync(OpenApiSpecVersion.OpenApi2_0, OpenApiConstants.Json); + var actual = await document.SerializeAsync(OpenApiSpecVersion.OpenApi2_0, OpenApiConstants.Json, TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(actual); @@ -1581,7 +1581,7 @@ public async Task SerializeRelativePathAsV2JsonWorks() }; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1611,7 +1611,7 @@ public async Task SerializeRelativePathWithHostAsV2JsonWorks() }; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1640,7 +1640,7 @@ public async Task SerializeRelativeRootPathWithHostAsV2JsonWorks() }; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1653,18 +1653,18 @@ public async Task TestHashCodesForSimilarOpenApiDocuments() // Arrange var sampleFolderPath = "Models/Samples/"; - var doc1 = await ParseInputFileAsync(Path.Combine(sampleFolderPath, "sampleDocument.yaml")); - var doc2 = await ParseInputFileAsync(Path.Combine(sampleFolderPath, "sampleDocument.yaml")); - var doc3 = await ParseInputFileAsync(Path.Combine(sampleFolderPath, "sampleDocumentWithWhiteSpaces.yaml")); + var doc1 = await ParseInputFileAsync(Path.Join(sampleFolderPath, "sampleDocument.yaml")); + var doc2 = await ParseInputFileAsync(Path.Join(sampleFolderPath, "sampleDocument.yaml")); + var doc3 = await ParseInputFileAsync(Path.Join(sampleFolderPath, "sampleDocumentWithWhiteSpaces.yaml")); // Act && Assert /* Test whether reading in two similar documents yield the same hash code, And reading in similar documents(one has a whitespace) yields the same hash code as the result is terse */ - var doc1HashCode = await doc1.GetHashCodeAsync(); - var doc2HashCode = await doc2.GetHashCodeAsync(); - var doc3HashCode = await doc3.GetHashCodeAsync(); + var doc1HashCode = await doc1.GetHashCodeAsync(TestContext.Current.CancellationToken); + var doc2HashCode = await doc2.GetHashCodeAsync(TestContext.Current.CancellationToken); + var doc3HashCode = await doc3.GetHashCodeAsync(TestContext.Current.CancellationToken); Assert.NotNull(doc1HashCode); Assert.NotNull(doc2HashCode); Assert.NotNull(doc3HashCode); @@ -1725,7 +1725,7 @@ public async Task SerializeV2DocumentWithNonArraySchemaTypeDoesNotWriteOutCollec }; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1813,7 +1813,7 @@ public async Task SerializeV2DocumentWithStyleAsNullDoesNotWriteOutStyleValue() }; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1885,7 +1885,7 @@ public async Task SerializeDocumentWithWebhooksAsV3JsonWorks(bool produceTerseOu // Act DocumentWithWebhooks.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -1929,7 +1929,7 @@ public async Task SerializeDocumentWithWebhooksAsV3YamlWorks() description: Return a 200 status to indicate that the data was received successfully"; // Act - var actual = await DocumentWithWebhooks.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await DocumentWithWebhooks.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -1959,7 +1959,7 @@ public async Task SerializeDocumentWithRootJsonSchemaDialectPropertyWorks() paths: { }"; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); @@ -1980,7 +1980,7 @@ public async Task SerializeV31DocumentWithRefsInWebhooksWorks() items: type: object"; - var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithReusableWebhooks.yaml", SettingsFixture.ReaderSettings)).Document; + var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithReusableWebhooks.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var stringWriter = new StringWriter(); var writer = new OpenApiYamlWriter(stringWriter, new OpenApiWriterSettings { InlineLocalReferences = true }); @@ -2034,8 +2034,8 @@ public async Task SerializeDocWithDollarIdInDollarRefSucceeds() radius: type: number "; - var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithDollarId.yaml", SettingsFixture.ReaderSettings)).Document; - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithDollarId.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); } @@ -2089,7 +2089,7 @@ public async Task SerializeDocumentTagsWithMultipleExtensionsWorks() } }; - var actual = await doc.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await doc.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); } [Fact] @@ -2145,6 +2145,17 @@ public void TagsSupportsCustomComparer() Assert.Equal(2, document.Tags.Count); } + [Fact] + public void TagsCanBeReInitializedToNull() + { + var document = new OpenApiDocument(); + Assert.Null(document.Tags); + document.Tags = new HashSet(); + Assert.NotNull(document.Tags); + document.Tags = null; + Assert.Null(document.Tags); + } + private sealed class CaseInsensitiveOpenApiTagEqualityComparer : IEqualityComparer { public bool Equals(OpenApiTag x, OpenApiTag y) @@ -2225,7 +2236,7 @@ public async Task SerializeAdvancedDocumentAsVersionJsonWorksAsync(OpenApiSpecVe // Act AdvancedDocument.SerializeAs(version, writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(version); @@ -2283,7 +2294,7 @@ public async Task SerializeDocWithSecuritySchemeWithInlineReferencesWorks() scopes: api://a2a7226d-e8d1-4ded-8c53-dd4c136ff456/repairs_read: Read repair records"; - var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithSecurityScheme.yaml", SettingsFixture.ReaderSettings)).Document; + var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithSecurityScheme.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var stringWriter = new StringWriter(); doc.SerializeAsV3(new OpenApiYamlWriter(stringWriter, new OpenApiWriterSettings { InlineLocalReferences = true })); var actual = stringWriter.ToString(); @@ -2315,7 +2326,7 @@ public async Task SerializeDocWithoutOperationSecurityWorks() type: object """; - var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithoutOperationSecurity.yaml", SettingsFixture.ReaderSettings)).Document; + var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithoutOperationSecurity.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var stringWriter = new StringWriter(); doc!.SerializeAsV3(new OpenApiYamlWriter(stringWriter, new OpenApiWriterSettings { InlineLocalReferences = true })); var actual = stringWriter.ToString(); @@ -2350,7 +2361,7 @@ public async Task SerializeDocWithEmptyOperationSecurityWorks() security: [ ] """; - var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithEmptyOperationSecurity.yaml", SettingsFixture.ReaderSettings)).Document; + var doc = (await OpenApiDocument.LoadAsync("Models/Samples/docWithEmptyOperationSecurity.yaml", SettingsFixture.ReaderSettings, token: TestContext.Current.CancellationToken)).Document; var stringWriter = new StringWriter(); doc!.SerializeAsV3(new OpenApiYamlWriter(stringWriter, new OpenApiWriterSettings { InlineLocalReferences = true })); var actual = stringWriter.ToString(); @@ -2382,7 +2393,7 @@ public async Task SerializeDocumentWithSelfPropertyAsV32Works() paths: { }"; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); @@ -2430,7 +2441,7 @@ public async Task SerializeDocumentWithSecurityRequirementAsJsonWorks(OpenApiSpe """; // Act - var actual = await doc.SerializeAsJsonAsync(openApiSpecVersion); + var actual = await doc.SerializeAsJsonAsync(openApiSpecVersion, TestContext.Current.CancellationToken); // Assert var actualSecurity = JsonNode.Parse(actual)?["security"]; @@ -2460,7 +2471,7 @@ public async Task SerializeDocumentWithSelfPropertyAsV31WritesAsExtension() x-oai-$self: https://example.org/api/openapi.json"; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); @@ -2488,10 +2499,203 @@ public async Task SerializeDocumentWithSelfPropertyAsV30WritesAsExtension() x-oai-$self: https://example.org/api/openapi.json"; // Act - var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await doc.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); } + + [Fact] + public void GetOperationById_ReturnsMatchingOperation() + { + var operation = new OpenApiOperation { OperationId = "getUser" }; + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users/{id}"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = operation + } + } + } + }; + + var result = doc.GetOperationById("getUser"); + + Assert.Same(operation, result); + } + + [Fact] + public void GetOperationById_ReturnsNullWhenNotFound() + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "listUsers" } + } + } + } + }; + + var result = doc.GetOperationById("nonExistentId"); + + Assert.Null(result); + } + + [Fact] + public void GetOperationById_SearchesWebhooks() + { + var webhookOperation = new OpenApiOperation { OperationId = "onUserCreated" }; + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = [], + Webhooks = new Dictionary + { + ["userCreated"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = webhookOperation + } + } + } + }; + + var result = doc.GetOperationById("onUserCreated"); + + Assert.Same(webhookOperation, result); + } + + [Fact] + public void GetOperationById_IsCaseSensitive() + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "getUser" } + } + } + } + }; + + Assert.NotNull(doc.GetOperationById("getUser")); + Assert.Null(doc.GetOperationById("GetUser")); + Assert.Null(doc.GetOperationById("GETUSER")); + } + + [Fact] + public void GetOperationById_ResolvesOperationThroughPathItemReference() + { + const string yaml = """ + openapi: '3.1.0' + info: + title: Test + version: 1.0.0 + paths: + /users: + $ref: '#/components/pathItems/userPathItem' + components: + pathItems: + userPathItem: + get: + operationId: listUsers + responses: + '200': + description: OK + """; + + var doc = OpenApiDocument.Parse(yaml, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings).Document; + doc.Workspace.RegisterComponents(doc); + + var result = doc.GetOperationById("listUsers"); + + Assert.NotNull(result); + Assert.Equal("listUsers", result.OperationId); + } + + [Fact] + public void GetOperationById_DuplicateIdReturnsFirstMatch() + { + // operationId must be unique per spec, but if not, Paths takes priority over Webhooks + var pathsOperation = new OpenApiOperation { OperationId = "duplicateId" }; + var webhooksOperation = new OpenApiOperation { OperationId = "duplicateId" }; + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = pathsOperation + } + } + }, + Webhooks = new Dictionary + { + ["userEvent"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = webhooksOperation + } + } + } + }; + + var result = doc.GetOperationById("duplicateId"); + + Assert.Same(pathsOperation, result); + } + + [Fact] + public void GetOperationById_UnresolvedPathItemReferenceIsSkipped() + { + // An unresolved $ref has Target = null, so Operations = null — should be skipped gracefully + var unresolvedRef = new OpenApiPathItemReference("nonExistentPathItem", null); + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = unresolvedRef + } + }; + + var result = doc.GetOperationById("anyId"); + + Assert.Null(result); + } + + [Fact] + public void GetOperationById_ThrowsOnNullOrEmptyId() + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = [] + }; + + Assert.Throws(() => doc.GetOperationById(null!)); + Assert.Throws(() => doc.GetOperationById(string.Empty)); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiEncodingTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiEncodingTests.cs index a1280d48e..819f98cc3 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiEncodingTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiEncodingTests.cs @@ -27,7 +27,7 @@ public class OpenApiEncodingTests public async Task SerializeBasicEncodingAsV3Works(string format, string expected) { // Arrange & Act - var actual = await BasicEncoding.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format); + var actual = await BasicEncoding.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -50,7 +50,7 @@ public async Task SerializeAdvanceEncodingAsV3JsonWorks() """; // Act - var actual = await AdvanceEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvanceEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -71,7 +71,7 @@ public async Task SerializeAdvanceEncodingAsV3YamlWorks() """; // Act - var actual = await AdvanceEncoding.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvanceEncoding.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -121,7 +121,7 @@ public async Task WhenExplodeIsSetOutputShouldHaveExplode(bool? expectedExplode, } // Act - var actual = await parameter.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await parameter.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -169,7 +169,7 @@ public async Task SerializeEncodingWithNestedEncodingAsV32JsonWorks() """; // Act - var actual = await encoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await encoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -210,7 +210,7 @@ public async Task SerializeEncodingWithNestedEncodingAsV32YamlWorks() """; // Act - var actual = await encoding.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await encoding.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -267,7 +267,7 @@ public async Task SerializeEncodingWithItemAndPrefixEncodingAsV32JsonWorks() """; // Act - var actual = await encoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await encoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -315,7 +315,7 @@ public async Task SerializeEncodingAsV31ShouldUseExtensionsForNewFields() """; // Act - var actual = await encoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await encoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiExampleTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiExampleTests.cs index e9f5e3d62..ecd87836b 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiExampleTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiExampleTests.cs @@ -104,7 +104,7 @@ public async Task SerializeAdvancedExampleAsV3JsonWorksAsync(bool produceTerseOu // Act AdvancedExample.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -121,7 +121,7 @@ public async Task SerializeReferencedExampleAsV3JsonWorksAsync(bool produceTerse // Act OpenApiExampleReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -138,7 +138,7 @@ public async Task SerializeReferencedExampleAsV3JsonWithoutReferenceWorksAsync(b // Act ReferencedExample.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -164,7 +164,7 @@ public async Task SerializeExampleWithDataValueAsV32JsonWorksAsync(bool produceT // Act example.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -190,7 +190,7 @@ public async Task SerializeExampleWithDataValueAsV31JsonWorksAsync(bool produceT // Act example.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -212,7 +212,7 @@ public async Task SerializeExampleWithSerializedValueAsV32JsonWorksAsync(bool pr // Act example.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -234,7 +234,7 @@ public async Task SerializeExampleWithSerializedValueAsV31JsonWorksAsync(bool pr // Act example.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiExternalDocsTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiExternalDocsTests.cs index 9a290d425..95bcde5d1 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiExternalDocsTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiExternalDocsTests.cs @@ -25,7 +25,7 @@ public class OpenApiExternalDocsTests public async Task SerializeBasicExternalDocsAsV3Works(string format, string expected) { // Arrange & Act - var actual = await BasicExDocs.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format); + var actual = await BasicExDocs.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -46,7 +46,7 @@ public async Task SerializeAdvanceExDocsAsV3JsonWorks() """; // Act - var actual = await AdvanceExDocs.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvanceExDocs.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -65,7 +65,7 @@ public async Task SerializeAdvanceExDocsAsV3YamlWorks() """; // Act - var actual = await AdvanceExDocs.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvanceExDocs.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiHeaderTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiHeaderTests.cs index 0f57503b3..a42fd1fb6 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiHeaderTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiHeaderTests.cs @@ -45,7 +45,7 @@ public async Task SerializeAdvancedHeaderAsV3JsonWorksAsync(bool produceTerseOut // Act AdvancedHeader.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -62,7 +62,7 @@ public async Task SerializeReferencedHeaderAsV3JsonWorksAsync(bool produceTerseO // Act OpenApiHeaderReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -79,7 +79,7 @@ public async Task SerializeReferencedHeaderAsV3JsonWithoutReferenceWorksAsync(bo // Act ReferencedHeader.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -96,7 +96,7 @@ public async Task SerializeAdvancedHeaderAsV2JsonWorksAsync(bool produceTerseOut // Act AdvancedHeader.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -113,7 +113,7 @@ public async Task SerializeReferencedHeaderAsV2JsonWorksAsync(bool produceTerseO // Act OpenApiHeaderReference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -130,7 +130,7 @@ public async Task SerializeReferencedHeaderAsV2JsonWithoutReferenceWorksAsync(bo // Act ReferencedHeader.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiInfoTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiInfoTests.cs index 57c3e1c7e..eaded3a3f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiInfoTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiInfoTests.cs @@ -61,7 +61,7 @@ public static IEnumerable BasicInfoJsonExpected() public async Task SerializeBasicInfoAsJsonWorks(OpenApiSpecVersion version, string expected) { // Arrange & Act - var actual = await BasicInfo.SerializeAsJsonAsync(version); + var actual = await BasicInfo.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -90,7 +90,7 @@ public static IEnumerable BasicInfoYamlExpected() public async Task SerializeBasicInfoAsYamlWorks(OpenApiSpecVersion version, string expected) { // Arrange & Act - var actual = await BasicInfo.SerializeAsYamlAsync(version); + var actual = await BasicInfo.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -135,7 +135,7 @@ public static IEnumerable AdvanceInfoJsonExpect() public async Task SerializeAdvanceInfoAsJsonWorks(OpenApiSpecVersion version, string expected) { // Arrange & Act - var actual = await AdvanceInfo.SerializeAsJsonAsync(version); + var actual = await AdvanceInfo.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -176,7 +176,7 @@ public static IEnumerable AdvanceInfoYamlExpect() public async Task SerializeAdvanceInfoAsYamlWorks(OpenApiSpecVersion version, string expected) { // Arrange & Act - var actual = await AdvanceInfo.SerializeAsYamlAsync(version); + var actual = await AdvanceInfo.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -201,7 +201,7 @@ public async Task InfoVersionShouldAcceptDateStyledAsVersions() """; // Act - var actual = await info.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, OpenApiConstants.Yaml); + var actual = await info.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, OpenApiConstants.Yaml, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -219,7 +219,7 @@ public async Task SerializeInfoObjectWithSummaryAsV31YamlWorks() summary: This is a sample server for a pet store."; // Act - var actual = await InfoWithSummary.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await InfoWithSummary.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -239,7 +239,7 @@ public async Task SerializeInfoObjectWithSummaryAsV31JsonWorks() }"; // Act - var actual = await InfoWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await InfoWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiLicenseTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiLicenseTests.cs index 58cdae2ce..0f9a99c18 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiLicenseTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiLicenseTests.cs @@ -45,7 +45,7 @@ public async Task SerializeBasicLicenseAsJsonWorks(OpenApiSpecVersion version) """; // Act - var actual = await BasicLicense.SerializeAsJsonAsync(version); + var actual = await BasicLicense.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -62,7 +62,7 @@ public async Task SerializeBasicLicenseAsYamlWorks(OpenApiSpecVersion version) var expected = "name: Apache 2.0"; // Act - var actual = await BasicLicense.SerializeAsYamlAsync(version); + var actual = await BasicLicense.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -86,7 +86,7 @@ public async Task SerializeAdvanceLicenseAsJsonWorks(OpenApiSpecVersion version) """; // Act - var actual = await AdvanceLicense.SerializeAsJsonAsync(version); + var actual = await AdvanceLicense.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -108,7 +108,7 @@ public async Task SerializeAdvanceLicenseAsYamlWorks(OpenApiSpecVersion version) """; // Act - var actual = await AdvanceLicense.SerializeAsYamlAsync(version); + var actual = await AdvanceLicense.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -142,7 +142,26 @@ public async Task SerializeLicenseWithIdentifierAsJsonWorks() }"; // Act - var actual = await LicenseWithIdentifier.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await LicenseWithIdentifier.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + public async Task SerializeLicenseWithIdentifierAsJsonWorksForV3AndV2(OpenApiSpecVersion version) + { + // Arrange + var expected = + @"{ + ""name"": ""Apache 2.0"", + ""x-oai-license-identifier"": ""Apache-2.0"" +}"; + + // Act + var actual = await LicenseWithIdentifier.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); @@ -156,7 +175,23 @@ public async Task SerializeLicenseWithIdentifierAsYamlWorks() identifier: Apache-2.0"; // Act - var actual = await LicenseWithIdentifier.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await LicenseWithIdentifier.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + public async Task SerializeLicenseWithIdentifierAsYamlWorksForV3AndV2(OpenApiSpecVersion version) + { + // Arrange + var expected = @"name: Apache 2.0 +x-oai-license-identifier: Apache-2.0"; + + // Act + var actual = await LicenseWithIdentifier.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiLinkTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiLinkTests.cs index f67e82356..bf5e49b2e 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiLinkTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiLinkTests.cs @@ -74,7 +74,7 @@ public async Task SerializeAdvancedLinkAsV3JsonWorksAsync(bool produceTerseOutpu // Act AdvancedLink.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -91,7 +91,7 @@ public async Task SerializeReferencedLinkAsV3JsonWorksAsync(bool produceTerseOut // Act LinkReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -108,7 +108,7 @@ public async Task SerializeReferencedLinkAsV3JsonWithoutReferenceWorksAsync(bool // Act ReferencedLink.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiMediaTypeTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiMediaTypeTests.cs index 6ad046fcd..23d0b8fbb 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiMediaTypeTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiMediaTypeTests.cs @@ -5,7 +5,6 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; using Xunit; -using Xunit.Abstractions; namespace Microsoft.OpenApi.Tests.Models { @@ -151,7 +150,7 @@ public OpenApiMediaTypeTests(ITestOutputHelper output) public async Task SerializeBasicMediaTypeAsV3Works(string format, string expected) { // Arrange & Act - var actual = await BasicMediaType.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format); + var actual = await BasicMediaType.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format, cancellationToken: TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -179,7 +178,7 @@ public async Task SerializeAdvanceMediaTypeAsV3JsonWorks() """; // Act - var actual = await AdvanceMediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvanceMediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -201,7 +200,7 @@ public async Task SerializeAdvanceMediaTypeAsV3YamlWorks() """; // Act - var actual = await AdvanceMediaType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvanceMediaType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -236,7 +235,7 @@ public async Task SerializeMediaTypeWithObjectExampleAsV3YamlWorks() """; // Act - var actual = await MediaTypeWithObjectExample.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await MediaTypeWithObjectExample.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -287,7 +286,7 @@ public async Task SerializeMediaTypeWithObjectExampleAsV3JsonWorks() """; // Act - var actual = await MediaTypeWithObjectExample.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await MediaTypeWithObjectExample.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -309,7 +308,7 @@ public async Task SerializeMediaTypeWithXmlExampleAsV3YamlWorks() """; // Act - var actual = await MediaTypeWithXmlExample.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await MediaTypeWithXmlExample.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -337,7 +336,7 @@ public async Task SerializeMediaTypeWithXmlExampleAsV3JsonWorks() """; // Act - var actual = await MediaTypeWithXmlExample.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await MediaTypeWithXmlExample.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -372,7 +371,7 @@ public async Task SerializeMediaTypeWithObjectExamplesAsV3YamlWorks() """; // Act - var actual = await MediaTypeWithObjectExamples.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await MediaTypeWithObjectExamples.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); _output.WriteLine(actual); // Assert @@ -428,7 +427,7 @@ public async Task SerializeMediaTypeWithObjectExamplesAsV3JsonWorks() """; // Act - var actual = await MediaTypeWithObjectExamples.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await MediaTypeWithObjectExamples.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); _output.WriteLine(actual); // Assert @@ -485,7 +484,7 @@ public async Task SerializeMediaTypeWithItemSchemaAsV32JsonWorks() """; // Act - var actual = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -522,7 +521,7 @@ public async Task SerializeMediaTypeWithItemSchemaAsV31JsonWorks() """; // Act - var actual = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -559,7 +558,7 @@ public async Task SerializeMediaTypeWithItemSchemaAsV3JsonWorks() """; // Act - var actual = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await mediaType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -603,7 +602,7 @@ public async Task SerializeMediaTypeWithItemEncodingAsV32JsonWorks() """; // Act - var actual = await MediaTypeWithItemEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await MediaTypeWithItemEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -626,7 +625,7 @@ public async Task SerializeMediaTypeWithItemEncodingAsV31JsonWorks() """; // Act - var actual = await MediaTypeWithItemEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await MediaTypeWithItemEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -655,7 +654,7 @@ public async Task SerializeMediaTypeWithPrefixEncodingAsV32JsonWorks() """; // Act - var actual = await MediaTypeWithPrefixEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await MediaTypeWithPrefixEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -684,7 +683,7 @@ public async Task SerializeMediaTypeWithPrefixEncodingAsV31JsonWorks() """; // Act - var actual = await MediaTypeWithPrefixEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await MediaTypeWithPrefixEncoding.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowTests.cs index 73cb64e95..86c1a7cfb 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowTests.cs @@ -60,7 +60,7 @@ public async Task SerializeBasicOAuthFlowAsV3JsonWorks() """; // Act - var actual = await BasicOAuthFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicOAuthFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -76,7 +76,7 @@ public async Task SerializeBasicOAuthFlowAsV3YamlWorks() @"scopes: { }"; // Act - var actual = await BasicOAuthFlow.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicOAuthFlow.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -100,7 +100,7 @@ public async Task SerializePartialOAuthFlowAsV3JsonWorks() """; // Act - var actual = await PartialOAuthFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await PartialOAuthFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -126,7 +126,7 @@ public async Task SerializeCompleteOAuthFlowAsV3JsonWorks() """; // Act - var actual = await CompleteOAuthFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await CompleteOAuthFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -153,7 +153,7 @@ public async Task SerializeOAuthFlowWithDeviceAuthorizationUrlAsV32JsonWorks() """; // Act - var actual = await OAuthFlowWithDeviceAuthorizationUrl.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await OAuthFlowWithDeviceAuthorizationUrl.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -178,7 +178,7 @@ public async Task SerializeOAuthFlowWithDeviceAuthorizationUrlAsV31JsonWorks() """; // Act - var actual = await OAuthFlowWithDeviceAuthorizationUrl.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await OAuthFlowWithDeviceAuthorizationUrl.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -203,7 +203,7 @@ public async Task SerializeOAuthFlowWithDeviceAuthorizationUrlAsV3JsonWorks() """; // Act - var actual = await OAuthFlowWithDeviceAuthorizationUrl.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OAuthFlowWithDeviceAuthorizationUrl.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowsTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowsTests.cs index cba2e1c93..550c7d7d5 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowsTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiOAuthFlowsTests.cs @@ -71,7 +71,7 @@ public async Task SerializeBasicOAuthFlowsAsV3JsonWorks() @"{ }"; // Act - var actual = await BasicOAuthFlows.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicOAuthFlows.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -87,7 +87,7 @@ public async Task SerializeBasicOAuthFlowsAsV3YamlWorks() @"{ }"; // Act - var actual = await BasicOAuthFlows.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicOAuthFlows.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -113,7 +113,7 @@ public async Task SerializeOAuthFlowsWithSingleFlowAsV3JsonWorks() """; // Act - var actual = await OAuthFlowsWithSingleFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OAuthFlowsWithSingleFlow.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -147,7 +147,7 @@ public async Task SerializeOAuthFlowsWithMultipleFlowsAsV3JsonWorks() """; // Act - var actual = await OAuthFlowsWithMultipleFlows.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OAuthFlowsWithMultipleFlows.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -174,7 +174,7 @@ public async Task SerializeOAuthFlowsWithDeviceAuthorizationAsV32JsonWorks() """; // Act - var actual = await OAuthFlowsWithDeviceAuthorization.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await OAuthFlowsWithDeviceAuthorization.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -199,7 +199,7 @@ public async Task SerializeOAuthFlowsWithDeviceAuthorizationAsV31JsonWorks() """; // Act - var actual = await OAuthFlowsWithDeviceAuthorization.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await OAuthFlowsWithDeviceAuthorization.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -224,7 +224,7 @@ public async Task SerializeOAuthFlowsWithDeviceAuthorizationAsV3JsonWorks() """; // Act - var actual = await OAuthFlowsWithDeviceAuthorization.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OAuthFlowsWithDeviceAuthorization.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiOperationTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiOperationTests.cs index b82a51e75..d281f4437 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiOperationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiOperationTests.cs @@ -293,7 +293,7 @@ public async Task SerializeBasicOperationAsV3JsonWorks() """; // Act - var actual = await _basicOperation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await _basicOperation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -343,7 +343,6 @@ public async Task SerializeOperationWithBodyAsV3JsonWorks() "$ref": "#/components/responses/response1" }, "400": { - "description": null, "content": { "application/json": { "schema": { @@ -365,7 +364,7 @@ public async Task SerializeOperationWithBodyAsV3JsonWorks() """; // Act - var actual = await _operationWithBody.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await _operationWithBody.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -416,7 +415,6 @@ public async Task SerializeAdvancedOperationWithTagAndSecurityAsV3JsonWorks() "$ref": "#/components/responses/response1" }, "400": { - "description": null, "content": { "application/json": { "schema": { @@ -447,7 +445,7 @@ public async Task SerializeAdvancedOperationWithTagAndSecurityAsV3JsonWorks() """; // Act - var actual = await _advancedOperationWithTagsAndSecurity.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await _advancedOperationWithTagsAndSecurity.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -465,7 +463,7 @@ public async Task SerializeBasicOperationAsV2JsonWorks() """; // Act - var actual = await _basicOperation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await _basicOperation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -542,7 +540,7 @@ public async Task SerializeOperationWithFormDataAsV3JsonWorks() """; // Act - var actual = await _operationWithFormData.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await _operationWithFormData.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -596,7 +594,7 @@ public async Task SerializeOperationWithFormDataAsV2JsonWorks() """; // Act - var actual = await _operationWithFormData.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await _operationWithFormData.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -648,7 +646,6 @@ public async Task SerializeOperationWithBodyAsV2JsonWorks() "$ref": "#/responses/response1" }, "400": { - "description": null, "schema": { "type": "number", "maximum": 10, @@ -663,7 +660,7 @@ public async Task SerializeOperationWithBodyAsV2JsonWorks() """; // Act - var actual = await _operationWithBody.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await _operationWithBody.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -718,7 +715,6 @@ public async Task SerializeAdvancedOperationWithTagAndSecurityAsV2JsonWorks() "$ref": "#/responses/response1" }, "400": { - "description": null, "schema": { "type": "number", "maximum": 10, @@ -742,7 +738,7 @@ public async Task SerializeAdvancedOperationWithTagAndSecurityAsV2JsonWorks() """; // Act - var actual = await _advancedOperationWithTagsAndSecurity.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await _advancedOperationWithTagsAndSecurity.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -765,7 +761,7 @@ public async Task SerializeOperationWithNullCollectionAsV2JsonWorks() }; // Act - var actual = await operation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await operation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -826,9 +822,9 @@ public async Task EnsureOpenApiOperationCopyConstructor_SerializationResultsInSa foreach (var operation in operations) { // Act - var expected = await operation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = await operation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); var openApiOperation = new OpenApiOperation(operation); - var actual = await openApiOperation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await openApiOperation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs index cf0348fa9..04f158bc2 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs @@ -86,7 +86,7 @@ public async Task SerializeCookieParameterAsV32JsonWorks() """; // Act - var actual = await CookieParameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await CookieParameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -109,7 +109,7 @@ public async Task SerializeCookieParameterWithDefaultStyleAsV32JsonWorks() """; // Act - var actual = await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -155,9 +155,9 @@ public async Task SerializeCookieParameterWithDefaultStyleWorksForEarlierVersion // Arrange & Act string actual = version switch { - OpenApiSpecVersion.OpenApi2_0 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0), - OpenApiSpecVersion.OpenApi3_0 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0), - OpenApiSpecVersion.OpenApi3_1 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1), + OpenApiSpecVersion.OpenApi2_0 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken), + OpenApiSpecVersion.OpenApi3_0 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken), + OpenApiSpecVersion.OpenApi3_1 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException() }; @@ -177,7 +177,7 @@ public async Task SerializeCookieParameterAsV32JsonWorksAsync(bool produceTerseO // Act CookieParameter.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -221,7 +221,7 @@ public async Task SerializeCookieParameterAsYamlV32Works() """; // Act - var actual = await CookieParameter.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await CookieParameter.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs index 0cd2726dd..e6c30fc04 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs @@ -172,7 +172,7 @@ public async Task WhenStyleAndInIsNullTheDefaultValueOfStyleShouldBeSimple(Param // Act & Assert parameter.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); Assert.Equal(expectedStyle, parameter.Style); } @@ -189,7 +189,7 @@ public async Task SerializeQueryParameterWithMissingStyleSucceeds() type: integer"; // Act - var actual = await QueryParameterWithMissingStyle.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await QueryParameterWithMissingStyle.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); @@ -208,7 +208,7 @@ public async Task SerializeBasicParameterAsV3JsonWorks() """; // Act - var actual = await BasicParameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicParameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -251,7 +251,7 @@ public async Task SerializeAdvancedParameterAsV3JsonWorks() """; // Act - var actual = await AdvancedPathParameterWithSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedPathParameterWithSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -281,7 +281,7 @@ public async Task SerializeAdvancedParameterAsV2JsonWorks() """; // Act - var actual = await AdvancedPathParameterWithSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await AdvancedPathParameterWithSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -300,7 +300,7 @@ public async Task SerializeReferencedParameterAsV3JsonWorksAsync(bool produceTer // Act OpenApiParameterReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -317,7 +317,7 @@ public async Task SerializeReferencedParameterAsV3JsonWithoutReferenceWorksAsync // Act ReferencedParameter.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -334,7 +334,7 @@ public async Task SerializeReferencedParameterAsV2JsonWorksAsync(bool produceTer // Act OpenApiParameterReference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -351,7 +351,7 @@ public async Task SerializeReferencedParameterAsV2JsonWithoutReferenceWorksAsync // Act ReferencedParameter.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -368,7 +368,7 @@ public async Task SerializeParameterWithSchemaTypeObjectAsV2JsonWorksAsync(bool // Act AdvancedHeaderParameterWithSchemaTypeObject.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -385,7 +385,7 @@ public async Task SerializeParameterWithFormStyleAndExplodeFalseWorksAsync(bool // Act ParameterWithFormStyleAndExplodeFalse.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -402,7 +402,7 @@ public async Task SerializeParameterWithFormStyleAndExplodeTrueWorksAsync(bool p // Act ParameterWithFormStyleAndExplodeTrue.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -451,7 +451,7 @@ public async Task SerializeQueryStringParameter_V32_Succeeds() Schema = null, // Explode must be false (default) and not set }; - var json = await parameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var json = await parameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); Assert.Contains("querystring", json); } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs index 5f76693a3..a27675e1d 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs @@ -130,7 +130,7 @@ public async Task SerializeAsV2JsonWorks() var parsedExpectedJson = JsonNode.Parse(expectedJson); // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); var parsedActualJson = JsonNode.Parse(actualJson); // Then @@ -262,7 +262,7 @@ public async Task SerializeAsV3JsonWorks() var parsedExpectedJson = JsonNode.Parse(expectedJson); // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); var parsedActualJson = JsonNode.Parse(actualJson); // Then @@ -394,7 +394,7 @@ public async Task SerializeAsV31JsonWorks() var parsedExpectedJson = JsonNode.Parse(expectedJson); // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); var parsedActualJson = JsonNode.Parse(actualJson); // Then @@ -517,7 +517,7 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() var parsedExpectedJson = JsonNode.Parse(expectedJson); // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); var parsedActualJson = JsonNode.Parse(actualJson); // Then @@ -574,7 +574,7 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() }; // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson)); // Then - should contain x-oai- prefixed extensions in the operation @@ -633,7 +633,7 @@ public async Task SerializeV32FeaturesAsExtensionsInV3Works() }; // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson)); // Then - should contain x-oai- prefixed extensions in the operation @@ -692,7 +692,7 @@ public async Task SerializeV32FeaturesAsExtensionsInV2Works() }; // When - var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson)); // Then - should contain x-oai- prefixed extensions in the operation @@ -790,7 +790,7 @@ public async Task SerializeV32PathItemToV31ProducesExtensions() }; // Act - var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); + var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.Contains("Query:", yaml); @@ -839,7 +839,7 @@ public async Task SerializeV32PathItemToV3ProducesExtensions() }; // Act - var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Contains("Query:", yaml); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceTests.cs index e2ad1f87c..a00963cfe 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceTests.cs @@ -97,7 +97,7 @@ public async Task SerializeSchemaReferenceAsJsonV3Works() """; // Act - var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); expected = expected.MakeLineBreaksEnvironmentNeutral(); actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -121,7 +121,7 @@ public async Task SerializeHttpSchemaReferenceAsJsonV31Works(string id, string r """; // Act - var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); expected = expected.MakeLineBreaksEnvironmentNeutral(); actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -142,7 +142,7 @@ public async Task SerializeSchemaReferenceAsYamlV3Works() var expected = @"$ref: '#/components/schemas/Pet'"; // Act - var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual); @@ -166,7 +166,7 @@ public async Task SerializeSchemaReferenceAsJsonV2Works() """.MakeLineBreaksEnvironmentNeutral(); // Act - var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual.MakeLineBreaksEnvironmentNeutral()); @@ -184,7 +184,7 @@ public async Task SerializeSchemaReferenceAsYamlV2Works() var expected = @"$ref: '#/definitions/Pet'"; // Act - var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual); @@ -209,7 +209,7 @@ public async Task SerializeExternalReferenceAsJsonV2Works() """; // Act - var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); expected = expected.MakeLineBreaksEnvironmentNeutral(); actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -230,7 +230,7 @@ public async Task SerializeExternalReferenceAsYamlV2Works() var expected = @"$ref: main.json#/definitions/Pets"; // Act - var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual); @@ -250,7 +250,7 @@ public async Task SerializeExternalReferenceAsJsonV3Works() """; // Act - var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await reference.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); expected = expected.MakeLineBreaksEnvironmentNeutral(); actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -266,7 +266,7 @@ public async Task SerializeExternalReferenceAsYamlV3Works() var expected = @"$ref: main.json#/components/schemas/Pets"; // Act - var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await reference.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected, actual); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs index d3df1e1ed..222de1c3c 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs @@ -57,7 +57,7 @@ public async Task SerializeAdvancedRequestBodyAsV3JsonWorksAsync(bool produceTer // Act AdvancedRequestBody.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -74,7 +74,7 @@ public async Task SerializeReferencedRequestBodyAsV3JsonWorksAsync(bool produceT // Act OpenApiRequestBodyReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -91,7 +91,7 @@ public async Task SerializeReferencedRequestBodyAsV3JsonWithoutReferenceWorksAsy // Act ReferencedRequestBody.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiResponseTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiResponseTests.cs index 5810e15fc..fdd291162 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiResponseTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiResponseTests.cs @@ -193,12 +193,10 @@ public async Task SerializeBasicResponseWorks( string format) { // Arrange - var expected = format == OpenApiConstants.Json ? @"{ - ""description"": null -}" : @"description: null"; + var expected = @"{ }"; // Act - var actual = await BasicResponse.SerializeAsync(version, format); + var actual = await BasicResponse.SerializeAsync(version, format, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -241,7 +239,7 @@ public async Task SerializeAdvancedResponseAsV3JsonWorks() }"; // Act - var actual = await AdvancedV3Response.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedV3Response.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -274,7 +272,7 @@ public async Task SerializeAdvancedResponseAsV3YamlWorks() myextension: myextensionvalue"; // Act - var actual = await AdvancedV3Response.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedV3Response.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -311,7 +309,7 @@ public async Task SerializeAdvancedResponseAsV2JsonWorks() }"; // Act - var actual = await AdvancedV2Response.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await AdvancedV2Response.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -341,7 +339,7 @@ public async Task SerializeAdvancedResponseAsV2YamlWorks() type: integer"; // Act - var actual = await AdvancedV2Response.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await AdvancedV2Response.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -360,7 +358,7 @@ public async Task SerializeReferencedResponseAsV3JsonWorksAsync(bool produceTers // Act V3OpenApiResponseReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -377,7 +375,7 @@ public async Task SerializeReferencedResponseAsV3JsonWithoutReferenceWorksAsync( // Act ReferencedV3Response.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -394,7 +392,7 @@ public async Task SerializeReferencedResponseAsV2JsonWorksAsync(bool produceTers // Act V2OpenApiResponseReference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -411,7 +409,7 @@ public async Task SerializeReferencedResponseAsV2JsonWithoutReferenceWorksAsync( // Act ReferencedV2Response.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -434,7 +432,7 @@ public async Task SerializeResponseWithSummaryAsV32Works() }"; // Act - var actual = await ResponseWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await ResponseWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -457,7 +455,7 @@ public async Task SerializeResponseWithSummaryAsV31Works() }"; // Act - var actual = await ResponseWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await ResponseWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -480,7 +478,7 @@ public async Task SerializeResponseWithSummaryAsV3Works() }"; // Act - var actual = await ResponseWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await ResponseWithSummary.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 40034eded..c1815a2d7 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -5,9 +5,12 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; using System.Threading.Tasks; -using FluentAssertions; +using Microsoft.OpenApi.Reader; using VerifyXunit; using Xunit; @@ -215,7 +218,7 @@ public async Task SerializeBasicSchemaAsJsonWorks(OpenApiSpecVersion version) var expected = @"{ }"; // Act - var actual = await BasicSchema.SerializeAsJsonAsync(version); + var actual = await BasicSchema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -245,7 +248,7 @@ public async Task SerializeAdvancedSchemaNumberAsV3JsonWorks() """; // Act - var actual = await AdvancedSchemaNumber.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedSchemaNumber.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -296,7 +299,7 @@ public async Task SerializeAdvancedSchemaObjectAsV3JsonWorks() """; // Act - var actual = await AdvancedSchemaObject.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedSchemaObject.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -351,7 +354,7 @@ public async Task SerializeAdvancedSchemaWithAllOfAsV3JsonWorks() """; // Act - var actual = await AdvancedSchemaWithAllOf.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedSchemaWithAllOf.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonObject.DeepEquals(JsonObject.Parse(expected), JsonObject.Parse(actual))); @@ -368,7 +371,7 @@ public async Task SerializeReferencedSchemaAsV3WithoutReferenceJsonWorksAsync(bo // Act ReferencedSchema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -385,7 +388,7 @@ public async Task SerializeReferencedSchemaAsV3JsonWorksAsync(bool produceTerseO // Act ReferencedSchema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -402,7 +405,7 @@ public async Task SerializeSchemaWRequiredPropertiesAsV2JsonWorksAsync(bool prod // Act AdvancedSchemaWithRequiredPropertiesObject.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -431,7 +434,7 @@ public async Task SerializeAsV2ShouldSetFormatPropertyInParentSchemaIfPresentInC // Act // Serialize as V2 schema.SerializeAsV2(openApiJsonWriter); - await openApiJsonWriter.FlushAsync(); + await openApiJsonWriter.FlushAsync(TestContext.Current.CancellationToken); var v2Schema = outputStringWriter.GetStringBuilder().ToString().MakeLineBreaksEnvironmentNeutral(); @@ -523,6 +526,83 @@ public void OpenApiSchemaCopyConstructorWithUnevaluatedPropertiesSchemaSucceeds( Assert.Equal(100, baseSchema.UnevaluatedPropertiesSchema.MaxLength); } + [Fact] + public void OpenApiSchemaCopyConstructorWithContainsSucceeds() + { + var baseSchema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String, + MaxLength = 100 + }, + MinContains = 1, + MaxContains = 5 + }; + + var actualSchema = Assert.IsType(baseSchema.CreateShallowCopy()); + + // Verify scalar properties are copied + Assert.Equal(baseSchema.MinContains, actualSchema.MinContains); + Assert.Equal(baseSchema.MaxContains, actualSchema.MaxContains); + + // Verify schema property is copied + Assert.NotNull(actualSchema.Contains); + Assert.Equal(JsonSchemaType.String, actualSchema.Contains.Type); + Assert.Equal(100, actualSchema.Contains.MaxLength); + + // Verify it's a shallow copy (different object reference) + Assert.NotSame(baseSchema.Contains, actualSchema.Contains); + + // Verify that changing the copy doesn't affect the original + var actualContainsTyped = Assert.IsType(actualSchema.Contains); + actualContainsTyped.MaxLength = 200; + Assert.Equal(100, baseSchema.Contains.MaxLength); + } + + [Fact] + public void OpenApiSchemaCopyConstructorWithMissingPropertiesSucceeds() + { + var baseSchema = new OpenApiSchema + { + Anchor = "root", + UnevaluatedProperties = false, + UnevaluatedPropertiesSchema = new OpenApiSchema { Type = JsonSchemaType.String }, + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actualSchema = Assert.IsType(baseSchema.CreateShallowCopy()); + var actualMissingProperties = Assert.IsAssignableFrom(actualSchema); + + Assert.Equal("root", actualMissingProperties.Anchor); + Assert.False(actualMissingProperties.UnevaluatedProperties); + Assert.NotNull(actualMissingProperties.UnevaluatedPropertiesSchema); + Assert.Equal("base64", actualMissingProperties.ContentEncoding); + Assert.Equal("application/jwt", actualMissingProperties.ContentMediaType); + Assert.NotNull(actualMissingProperties.ContentSchema); + Assert.NotNull(actualMissingProperties.PropertyNames); + Assert.NotNull(actualMissingProperties.DependentSchemas); + Assert.NotNull(actualMissingProperties.If); + Assert.NotNull(actualMissingProperties.Then); + Assert.NotNull(actualMissingProperties.Else); + Assert.NotSame(baseSchema.ContentSchema, actualMissingProperties.ContentSchema); + Assert.NotSame(baseSchema.PropertyNames, actualMissingProperties.PropertyNames); + Assert.NotSame(baseSchema.If, actualMissingProperties.If); + Assert.NotSame(baseSchema.Then, actualMissingProperties.Then); + Assert.NotSame(baseSchema.Else, actualMissingProperties.Else); + } + public static TheoryData SchemaExamples() { return new() @@ -543,6 +623,7 @@ public static TheoryData SchemaExamples() [Theory] [MemberData(nameof(SchemaExamples))] +#pragma warning disable CS0618 public void CloningSchemaExamplesWorks(JsonNode example) { // Arrange @@ -555,10 +636,9 @@ public void CloningSchemaExamplesWorks(JsonNode example) var schemaCopy = schema.CreateShallowCopy(); // Act && Assert - schema.Example.Should().BeEquivalentTo(schemaCopy.Example, options => options - .IgnoringCyclicReferences() - .Excluding(x => x.Options)); + OpenApiTestAssert.Equivalent(schemaCopy.Example, schema.Example, nameof(JsonNode.Options)); } +#pragma warning restore CS0618 [Fact] public void CloningSchemaExtensionsWorks() @@ -646,7 +726,7 @@ public async Task SerializeSchemaWithUnrecognizedPropertiesWorks() }"; // Act - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); @@ -668,7 +748,7 @@ public async Task WriteAsItemsPropertiesDoesNotWriteNull() // Act schema.WriteAsItemsProperties(writer); writer.WriteEndObject(); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert var actual = outputStringWriter.GetStringBuilder().ToString(); @@ -692,7 +772,7 @@ public async Task SerializeConstAsEnumV30() // Act - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert var v3Node = JsonNode.Parse(actual); @@ -712,7 +792,7 @@ public async Task SerializeConstAsEnumV20() }; // Act - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Assert var v2Node = JsonNode.Parse(actual); @@ -732,7 +812,7 @@ public async Task SerializeAdditionalPropertiesAsV2WithEmptySchemaEmits() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -750,7 +830,7 @@ public async Task SerializeAdditionalPropertiesAsV2WithRefSchemaEmits() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -767,7 +847,7 @@ public async Task SerializeAdditionalPropertiesAllowedAsV2DefaultDoesNotEmit() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -784,7 +864,7 @@ public async Task SerializeAdditionalPropertiesAllowedAsV2FalseEmits() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -804,7 +884,7 @@ public async Task SerializeAdditionalPropertiesAllowedAsV3PlusDefaultDoesNotEmit }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -824,7 +904,7 @@ public async Task SerializeAdditionalPropertiesAllowedAsV3PlusFalseEmits(OpenApi }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -844,7 +924,7 @@ public async Task SerializeAdditionalPropertiesAsV3PlusEmits(OpenApiSpecVersion }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -872,21 +952,25 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() // Act schema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v3Schema = outputStringWriter.GetStringBuilder().ToString(); var expectedV3Schema = """ { - "type": "string", "oneOf": [ + { + "enum": [ + null + ], + "nullable": true + }, { "maxLength": 10, "type": "string" } - ], - "nullable": true + ] } """; @@ -913,7 +997,7 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull // Act schema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v3Schema = outputStringWriter.GetStringBuilder().ToString(); @@ -921,14 +1005,19 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull """ { "oneOf": [ + { + "enum": [ + null + ], + "nullable": true + }, { "type": "string" }, { "type": "number" } - ], - "nullable": true + ] } """; @@ -961,15 +1050,20 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() // Act schema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v3Schema = outputStringWriter.GetStringBuilder().ToString(); var expectedV3Schema = """ { - "type": "object", "anyOf": [ + { + "enum": [ + null + ], + "nullable": true + }, { "type": "object", "properties": { @@ -978,8 +1072,7 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() } } } - ], - "nullable": true + ] } """; // Assert @@ -1005,7 +1098,7 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl // Act schema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v3Schema = outputStringWriter.GetStringBuilder().ToString(); @@ -1013,6 +1106,12 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl """ { "anyOf": [ + { + "enum": [ + null + ], + "nullable": true + }, { "minLength": 1, "type": "string" @@ -1020,8 +1119,7 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl { "type": "integer" } - ], - "nullable": true + ] } """; @@ -1046,14 +1144,21 @@ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() // Act schema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v3Schema = outputStringWriter.GetStringBuilder().ToString(); var expectedV3Schema = """ { - "nullable": true + "oneOf": [ + { + "enum": [ + null + ], + "nullable": true + } + ] } """; @@ -1079,7 +1184,7 @@ public async Task SerializeOneOfWithNullAsV31ShouldNotChangeAsync() // Act schema.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v31Schema = outputStringWriter.GetStringBuilder().ToString(); @@ -1143,20 +1248,97 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() // Act schema.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var v3Schema = outputStringWriter.GetStringBuilder().ToString(); var expectedV3Schema = """ { - "type": "object", "oneOf": [ + { + "enum": [ + null + ], + "nullable": true + }, { "$ref": "#/components/schemas/Pet" } - ], - "nullable": true + ] + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeContainsKeywordsAsV31Works() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 1, + MaxContains = 5 + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV31(writer); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + var v31Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV31Schema = + """ + { + "type": "array", + "contains": { + "type": "string" + }, + "maxContains": 5, + "minContains": 1 + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV31Schema), JsonNode.Parse(v31Schema))); + } + + [Fact] + public async Task SerializeContainsKeywordsAsV3EmitsCompatibilityExtensions() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 1, + MaxContains = 5 + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "type": "array", + "x-jsonschema-contains": { + "type": "string" + }, + "x-jsonschema-maxContains": 5, + "x-jsonschema-minContains": 1 } """; @@ -1173,7 +1355,7 @@ public async Task SerializeUnevaluatedPropertiesBooleanDefaultDoesNotEmit() var schema = new OpenApiSchema(); // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1190,7 +1372,7 @@ public async Task SerializeUnevaluatedPropertiesBooleanTrueDoesNotEmit() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1207,7 +1389,7 @@ public async Task SerializeUnevaluatedPropertiesBooleanFalseEmitsInV31() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1229,7 +1411,7 @@ public async Task SerializeUnevaluatedPropertiesSchemaEmits(OpenApiSpecVersion v }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1250,7 +1432,7 @@ public async Task SerializeUnevaluatedPropertiesSchemaTakesPrecedenceOverBoolean }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1262,16 +1444,13 @@ public async Task SerializeUnevaluatedPropertiesSchemaTakesPrecedenceOverBoolean public async Task SerializeUnevaluatedPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version) { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; - // Given - UnevaluatedProperties should be emitted as extension in versions < 3.1 var schema = new OpenApiSchema { UnevaluatedProperties = false }; - // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); - // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } @@ -1281,7 +1460,6 @@ public async Task SerializeUnevaluatedPropertiesAsExtensionInEarlierVersions(Ope public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersions(OpenApiSpecVersion version) { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; - // Given - UnevaluatedPropertiesSchema should be emitted as extension in versions < 3.1 var schema = new OpenApiSchema { UnevaluatedPropertiesSchema = new OpenApiSchema @@ -1290,10 +1468,8 @@ public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersio } }; - // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); - // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } @@ -1310,12 +1486,128 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions( }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + [Fact] + public async Task SerializeMissingPropertiesEmitsJsonSchemaKeywordsInV31() + { + var expected = JsonNode.Parse(""" + { + "$anchor": "root", + "contentEncoding": "base64", + "contentMediaType": "application/jwt", + "contentSchema": { + "type": "array" + }, + "propertyNames": { + "pattern": "^[a-z]+$" + }, + "dependentSchemas": { + "token": { + "type": "string" + } + }, + "if": { + "required": [ + "token" + ] + }, + "then": { + "minProperties": 1 + }, + "else": { + "maxProperties": 0 + } + } + """); + + var schema = new OpenApiSchema + { + Anchor = "root", + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actual = JsonNode.Parse(await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken)); + + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + + [Fact] + public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() + { + var expected = JsonNode.Parse(""" + { + "x-jsonschema-$anchor": "root", + "x-jsonschema-contentEncoding": "base64", + "x-jsonschema-contentMediaType": "application/jwt", + "x-jsonschema-contentSchema": { + "type": "array" + }, + "x-jsonschema-contains": { + "type": "string" + }, + "x-jsonschema-maxContains": 3, + "x-jsonschema-minContains": 1, + "x-jsonschema-propertyNames": { + "pattern": "^[a-z]+$" + }, + "x-jsonschema-dependentSchemas": { + "token": { + "type": "string" + } + }, + "x-jsonschema-if": { + "required": [ + "token" + ] + }, + "x-jsonschema-then": { + "minProperties": 1 + }, + "x-jsonschema-else": { + "maxProperties": 0 + } + } + """); + + var schema = new OpenApiSchema + { + Anchor = "root", + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MaxContains = 3, + MinContains = 1, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actual = JsonNode.Parse(await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken)); + + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + [Theory] [InlineData(JsonSchemaType.Array, "array")] [InlineData(JsonSchemaType.String, "string")] @@ -1334,7 +1626,7 @@ public async Task SerializeUnevaluatedPropertiesFalseNotEmittedForNonObjectType( }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1358,7 +1650,7 @@ public async Task SerializeUnevaluatedPropertiesSchemaNotEmittedForNonObjectType }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1379,7 +1671,7 @@ public async Task SerializeUnevaluatedPropertiesNotEmittedAsExtensionForNonObjec }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then - should not contain unevaluatedProperties extension var parsed = JsonNode.Parse(actual)!.AsObject(); @@ -1398,7 +1690,7 @@ public async Task SerializeUnevaluatedPropertiesFalseStillEmittedForObjectType() }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1415,7 +1707,7 @@ public async Task SerializeUnevaluatedPropertiesFalseStillEmittedWhenTypeNotSet( }; // When - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1438,7 +1730,7 @@ public async Task SerializePatternPropertiesAsKeywordInV31AndV32(OpenApiSpecVers }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1460,12 +1752,89 @@ public async Task SerializePatternPropertiesAsExtensionInEarlierVersions(OpenApi }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task SerializeSingleExampleAsExampleInV2V3WhenExampleUnset(OpenApiSpecVersion version) + { + var expected = """ + { + "example": "example value" + } + """; + var schema = new OpenApiSchema + { + Examples = + [ + JsonValue.Create("example value")! + ] + }; + + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task SerializeMultipleExamplesInV2V3WhenExampleUnset(OpenApiSpecVersion version) + { + var expected = """ + { + "example": "example value", + "x-jsonschema-examples": [ + 42 + ] + } + """; + var schema = new OpenApiSchema + { + Examples = + [ + JsonValue.Create("example value")!, + JsonValue.Create(42)! + ] + }; + + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeExamplesAsJsonSchemaKeywordInV31AndLater(OpenApiSpecVersion version) + { + var expected = """ + { + "examples": [ + "example value", + 42 + ] + } + """; + var schema = new OpenApiSchema + { + Examples = + [ + JsonValue.Create("example value")!, + JsonValue.Create(42)! + ] + }; + + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + [Theory] [InlineData(OpenApiSpecVersion.OpenApi2_0)] [InlineData(OpenApiSpecVersion.OpenApi3_0)] @@ -1479,7 +1848,7 @@ public async Task SerializeEmptyPatternPropertiesNotEmittedInEarlierVersions(Ope }; // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -1557,6 +1926,978 @@ public void DeserializePatternPropertiesExtensionInV3AssignsPatternPropertiesPro Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey("x-jsonschema-patternProperties")); } + [Fact] + public void DeserializeExamplesExtensionInV2AssignsExamplesProperty() + { + var jsonContent = """ + { + "swagger": "2.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "definitions": { + "TestSchema": { + "type": "string", + "example": "primary example", + "x-jsonschema-examples": [ + "secondary example", + 42 + ] + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + Assert.Empty(readResult.Diagnostic.Errors); + var schema = readResult.Document.Components.Schemas["TestSchema"]; +#pragma warning disable CS0618 // Type or member is obsolete + Assert.Equal("primary example", schema.Example?.GetValue()); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.NotNull(schema.Examples); + Assert.Collection( + schema.Examples, + example => Assert.Equal("secondary example", example.GetValue()), + example => Assert.Equal(42, example.GetValue())); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.JsonSchemaExamplesExtension)); + } + + [Fact] + public void DeserializeExamplesExtensionInV3AssignsExamplesProperty() + { + var jsonContent = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": { + "type": "string", + "example": "primary example", + "x-jsonschema-examples": [ + "secondary example", + 42 + ] + } + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + Assert.Empty(readResult.Diagnostic.Errors); + var schema = readResult.Document.Components.Schemas["TestSchema"]; +#pragma warning disable CS0618 // Type or member is obsolete + Assert.Equal("primary example", schema.Example?.GetValue()); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.NotNull(schema.Examples); + Assert.Collection( + schema.Examples, + example => Assert.Equal("secondary example", example.GetValue()), + example => Assert.Equal(42, example.GetValue())); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.JsonSchemaExamplesExtension)); + } + + [Fact] + public void DeserializeContainsExtensionsInV3AssignsContainsProperties() + { + var jsonContent = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": { + "type": "array", + "x-jsonschema-contains": { + "type": "string" + }, + "x-jsonschema-maxContains": 5, + "x-jsonschema-minContains": 1 + } + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + Assert.Empty(readResult.Diagnostic.Errors); + var schema = readResult.Document.Components.Schemas["TestSchema"]; + var missingProperties = Assert.IsAssignableFrom(schema); + Assert.Equal(JsonSchemaType.String, missingProperties.Contains?.Type); + Assert.Equal((uint?)5, missingProperties.MaxContains); + Assert.Equal((uint?)1, missingProperties.MinContains); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.ContainsExtension)); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MaxContainsExtension)); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MinContainsExtension)); + } + + [Fact] + public async Task SerializeNullableEnumWith3_0() + { + // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20 + // Documentation for nullable states: + // This keyword only takes effect if type is explicitly defined within the same Schema Object. + // So, we want to ensure that we emit the type property if we will be adding nullable property. + // In addition, we need to still keep 'null' in the enum array. + // Otherwise, validators will consider null as invalid even if nullable is set to true. + // It's unclear if it's an issue of the validators or not, but it's safer to do it that way. + var schema = CreateNullableEnumSchema(); + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "oneOf": [ + { + "enum": [ + null + ], + "nullable": true + }, + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion version) + { + var schema = CreateNullableEnumSchema(); + var result = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); + var expected = """ + { + "oneOf": [ + { + "type": "null" + }, + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Fact] + public async Task SerializeNullableTypeWith3_0() + { + var schema = CreateTypeNullSchema(); + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "enum": [ + null + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeNullableTypeWith3_1_And_Later(OpenApiSpecVersion version) + { + var schema = CreateTypeNullSchema(); + var result = await schema.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); + var expected = """ + { + "type": "null" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_NotIncludingOneOfOrAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "integer", "string" ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_IncludingOneOfButNotAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "integer", "string" ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_IncludingAnyOfButNotOneOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "integer", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_IncludingBothOneOfAndAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Null(deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_NotIncludingOneOfOrAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "type": "string", + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "string" ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_IncludingOneOfButNotAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "type": "string", + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "string" ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_IncludingAnyOfButNotOneOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_IncludingBothOneOfAndAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_NotIncludingOneOfOrAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "integer", "string" ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_IncludingOneOfButNotAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "integer", "string" ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_IncludingAnyOfButNotOneOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var expected = """ + { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); + var expected32 = """ + { + "type": [ "null", "integer", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_IncludingBothOneOfAndAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, cancellationToken: TestContext.Current.CancellationToken); + var expected = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Null(deserializedSchema.Type); + + var actual32 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, cancellationToken: TestContext.Current.CancellationToken); + var expected32 = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected32), JsonNode.Parse(actual32))); + } + + private OpenApiSchema CreateNullableEnumSchema() + { + var schema = new OpenApiSchema(); + schema.OneOf ??= []; + schema.OneOf.Add(new OpenApiSchema() { Type = JsonSchemaType.Null }); + schema.OneOf.Add(new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }); + return schema; + } + + private OpenApiSchema CreateTypeNullSchema() + { + var schema = new OpenApiSchema(); + schema.Type = JsonSchemaType.Null; + return schema; + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs new file mode 100644 index 000000000..ae03dcfb2 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models +{ + /// + /// Tests for the transformations applied when an that uses + /// JSON Schema DRAFT 2020-12 / OpenAPI 3.2 features is serialized down to OpenAPI 3.0 (which lacks those features), + /// and the corresponding behavior when such documents are deserialized back. + /// + [Collection("DefaultSettings")] + public class OpenApiSchemaV30CompatibilityTests + { + private static IOpenApiSchema ParseSchemaFromV30Document(string schemaJson) + { + var jsonContent = $$""" + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": {{schemaJson}} + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + Assert.Empty(readResult.Diagnostic.Errors); + return readResult.Document.Components.Schemas["TestSchema"]; + } + + [Fact] + public async Task NullableEnumShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema + { + Enum = + [ + JsonValue.Create(1), + JsonValue.Create(2), + JsonValue.Create(3), + JsonNullSentinel.JsonNull, + ] + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = + """ + { + "enum": [ + 1, + 2, + 3, + null + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(4, deserializedSchema.Enum.Count); + Assert.Equal(1, deserializedSchema.Enum[0].GetValue()); + Assert.Equal(2, deserializedSchema.Enum[1].GetValue()); + Assert.Equal(3, deserializedSchema.Enum[2].GetValue()); + Assert.True(deserializedSchema.Enum[3].IsJsonNullSentinel()); + } + + [Fact] + public async Task TypeNullAloneAsV3ShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Null }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = + """ + { + "enum": [ + null + ], + "nullable": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(JsonSchemaType.Null, deserializedSchema.Type); + Assert.Null(deserializedSchema.Enum); + } + + [Fact] + public async Task NullableTypeAsV3ShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = + """ + { + "type": "string", + "nullable": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, deserializedSchema.Type); + } + + [Fact] + public async Task SerializeMultipleNonNullTypesAsV3DoesNotOmitType() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Integer }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(schema.Type, deserializedSchema.Type); + } + + [Fact] + public async Task SerializeMultipleNonNullTypesWithNullAsV3DoesNotOmitType() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(schema.Type, deserializedSchema.Type); + } + + [Fact] + public void DeserializeAnyOfWithConstraintsAsV3PreservesConstraints() + { + var schemaJson = """ + { + "anyOf": [ + { + "type": "string", + "format": "email", + "maxLength": 10 + }, + { + "type": "integer", + "minimum": 0 + } + ] + } + """; + + var deserializedSchema = ParseSchemaFromV30Document(schemaJson); + + Assert.Null(deserializedSchema.Type); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Collection(deserializedSchema.AnyOf, + first => + { + Assert.Equal(JsonSchemaType.String, first.Type); + Assert.Equal("email", first.Format); + Assert.Equal(10, first.MaxLength); + }, + second => + { + Assert.Equal(JsonSchemaType.Integer, second.Type); + Assert.Equal("0", second.Minimum); + }); + } + + [Fact] + public async Task SerializeConstAsV3EmitsSingleValueEnum() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String, Const = "foo" }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var node = JsonNode.Parse(actual)!.AsObject(); + Assert.False(node.ContainsKey("const")); + Assert.True(node["enum"] is JsonArray enumArray + && enumArray.Count == 1 + && enumArray[0]!.ToString() == "foo"); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Null(deserializedSchema.Const); + Assert.NotNull(deserializedSchema.Enum); + Assert.Single(deserializedSchema.Enum); + Assert.Equal("foo", deserializedSchema.Enum[0]!.ToString()); + } + + [Fact] + public async Task SerializeExclusiveMaximumAsV3EmitsMaximumWithBooleanFlag() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Integer, + ExclusiveMaximum = "5" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = + """ + { + "type": "integer", + "maximum": 5, + "exclusiveMaximum": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = (OpenApiSchema)ParseSchemaFromV30Document(actual); + Assert.True(deserializedSchema.IsExclusiveMaximum); + Assert.Null(deserializedSchema.Maximum); + Assert.Equal("5", deserializedSchema.ExclusiveMaximum); + } + + [Fact] + public async Task SerializeExclusiveMinimumAsV3EmitsMinimumWithBooleanFlag() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Integer, + ExclusiveMinimum = "1" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + + var expected = + """ + { + "type": "integer", + "minimum": 1, + "exclusiveMinimum": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = (OpenApiSchema)ParseSchemaFromV30Document(actual); + Assert.True(deserializedSchema.IsExclusiveMinimum); + Assert.Null(deserializedSchema.Minimum); + Assert.Equal("1", deserializedSchema.ExclusiveMinimum); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecurityRequirementTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecurityRequirementTests.cs index 29f5af3c6..47edf9aa9 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecurityRequirementTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecurityRequirementTests.cs @@ -6,7 +6,6 @@ using System.Globalization; using System.IO; using System.Threading.Tasks; -using FluentAssertions; using VerifyXunit; using Xunit; using System.Text.Json.Nodes; @@ -105,7 +104,7 @@ public async Task SerializeBasicSecurityRequirementAsV3JsonWorks() var expected = @"{ }"; // Act - var actual = await BasicSecurityRequirement.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicSecurityRequirement.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -124,7 +123,7 @@ public async Task SerializeSecurityRequirementAsV3JsonWorksAsync(bool produceTer // Act SecurityRequirementWithReferencedSecurityScheme.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -153,7 +152,7 @@ public async Task SerializeSecurityRequirementWithReferencedSecuritySchemeAsJson """; // Act - var actual = await SecurityRequirementWithReferencedSecurityScheme.SerializeAsJsonAsync(openApiSpecVersion); + var actual = await SecurityRequirementWithReferencedSecurityScheme.SerializeAsJsonAsync(openApiSpecVersion, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -178,7 +177,7 @@ public async Task SerializeSecurityRequirementWithUnreferencedSecuritySchemeAsJs """; // Act - var actual = await SecurityRequirementWithUnreferencedSecurityScheme.SerializeAsJsonAsync(openApiSpecVersion); + var actual = await SecurityRequirementWithUnreferencedSecurityScheme.SerializeAsJsonAsync(openApiSpecVersion, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -234,14 +233,15 @@ public void SchemesShouldConsiderOnlyReferenceIdForEquality() Assert.Equal(2, securityRequirement.Count); - securityRequirement.Should().BeEquivalentTo( + OpenApiTestAssert.Equivalent( new OpenApiSecurityRequirement { // This should work with any security scheme object // as long as Reference.Id os securityScheme1 [new OpenApiSecuritySchemeReference("securityScheme1", null)] = new List(), [new OpenApiSecuritySchemeReference("securityScheme2", null)] = new List { "scope1", "scope2" }, - }); + }, + securityRequirement); } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs index b826c89b3..9089d580c 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSecuritySchemeTests.cs @@ -158,7 +158,7 @@ public async Task SerializeApiKeySecuritySchemeAsV3JsonWorks() """; // Act - var actual = await ApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await ApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -179,7 +179,7 @@ public async Task SerializeApiKeySecuritySchemeAsV3YamlWorks() """; // Act - var actual = await ApiKeySecurityScheme.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await ApiKeySecurityScheme.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -187,6 +187,155 @@ public async Task SerializeApiKeySecuritySchemeAsV3YamlWorks() Assert.Equal(expected, actual); } + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public Task SerializeSecuritySchemeWithoutTypeThrows(OpenApiSpecVersion version) + { + // Arrange + var securityScheme = new OpenApiSecurityScheme(); + + // Act & Assert + return AssertRequiredPropertyThrowsAsync( + securityScheme, + version, + "The 'type' property is required for security schemes."); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public Task SerializeApiKeySecuritySchemeWithoutNameThrows(OpenApiSpecVersion version) + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Query + }; + + // Act & Assert + return AssertRequiredPropertyThrowsAsync( + securityScheme, + version, + "The 'name' property is required for apiKey security schemes."); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public Task SerializeApiKeySecuritySchemeWithoutInThrows(OpenApiSpecVersion version) + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Name = "parameterName", + Type = SecuritySchemeType.ApiKey + }; + + // Act & Assert + return AssertRequiredPropertyThrowsAsync( + securityScheme, + version, + "The 'in' property is required for apiKey security schemes."); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public Task SerializeHttpSecuritySchemeWithoutSchemeThrows(OpenApiSpecVersion version) + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http + }; + + // Act & Assert + return AssertRequiredPropertyThrowsAsync( + securityScheme, + version, + "The 'scheme' property is required for http security schemes."); + } + + [Fact] + public async Task SerializeHttpSecuritySchemeWithoutSchemeAsV2DoesNotThrow() + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http + }; + + // Act + var actual = await securityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse("{}"), JsonNode.Parse(actual))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public Task SerializeOAuth2SecuritySchemeWithoutFlowsThrows(OpenApiSpecVersion version) + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2 + }; + + // Act & Assert + return AssertRequiredPropertyThrowsAsync( + securityScheme, + version, + "The 'flows' property is required for oauth2 security schemes."); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public Task SerializeOpenIdConnectSecuritySchemeWithoutOpenIdConnectUrlThrows(OpenApiSpecVersion version) + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OpenIdConnect + }; + + // Act & Assert + return AssertRequiredPropertyThrowsAsync( + securityScheme, + version, + "The 'openIdConnectUrl' property is required for openIdConnect security schemes."); + } + + [Fact] + public async Task SerializeOpenIdConnectSecuritySchemeWithoutOpenIdConnectUrlAsV2DoesNotThrow() + { + // Arrange + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OpenIdConnect + }; + + // Act + var actual = await securityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0, TestContext.Current.CancellationToken); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse("{}"), JsonNode.Parse(actual))); + } + [Fact] public async Task SerializeHttpBasicSecuritySchemeAsV3JsonWorks() { @@ -201,7 +350,7 @@ public async Task SerializeHttpBasicSecuritySchemeAsV3JsonWorks() """; // Act - var actual = await HttpBasicSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await HttpBasicSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -224,7 +373,7 @@ public async Task SerializeHttpBearerSecuritySchemeAsV3JsonWorks() """; // Act - var actual = await HttpBearerSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await HttpBearerSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -267,7 +416,7 @@ public async Task SerializeOAuthSingleFlowSecuritySchemeAsV3JsonWorks() """; // Act - var actual = await OAuth2SingleFlowSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OAuth2SingleFlowSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -297,7 +446,7 @@ public async Task SerializeOAuthSecuritySchemeWithMetadataUrlAsV32JsonWorks() """; // Act - var actual = await OAuth2MetadataSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await OAuth2MetadataSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -312,7 +461,7 @@ public async Task SerializeOAuthSecuritySchemeWithMetadataUrlAsV31JsonOmitsMetad { "type": "oauth2", "description": "description1", - "x-oauth2-metadata-url": "https://idp.example.com/.well-known/oauth-authorization-server", + "x-oai-oauth2-metadata-url": "https://idp.example.com/.well-known/oauth-authorization-server", "flows": { "clientCredentials": { "tokenUrl": "https://idp.example.com/oauth/token", @@ -325,7 +474,7 @@ public async Task SerializeOAuthSecuritySchemeWithMetadataUrlAsV31JsonOmitsMetad """; // Act - var actual = await OAuth2MetadataSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await OAuth2MetadataSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -369,7 +518,7 @@ public async Task SerializeOAuthMultipleFlowSecuritySchemeAsV3JsonWorks() """; // Act - var actual = await OAuth2MultipleFlowSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OAuth2MultipleFlowSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -391,7 +540,7 @@ public async Task SerializeOpenIdConnectSecuritySchemeAsV3JsonWorks() """; // Act - var actual = await OpenIdConnectSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await OpenIdConnectSecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -410,7 +559,7 @@ public async Task SerializeReferencedSecuritySchemeAsV3JsonWorksAsync(bool produ // Act OpenApiSecuritySchemeReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -427,7 +576,7 @@ public async Task SerializeReferencedSecuritySchemeAsV3JsonWithoutReferenceWorks // Act ReferencedSecurityScheme.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -449,7 +598,7 @@ public async Task SerializeDeprecatedSecuritySchemeAsV32JsonWorks() """; // Act - var actual = await DeprecatedApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await DeprecatedApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -471,7 +620,7 @@ public async Task SerializeDeprecatedSecuritySchemeAsV31JsonWorks() """; // Act - var actual = await DeprecatedApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await DeprecatedApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -493,10 +642,16 @@ public async Task SerializeDeprecatedSecuritySchemeAsV3JsonWorks() """; // Act - var actual = await DeprecatedApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await DeprecatedApiKeySecurityScheme.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + + private static async Task AssertRequiredPropertyThrowsAsync(OpenApiSecurityScheme securityScheme, OpenApiSpecVersion version, string message) + { + var exception = await Assert.ThrowsAsync(() => securityScheme.SerializeAsJsonAsync(version)); + Assert.Contains(message, exception.Message); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiServerTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiServerTests.cs index 7af4adbe4..1fa974263 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiServerTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiServerTests.cs @@ -65,7 +65,7 @@ public async Task SerializeBasicServerAsV3JsonWorks() """; // Act - var actual = await BasicServer.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await BasicServer.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -103,7 +103,7 @@ public async Task SerializeAdvancedServerAsV3JsonWorks() """; // Act - var actual = await AdvancedServer.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedServer.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -125,7 +125,7 @@ public async Task SerializeServerWithNameAsV32JsonWorks() """; // Act - var actual = await ServerWithName.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await ServerWithName.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -145,7 +145,7 @@ public async Task SerializeServerWithNameAsV31JsonWorks() """; // Act - var actual = await ServerWithName.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await ServerWithName.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); @@ -165,7 +165,7 @@ public async Task SerializeServerWithNameAsV3JsonWorks() """; // Act - var actual = await ServerWithName.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await ServerWithName.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiServerVariableTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiServerVariableTests.cs index ee9a81826..4770d34d9 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiServerVariableTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiServerVariableTests.cs @@ -28,7 +28,7 @@ public class OpenApiServerVariableTests public async Task SerializeBasicServerVariableAsV3Works(string format, string expected) { // Arrange & Act - var actual = await BasicServerVariable.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format); + var actual = await BasicServerVariable.SerializeAsync(OpenApiSpecVersion.OpenApi3_0, format, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -53,7 +53,7 @@ public async Task SerializeAdvancedServerVariableAsV3JsonWorks() """; // Act - var actual = await AdvancedServerVariable.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedServerVariable.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -75,7 +75,7 @@ public async Task SerializeAdvancedServerVariableAsV3YamlWorks() """; // Act - var actual = await AdvancedServerVariable.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await AdvancedServerVariable.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiTagTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiTagTests.cs index 0a7ea4a84..946bac75b 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiTagTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiTagTests.cs @@ -49,7 +49,7 @@ public async Task SerializeBasicTagAsV3JsonWithoutReferenceWorksAsync(bool produ // Act BasicTag.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -66,7 +66,7 @@ public async Task SerializeBasicTagAsV2JsonWithoutReferenceWorksAsync(bool produ // Act BasicTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -100,7 +100,7 @@ public async Task SerializeBasicTagAsV2YamlWithoutReferenceWorks() // Act BasicTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -127,7 +127,7 @@ public async Task SerializeAdvancedTagAsV3YamlWithoutReferenceWorks() // Act AdvancedTag.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -154,7 +154,7 @@ public async Task SerializeAdvancedTagAsV2YamlWithoutReferenceWorks() // Act AdvancedTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -174,7 +174,7 @@ public async Task SerializeAdvancedTagAsV3JsonWorksAsync(bool produceTerseOutput // Act AdvancedTag.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -191,7 +191,7 @@ public async Task SerializeAdvancedTagAsV2JsonWorksAsync(bool produceTerseOutput // Act AdvancedTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -213,7 +213,7 @@ public async Task SerializeAdvancedTagAsV3YamlWorks() // Act AdvancedTag.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -238,7 +238,7 @@ public async Task SerializeAdvancedTagAsV2YamlWorks() // Act AdvancedTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -258,7 +258,7 @@ public async Task SerializeReferencedTagAsV3JsonWorksAsync(bool produceTerseOutp // Act ReferencedTag.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -275,7 +275,7 @@ public async Task SerializeReferencedTagAsV2JsonWorksAsync(bool produceTerseOutp // Act ReferencedTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -292,7 +292,7 @@ public async Task SerializeReferencedTagAsV3YamlWorks() // Act ReferencedTag.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -312,7 +312,7 @@ public async Task SerializeReferencedTagAsV2YamlWorks() // Act ReferencedTag.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -333,7 +333,7 @@ public async Task SerializeTagWithV32PropertiesAsV31JsonWorksAsync(bool produceT // Act TagWithV32Properties.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -356,7 +356,7 @@ public async Task SerializeTagWithV32PropertiesAsV31YamlWorks() // Act TagWithV32Properties.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -377,7 +377,7 @@ public async Task SerializeTagWithV32PropertiesAsV32JsonWorksAsync(bool produceT // Act TagWithV32Properties.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -400,7 +400,7 @@ public async Task SerializeTagWithV32PropertiesAsV32YamlWorks() // Act TagWithV32Properties.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert @@ -427,7 +427,7 @@ public async Task SerializeTagWithV32PropertiesAsV3YamlWorks() // Act TagWithV32Properties.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actual = outputStringWriter.GetStringBuilder().ToString(); // Assert diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiXmlTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiXmlTests.cs index 6426c0237..758615cc2 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiXmlTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiXmlTests.cs @@ -56,7 +56,7 @@ public async Task SerializeBasicXmlWorks( string format) { // Act - var actual = await BasicXml.SerializeAsync(version, format); + var actual = await BasicXml.SerializeAsync(version, format, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -81,7 +81,7 @@ public async Task SerializeAdvancedXmlAsJsonWorks(OpenApiSpecVersion version) """; // Act - var actual = await AdvancedXml.SerializeAsJsonAsync(version); + var actual = await AdvancedXml.SerializeAsJsonAsync(version, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -103,7 +103,7 @@ public async Task SerializeAdvancedXmlAsYamlWorks(OpenApiSpecVersion version) """; // Act - var actual = await AdvancedXml.SerializeAsYamlAsync(version); + var actual = await AdvancedXml.SerializeAsYamlAsync(version, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -127,7 +127,7 @@ public async Task SerializeXmlWithNodeTypeAsJsonV32Works() """; // Act - var actual = await XmlWithNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await XmlWithNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -147,7 +147,7 @@ public async Task SerializeXmlWithNodeTypeAsYamlV32Works() """; // Act - var actual = await XmlWithNodeType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await XmlWithNodeType.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert actual = actual.MakeLineBreaksEnvironmentNeutral(); @@ -168,7 +168,7 @@ public async Task SerializeXmlWithAttributeNodeTypeAsJsonV32Works() """; // Act - var actual = await XmlWithAttributeNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var actual = await XmlWithAttributeNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -190,7 +190,7 @@ public async Task SerializeXmlWithNodeTypeAsJsonV31DoesNotSerializeNodeType() """; // Act - var actual = await XmlWithNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await XmlWithNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); @@ -209,7 +209,7 @@ public async Task SerializeXmlWithAttributeNodeTypeAsJsonV31DoesNotSerializeNode """; // Act - var actual = await XmlWithAttributeNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var actual = await XmlWithAttributeNodeType.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/BaseOpenApiReferenceHolderTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/BaseOpenApiReferenceHolderTests.cs new file mode 100644 index 000000000..24d2b038e --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/References/BaseOpenApiReferenceHolderTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models.References +{ + public class BaseOpenApiReferenceHolderTests + { + [Fact] + public void DelegatedAccessDetectsCyclesForEveryComponentReferenceType() + { + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiCallbackReference("B", document)); + document.AddComponent("B", new OpenApiCallbackReference("A", document)); + return document.Components!.Callbacks!["A"].PathItems; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiExampleReference("B", document)); + document.AddComponent("B", new OpenApiExampleReference("A", document)); + return document.Components!.Examples!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiHeaderReference("B", document)); + document.AddComponent("B", new OpenApiHeaderReference("A", document)); + return document.Components!.Headers!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiLinkReference("B", document)); + document.AddComponent("B", new OpenApiLinkReference("A", document)); + return document.Components!.Links!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.Components = new OpenApiComponents + { + MediaTypes = new Dictionary + { + ["A"] = new OpenApiMediaTypeReference("B", document), + ["B"] = new OpenApiMediaTypeReference("A", document) + } + }; + document.RegisterComponents(); + return document.Components.MediaTypes["A"].Schema; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiParameterReference("B", document)); + document.AddComponent("B", new OpenApiParameterReference("A", document)); + return document.Components!.Parameters!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiPathItemReference("B", document)); + document.AddComponent("B", new OpenApiPathItemReference("A", document)); + return document.Components!.PathItems!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiRequestBodyReference("B", document)); + document.AddComponent("B", new OpenApiRequestBodyReference("A", document)); + return document.Components!.RequestBodies!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiResponseReference("B", document)); + document.AddComponent("B", new OpenApiResponseReference("A", document)); + return document.Components!.Responses!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiSchemaReference("B", document)); + document.AddComponent("B", new OpenApiSchemaReference("A", document)); + return document.Components!.Schemas!["A"].Description; + }); + AssertCircularAccessThrows(document => + { + document.AddComponent("A", new OpenApiSecuritySchemeReference("B", document)); + document.AddComponent("B", new OpenApiSecuritySchemeReference("A", document)); + return document.Components!.SecuritySchemes!["A"].Description; + }); + } + + [Fact] + public void InliningACircularNonSchemaReferenceThrows() + { + // Arrange + var document = new OpenApiDocument(); + document.AddComponent("A", new OpenApiResponseReference("B", document)); + document.AddComponent("B", new OpenApiResponseReference("A", document)); + var response = Assert.IsType(document.Components!.Responses!["A"]); + var writer = new OpenApiJsonWriter( + new StringWriter(), + new OpenApiWriterSettings { InlineLocalReferences = true }); + + // Act + var exception = Assert.Throws(() => response.SerializeAsV31(writer)); + + // Assert + Assert.Contains("Circular reference detected while resolving reference:", exception.Message, StringComparison.Ordinal); + } + + private static void AssertCircularAccessThrows(Func access) + { + var exception = Assert.Throws(() => access(new OpenApiDocument())); + Assert.Contains("Circular reference detected while resolving reference:", exception.Message, StringComparison.Ordinal); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiCallbackReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiCallbackReferenceTests.cs index c39db4c13..6c6f42665 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiCallbackReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiCallbackReferenceTests.cs @@ -166,7 +166,7 @@ public async Task SerializeCallbackReferenceAsV3JsonWorks(bool produceTerseOutpu // Act _externalCallbackReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -185,7 +185,7 @@ public async Task SerializeCallbackReferenceAsV31JsonWorks(bool produceTerseOutp // Act _externalCallbackReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiExampleReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiExampleReferenceTests.cs index bd40056a4..98f352da7 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiExampleReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiExampleReferenceTests.cs @@ -156,7 +156,7 @@ public async Task SerializeExampleReferenceAsV3JsonWorks(bool produceTerseOutput // Act _localExampleReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -175,7 +175,7 @@ public async Task SerializeExampleReferenceAsV31JsonWorks(bool produceTerseOutpu // Act _localExampleReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiHeaderReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiHeaderReferenceTests.cs index 7a5e7fc9c..4556c1bff 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiHeaderReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiHeaderReferenceTests.cs @@ -116,7 +116,7 @@ public async Task SerializeHeaderReferenceAsV3JsonWorks(bool produceTerseOutput, // Act _localHeaderReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -135,7 +135,7 @@ public async Task SerializeHeaderReferenceAsV31JsonWorks(bool produceTerseOutput // Act _localHeaderReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -154,7 +154,7 @@ public async Task SerializeHeaderReferenceAsV2JsonWorksAsync(bool produceTerseOu // Act _localHeaderReference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiLinkReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiLinkReferenceTests.cs index 33354a67c..f75dbb11b 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiLinkReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiLinkReferenceTests.cs @@ -164,7 +164,7 @@ public async Task SerializeLinkReferenceAsV3JsonWorks(bool produceTerseOutput, b // Act _localLinkReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -183,7 +183,7 @@ public async Task SerializeLinkReferenceAsV31JsonWorks(bool produceTerseOutput, // Act _localLinkReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiMediaTypeReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiMediaTypeReferenceTests.cs index 7292eacdc..d75f97a38 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiMediaTypeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiMediaTypeReferenceTests.cs @@ -253,7 +253,7 @@ public async Task SerializeMediaTypeReferenceAsV32JsonWorks(bool produceTerseOut // Act document.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -318,7 +318,7 @@ public async Task SerializeMediaTypeReferenceAsV31JsonWorks(bool produceTerseOut // Act document.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -383,7 +383,7 @@ public async Task SerializeMediaTypeReferenceAsV3JsonWorks(bool produceTerseOutp // Act document.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiParameterReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiParameterReferenceTests.cs index 89f96c990..f682b012f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiParameterReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiParameterReferenceTests.cs @@ -117,7 +117,7 @@ public async Task SerializeParameterReferenceAsV3JsonWorks(bool produceTerseOutp // Act _localParameterReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -136,7 +136,7 @@ public async Task SerializeParameterReferenceAsV31JsonWorks(bool produceTerseOut // Act _localParameterReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -155,7 +155,7 @@ public async Task SerializeParameterReferenceAsV2JsonWorksAsync(bool produceTers // Act _localParameterReference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiPathItemReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiPathItemReferenceTests.cs index 5c70a1ffa..e06ebc683 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiPathItemReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiPathItemReferenceTests.cs @@ -126,7 +126,7 @@ public async Task SerializePathItemReferenceAsV31JsonWorks(bool produceTerseOutp // Act _localPathItemReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiRequestBodyReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiRequestBodyReferenceTests.cs index e3219e021..5991237ba 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiRequestBodyReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiRequestBodyReferenceTests.cs @@ -130,7 +130,7 @@ public async Task SerializeRequestBodyReferenceAsV3JsonWorks(bool produceTerseOu // Act _localRequestBodyReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -149,7 +149,7 @@ public async Task SerializeRequestBodyReferenceAsV31JsonWorks(bool produceTerseO // Act _localRequestBodyReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiResponseReferenceTest.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiResponseReferenceTest.cs index b1e44a90d..63f272acc 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiResponseReferenceTest.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiResponseReferenceTest.cs @@ -112,7 +112,7 @@ public async Task SerializeResponseReferenceAsV3JsonWorks(bool produceTerseOutpu // Act _localResponseReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -131,7 +131,7 @@ public async Task SerializeResponseReferenceAsV31JsonWorks(bool produceTerseOutp // Act _localResponseReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt index ddb324ad6..0af262f8e 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt @@ -1,3 +1,3 @@ { "$ref": "#/definitions/Pet" -} +} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt index b36112c01..d4b02da13 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt @@ -1 +1 @@ -{"$ref":"#/definitions/Pet"} +{"$ref":"#/definitions/Pet"} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=False.verified.txt index be99f20ed..0bb71a8f1 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=False.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=False.verified.txt @@ -1,3 +1,3 @@ -{ +{ "$ref": "#/components/schemas/Pet" } \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=True.verified.txt index 5838142cd..f6b40c73f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=True.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV3JsonWorks_produceTerseOutput=True.verified.txt @@ -1 +1 @@ -{"$ref":"#/components/schemas/Pet"} \ No newline at end of file +{"$ref":"#/components/schemas/Pet"} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs index 488488518..1041a651f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs @@ -1,6 +1,7 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -53,7 +54,7 @@ public void SchemaReferenceWithAnnotationsShouldWork() var schemaReference = new OpenApiSchemaReference(referenceId, workingDocument) { Title = "Override Title", - Description = "Override Description", + Description = "Override Description", ReadOnly = true, WriteOnly = true, Deprecated = true, @@ -119,6 +120,175 @@ public void SchemaReferenceWithoutAnnotationsShouldFallbackToTarget() Assert.Equal("target example", schemaReference.Examples.First()?.GetValue()); } + [Fact] + public void SchemaReferenceExposesMissingPropertiesFromTarget() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + const string referenceId = "targetSchema"; + workingDocument.Components.Schemas = new Dictionary + { + [referenceId] = new OpenApiSchema + { + Anchor = "root", + UnevaluatedProperties = false, + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaReference = new OpenApiSchemaReference(referenceId, workingDocument); + var missingProperties = Assert.IsAssignableFrom(schemaReference); + + Assert.Equal("root", missingProperties.Anchor); + Assert.False(missingProperties.UnevaluatedProperties); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + + [Fact] + public void SchemaReferenceWithKeywordSiblingsShouldOverrideTargetValues() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + const string referenceId = "targetSchema"; + workingDocument.Components.Schemas = new Dictionary + { + [referenceId] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Format = "target-format", + MaxLength = 20, + Required = new HashSet { "target" }, + Properties = new Dictionary + { + ["target"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + AdditionalPropertiesAllowed = true, + ContentEncoding = "gzip", + If = new OpenApiSchema { Required = new HashSet { "target" } } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaReference = new OpenApiSchemaReference(referenceId, workingDocument) + { + Type = JsonSchemaType.String, + Format = "reference-format", + MaxLength = 10, + Required = new HashSet { "reference" }, + Properties = new Dictionary + { + ["reference"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + }, + AdditionalPropertiesAllowed = false, + ContentEncoding = "base64", + If = new OpenApiSchema { Required = new HashSet { "reference" } } + }; + + Assert.Equal(JsonSchemaType.String, schemaReference.Type); + Assert.Equal("reference-format", schemaReference.Format); + Assert.Equal(10, schemaReference.MaxLength); + Assert.NotNull(schemaReference.Required); + Assert.Contains("reference", schemaReference.Required); + Assert.Equal(JsonSchemaType.Integer, schemaReference.Properties?["reference"].Type); + Assert.False(schemaReference.AdditionalPropertiesAllowed); + Assert.Equal("base64", schemaReference.ContentEncoding); + Assert.NotNull(schemaReference.If?.Required); + Assert.Contains("reference", schemaReference.If.Required); + } + + [Fact] + public void ParseSchemaReferenceWithKeywordSiblingsWorks() + { + var jsonContent = @"{ + ""openapi"": ""3.1.0"", + ""info"": { + ""title"": ""Test API"", + ""version"": ""1.0.0"" + }, + ""paths"": { + ""/test"": { + ""get"": { + ""responses"": { + ""200"": { + ""description"": ""OK"", + ""content"": { + ""application/json"": { + ""schema"": { + ""$ref"": ""#/components/schemas/Pet"", + ""type"": ""string"", + ""format"": ""uuid"", + ""maxLength"": 36, + ""required"": [""id""], + ""properties"": { + ""id"": { + ""type"": ""string"" + } + }, + ""additionalProperties"": false, + ""contentEncoding"": ""base64"", + ""if"": { + ""required"": [""id""] + } + } + } + } + } + } + } + } + }, + ""components"": { + ""schemas"": { + ""Pet"": { + ""type"": ""object"", + ""format"": ""target-format"", + ""maxLength"": 10, + ""additionalProperties"": true + } + } + } +}"; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + Assert.Empty(readResult.Diagnostic.Errors); + var schemaReference = Assert.IsType(readResult.Document?.Paths["/test"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema); + Assert.Equal(JsonSchemaType.String, schemaReference.Type); + Assert.Equal("uuid", schemaReference.Format); + Assert.Equal(36, schemaReference.MaxLength); + Assert.NotNull(schemaReference.Required); + Assert.Contains("id", schemaReference.Required); + Assert.Equal(JsonSchemaType.String, schemaReference.Properties?["id"].Type); + Assert.False(schemaReference.AdditionalPropertiesAllowed); + Assert.Equal("base64", schemaReference.ContentEncoding); + Assert.NotNull(schemaReference.If?.Required); + Assert.Contains("id", schemaReference.If.Required); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -145,7 +315,7 @@ public async Task SerializeSchemaReferenceAsV31JsonWorks(bool produceTerseOutput // Act reference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -177,7 +347,7 @@ public async Task SerializeSchemaReferenceAsV32JsonWorks(bool produceTerseOutput // Act reference.SerializeAsV32(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -209,7 +379,7 @@ public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput) // Act reference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -241,7 +411,7 @@ public async Task SerializeSchemaReferenceAsV2JsonWorks(bool produceTerseOutput) // Act reference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -306,13 +476,13 @@ public void ParseSchemaReferenceWithAnnotationsWorks() // Assert Assert.NotNull(document); Assert.Empty(readResult.Diagnostic.Errors); - + var schema = document.Paths["/test"].Operations[HttpMethod.Get] .Responses["200"].Content["application/json"].Schema; - + Assert.IsType(schema); var schemaRef = (OpenApiSchemaReference)schema; - + // Test that reference annotations override target values Assert.Equal("Pet Response Schema", schemaRef.Title); Assert.Equal("A pet object returned from the API", schemaRef.Description); @@ -321,7 +491,7 @@ public void ParseSchemaReferenceWithAnnotationsWorks() Assert.False(schemaRef.WriteOnly); Assert.NotNull(schemaRef.Default); Assert.Single(schemaRef.Examples); - + // Test that target schema still has original values var targetSchema = schemaRef.Target; Assert.NotNull(targetSchema); @@ -412,7 +582,7 @@ public async Task SchemaReferenceExtensionsNotWrittenInV30() // Act reference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var output = outputStringWriter.ToString(); // Assert: In v3.0, ONLY $ref should appear - no description, no extensions @@ -437,11 +607,309 @@ public async Task SchemaReferenceExtensionsNotWrittenInV2() // Act reference.SerializeAsV2(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var output = outputStringWriter.ToString(); // Assert: In v2, ONLY $ref should appear - no description, no extensions Assert.Equal(@"{""$ref"":""#/definitions/Pet""}", output); } + + private const string CircularSchemaReferencePairJson = + """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "0.0.1" }, + "paths": {}, + "components": { + "schemas": { + "A": { "$ref": "#/components/schemas/B" }, + "B": { "$ref": "#/components/schemas/A" } + } + } + } + """; + + [Fact] + public void CircularSchemaReferencePairThrowsWhenReadingAllOf() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + var schemaA = readResult.Document.Components!.Schemas!["A"]; + + // Act + var exception = Assert.Throws(() => _ = schemaA.AllOf); + + // Assert + Assert.Contains("Circular reference detected while resolving reference:", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void CircularSchemaReferencePairTargetReturnsTheImmediateReference() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + var schemaA = Assert.IsType(readResult.Document.Components!.Schemas!["A"]); + var schemaB = readResult.Document.Components.Schemas["B"]; + + // Act + var target = schemaA.Target; + + // Assert + Assert.Same(schemaB, target); + } + + [Fact] + public void CircularSchemaReferencePairRecursiveTargetThrows() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + var schemaA = Assert.IsType(readResult.Document.Components!.Schemas!["A"]); + + // Act + var exception = Assert.Throws(() => _ = schemaA.RecursiveTarget); + + // Assert + Assert.Contains("Circular reference detected while resolving reference:", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void CircularSchemaReferencePairThrowsWhenReadingDescription() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + var schemaA = readResult.Document.Components!.Schemas!["A"]; + + // Act + var exception = Assert.Throws(() => _ = schemaA.Description); + + // Assert + Assert.Contains("Circular reference detected while resolving reference:", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void CircularSchemaReferenceChainOfThreeThrowsWhenReadingType() + { + // Arrange + var readResult = OpenApiDocument.Parse( + """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "0.0.1" }, + "paths": {}, + "components": { + "schemas": { + "A": { "$ref": "#/components/schemas/B" }, + "B": { "$ref": "#/components/schemas/C" }, + "C": { "$ref": "#/components/schemas/A" } + } + } + } + """, "json"); + Assert.NotNull(readResult.Document); + var schemaA = readResult.Document.Components!.Schemas!["A"]; + + // Act + var exception = Assert.Throws(() => _ = schemaA.Type); + + // Assert + Assert.Contains("Circular reference detected while resolving reference:", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CircularSchemaReferencePairStillSerializesAsAReference() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true }); + + // Act + readResult.Document.SerializeAsV3(writer); + await writer.FlushAsync(TestContext.Current.CancellationToken); + var output = outputStringWriter.ToString(); + + // Assert + Assert.Contains(@"""A"":{""$ref"":""#/components/schemas/B""}", output, StringComparison.Ordinal); + Assert.Contains(@"""B"":{""$ref"":""#/components/schemas/A""}", output, StringComparison.Ordinal); + } + + [Fact] + public void SchemaReferenceChainThatTerminatesStillResolvesTheTarget() + { + // Arrange + var readResult = OpenApiDocument.Parse( + """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "0.0.1" }, + "paths": {}, + "components": { + "schemas": { + "A": { "$ref": "#/components/schemas/B" }, + "B": { "$ref": "#/components/schemas/C" }, + "C": { "type": "string", "description": "The concrete schema" } + } + } + } + """, "json"); + Assert.NotNull(readResult.Document); + var schemaA = readResult.Document.Components!.Schemas!["A"]; + + // Act + var description = schemaA.Description; + var repeatedDescription = schemaA.Description; + + // Assert + Assert.Equal("The concrete schema", description); + Assert.Equal("The concrete schema", repeatedDescription); + Assert.Equal(JsonSchemaType.String, schemaA.Type); + } + + [Fact] + public void DelegatedAccessResolvesEachReferenceOnce() + { + // Arrange + const int referenceCount = 100; + var references = Enumerable.Range(0, referenceCount) + .Select(index => new CountingSchemaReference($"S{index}")) + .ToArray(); + for (var index = 0; index < references.Length - 1; index++) + { + references[index].ImmediateTarget = references[index + 1]; + } + references[^1].ImmediateTarget = new OpenApiSchema { Description = "end" }; + + // Act + var description = references[0].Description; + + // Assert + Assert.Equal("end", description); + Assert.Equal(referenceCount, references.Sum(reference => reference.TargetAccessCount)); + } + + [Fact] + public void RecursiveTargetResolvesADeepChainIteratively() + { + // Arrange + const int referenceCount = 10_000; + var references = Enumerable.Range(0, referenceCount) + .Select(index => new CountingSchemaReference($"S{index}")) + .ToArray(); + var concreteTarget = new OpenApiSchema { Description = "end" }; + for (var index = 0; index < references.Length - 1; index++) + { + references[index].ImmediateTarget = references[index + 1]; + } + references[^1].ImmediateTarget = concreteTarget; + + // Act + var target = references[0].RecursiveTarget; + + // Assert + Assert.Same(concreteTarget, target); + Assert.Equal(referenceCount, references.Sum(reference => reference.TargetAccessCount)); + } + + [Fact] + public void FilteringADocumentWithCircularSchemaReferencesPreservesTheReferences() + { + // Arrange + var readResult = OpenApiDocument.Parse( + """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "0.0.1" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/A" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "A": { "$ref": "#/components/schemas/B" }, + "B": { "$ref": "#/components/schemas/A" } + } + } + } + """, "json"); + Assert.NotNull(readResult.Document); + var predicate = OpenApiFilterService.CreatePredicate(operationIds: "getTest"); + + // Act + var filtered = OpenApiFilterService.CreateFilteredDocument(readResult.Document, predicate); + + // Assert + Assert.NotNull(filtered.Components); + Assert.NotNull(filtered.Components.Schemas); + Assert.Contains("A", filtered.Components.Schemas.Keys); + Assert.Contains("B", filtered.Components.Schemas.Keys); + } + + [Fact] + public void CircularSchemaReferencePairIsStillReportedAsResolved() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + var schemaA = Assert.IsType(readResult.Document.Components!.Schemas!["A"]); + + // Act + var unresolved = schemaA.UnresolvedReference; + + // Assert: the reference does resolve to an element, only the chain never terminates + Assert.False(unresolved); + } + + [Fact] + public void ValidatingADocumentWithACircularSchemaReferencePairDoesNotThrow() + { + // Arrange + var readResult = OpenApiDocument.Parse(CircularSchemaReferencePairJson, "json"); + Assert.NotNull(readResult.Document); + + // Act + var errors = readResult.Document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + + // Assert + Assert.NotNull(errors); + } + + private sealed class CountingSchemaReference : OpenApiSchemaReference + { + public CountingSchemaReference(string referenceId) + : base(referenceId) + { + } + + public IOpenApiSchema ImmediateTarget { get; set; } + + public int TargetAccessCount { get; private set; } + + public override IOpenApiSchema Target + { + get + { + TargetAccessCount++; + return ImmediateTarget; + } + } + } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSecuritySchemeReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSecuritySchemeReferenceTests.cs index 767ea126f..1fb49794a 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSecuritySchemeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSecuritySchemeReferenceTests.cs @@ -63,7 +63,7 @@ public async Task SerializeSecuritySchemeReferenceAsV3JsonWorks(bool produceTers // Act _openApiSecuritySchemeReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); @@ -82,7 +82,7 @@ public async Task SerializeSecuritySchemeReferenceAsV31JsonWorks(bool produceTer // Act _openApiSecuritySchemeReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput, inlineLocalReferences); diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiTagReferenceTest.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiTagReferenceTest.cs index e760982d2..f7e1fb62e 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiTagReferenceTest.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiTagReferenceTest.cs @@ -95,7 +95,7 @@ public async Task SerializeTagReferenceAsV3JsonWorks(bool produceTerseOutput) // Act _openApiTagReference.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -112,7 +112,7 @@ public async Task SerializeTagReferenceAsV31JsonWorks(bool produceTerseOutput) // Act _openApiTagReference.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); @@ -129,7 +129,7 @@ public async Task SerializeTagAsV3JsonWorks(bool produceTerseOutput) // Act _openApiTagReference2.SerializeAsV3(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal("\"users.user\"", outputStringWriter.ToString()); @@ -146,7 +146,7 @@ public async Task SerializeTagAsV31JsonWorks(bool produceTerseOutput) // Act _openApiTagReference2.SerializeAsV31(writer); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal("\"users.user\"", outputStringWriter.ToString()); @@ -193,7 +193,7 @@ public async Task SerializesADescriptionWithNoMatchingTagDefinition(OpenApiSpecV document.SerializeAsV31(writer); break; } - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var expected = version switch { diff --git a/test/Microsoft.OpenApi.Tests/OpenApiTestAssert.cs b/test/Microsoft.OpenApi.Tests/OpenApiTestAssert.cs new file mode 100644 index 000000000..e9bd68048 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/OpenApiTestAssert.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using Xunit; + +namespace Microsoft.OpenApi.Tests; + +public static class OpenApiTestAssert +{ + public static void Equivalent(T expected, T actual, params string[] excludedMemberNames) + { + Equivalent(expected, actual, path => excludedMemberNames.Contains(path.MemberName)); + } + + public static void Equivalent(T expected, T actual, Func exclude) + { + Compare(expected, actual, EquivalencyPath.Root, exclude, new HashSet()); + } + + public readonly record struct EquivalencyPath(string Value, string MemberName) + { + public static EquivalencyPath Root { get; } = new(string.Empty, string.Empty); + + public EquivalencyPath Member(string memberName) + { + var value = string.IsNullOrEmpty(Value) ? memberName : $"{Value}.{memberName}"; + return new(value, memberName); + } + + public EquivalencyPath Index(object index) + { + return new($"{Value}[{index}]", MemberName); + } + } + + private static void Compare(object expected, object actual, EquivalencyPath path, Func exclude, HashSet visited) + { + if (exclude(path)) + { + return; + } + + if (expected is null || actual is null) + { + Assert.Equal(expected, actual); + return; + } + + if (expected is JsonNode || actual is JsonNode) + { + var expectedNode = Assert.IsAssignableFrom(expected); + var actualNode = Assert.IsAssignableFrom(actual); + Assert.True(JsonNode.DeepEquals(expectedNode, actualNode), $"Expected JSON node at '{path.Value}' to be equivalent."); + return; + } + + var expectedType = expected.GetType(); + if (IsSimpleType(expectedType)) + { + Assert.Equal(expected, actual); + return; + } + + if (!expectedType.IsValueType) + { + var pair = new ReferencePair(expected, actual); + if (!visited.Add(pair)) + { + return; + } + } + + if (expected is IDictionary expectedDictionary && actual is IDictionary actualDictionary) + { + Assert.Equal(expectedDictionary.Count, actualDictionary.Count); + foreach (DictionaryEntry expectedEntry in expectedDictionary) + { + Assert.True(actualDictionary.Contains(expectedEntry.Key), $"Expected dictionary at '{path.Value}' to contain key '{expectedEntry.Key}'."); + Compare(expectedEntry.Value, actualDictionary[expectedEntry.Key], path.Index(expectedEntry.Key), exclude, visited); + } + + return; + } + + if (expected is IEnumerable expectedEnumerable && actual is IEnumerable actualEnumerable && expected is not string) + { + var expectedItems = expectedEnumerable.Cast().ToList(); + var actualItems = actualEnumerable.Cast().ToList(); + + Assert.Equal(expectedItems.Count, actualItems.Count); + for (var index = 0; index < expectedItems.Count; index++) + { + Compare(expectedItems[index], actualItems[index], path.Index(index), exclude, visited); + } + + return; + } + + foreach (var property in expectedType.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(static p => p.GetIndexParameters().Length == 0)) + { + var propertyPath = path.Member(property.Name); + if (exclude(propertyPath)) + { + continue; + } + + var actualProperty = actual.GetType().GetProperty(property.Name, BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(actualProperty); + Compare(property.GetValue(expected), actualProperty.GetValue(actual), propertyPath, exclude, visited); + } + + foreach (var field in expectedType.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + var fieldPath = path.Member(field.Name); + if (exclude(fieldPath)) + { + continue; + } + + var actualField = actual.GetType().GetField(field.Name, BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(actualField); + Compare(field.GetValue(expected), actualField.GetValue(actual), fieldPath, exclude, visited); + } + } + + private static bool IsSimpleType(Type type) + { + return type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) + || type == typeof(Uri) + || type == typeof(Guid); + } + + private readonly record struct ReferencePair(object Expected, object Actual) + { + public bool Equals(ReferencePair other) + { + return ReferenceEquals(Expected, other.Expected) && ReferenceEquals(Actual, other.Actual); + } + + public override int GetHashCode() + { + return HashCode.Combine(RuntimeHelpers.GetHashCode(Expected), RuntimeHelpers.GetHashCode(Actual)); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs index 8b0fac733..e6dd63989 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs @@ -57,6 +57,45 @@ public void ReadValidatesParsedDocumentAgainstConfiguredRules() Assert.Equal("Document failed validation.", error.Message); } + [Fact] + public void ReadReturnsValidationErrorForSelfReferentialDiscriminatorSchema() + { + var reader = new OpenApiJsonReader(); + using var stream = CreateStream( + """ + { + "openapi": "3.0.1", + "info": { + "title": "Sample", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Pet": { + "type": "object", + "discriminator": { + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ] + } + } + } + } + """); + + var result = reader.Read(stream, DocumentLocation, new OpenApiReaderSettings()); + + Assert.NotNull(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => + error is OpenApiValidatorError validatorError && + validatorError.RuleName == nameof(OpenApiSchemaRules.ValidateSchemaDiscriminator)); + } + [Fact] public void ReadReturnsDiagnosticWhenRootNodeCannotBeParsedAsDocument() { diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs index 595daabae..f7e9e0431 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs @@ -12,7 +12,7 @@ public class OpenApiModelFactoryTests [Fact] public async Task LoadDocumentWithCircularSchemaPropertyReferencesShouldSucceed() { - var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.json"); + var filePath = Path.Join(Path.GetTempPath(), $"{Guid.NewGuid():N}.json"); await File.WriteAllTextAsync(filePath, """ { "openapi": "3.0.0", @@ -40,11 +40,11 @@ await File.WriteAllTextAsync(filePath, """ } } } -"""); +""", TestContext.Current.CancellationToken); try { - var result = await OpenApiDocument.LoadAsync(filePath); + var result = await OpenApiDocument.LoadAsync(filePath, token: TestContext.Current.CancellationToken); Assert.NotNull(result.Document); Assert.Empty(result.Diagnostic.Errors); @@ -62,7 +62,7 @@ await File.WriteAllTextAsync(filePath, """ [Fact] public async Task LoadDocumentWithCircularRootSchemaReferencesShouldReturnDiagnosticWarning() { - var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.json"); + var filePath = Path.Join(Path.GetTempPath(), $"{Guid.NewGuid():N}.json"); await File.WriteAllTextAsync(filePath, """ { "openapi": "3.0.0", @@ -82,11 +82,11 @@ await File.WriteAllTextAsync(filePath, """ } } } -"""); +""", TestContext.Current.CancellationToken); try { - var result = await OpenApiDocument.LoadAsync(filePath); + var result = await OpenApiDocument.LoadAsync(filePath, token: TestContext.Current.CancellationToken); Assert.NotNull(result.Document); Assert.Empty(result.Diagnostic.Errors); @@ -102,7 +102,7 @@ await File.WriteAllTextAsync(filePath, """ [Fact] public async Task UsesSettingsBaseUrl() { - var tempFilePathReferee = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); + var tempFilePathReferee = Path.GetTempFileName(); await File.WriteAllTextAsync(tempFilePathReferee, """ { @@ -147,8 +147,8 @@ await File.WriteAllTextAsync(tempFilePathReferee, } } } -"""); - var tempFilePathReferrer = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); +""", TestContext.Current.CancellationToken); + var tempFilePathReferrer = Path.GetTempFileName(); await File.WriteAllTextAsync(tempFilePathReferrer, $$$""" { @@ -193,18 +193,18 @@ await File.WriteAllTextAsync(tempFilePathReferrer, } } } -"""); +""", TestContext.Current.CancellationToken); // read referrer document to a memory stream using var stream = new MemoryStream(); using var reader = new StreamReader(tempFilePathReferrer); - await reader.BaseStream.CopyToAsync(stream); + await reader.BaseStream.CopyToAsync(stream, TestContext.Current.CancellationToken); stream.Position = 0; var baseUri = new Uri(tempFilePathReferrer); var settings = new OpenApiReaderSettings { BaseUrl = baseUri, }; - var readResult = await OpenApiDocument.LoadAsync(stream, settings: settings); + var readResult = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(OpenApiConstants.Json, readResult.Diagnostic.Format); Assert.NotNull(readResult.Document); Assert.NotNull(readResult.Document.Components); @@ -237,7 +237,7 @@ public async Task CanLoadANonSeekableStreamInJsonAndDetectFormat() using var nonSeekableStream = new NonSeekableStream(memoryStream); // When - var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream); + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, cancellationToken: TestContext.Current.CancellationToken); // Then Assert.NotNull(document); @@ -254,7 +254,7 @@ public async Task CanLoadANonSeekableStreamInYamlAndDetectFormat() settings.AddYamlReader(); // When - var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, settings: settings); + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); // Then Assert.NotNull(document); @@ -269,7 +269,7 @@ public async Task CanLoadAnAsyncOnlyStreamInJsonAndDetectFormat() await using var nonSeekableStream = new AsyncOnlyStream(memoryStream); // When - var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream); + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, cancellationToken: TestContext.Current.CancellationToken); // Then Assert.NotNull(document); @@ -286,7 +286,7 @@ public async Task CanLoadAnAsyncOnlyStreamInYamlAndDetectFormat() settings.AddYamlReader(); // When - var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, settings: settings); + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, settings: settings, cancellationToken: TestContext.Current.CancellationToken); // Then Assert.NotNull(document); @@ -301,7 +301,7 @@ public async Task CanLoadANonSeekableStreamInJsonAndDetectFormatWhenPrecededBySp using var nonSeekableStream = new NonSeekableStream(memoryStream); // When - var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream); + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, cancellationToken: TestContext.Current.CancellationToken); // Then Assert.NotNull(document); diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs index 6a98dea42..0ce7ec4eb 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs @@ -179,7 +179,7 @@ public async Task SerializeAndDeserializeCookieParameterRoundTrip() }; // Act - var json = await document.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var json = await document.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2, TestContext.Current.CancellationToken); var result = OpenApiDocument.Parse(json, "json", SettingsFixture.ReaderSettings); // Assert diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs index 68f89a2a2..8993c17a5 100644 --- a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs @@ -2,7 +2,11 @@ // Licensed under the MIT license. using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; using Xunit; namespace Microsoft.OpenApi.Validations.Tests @@ -32,8 +36,65 @@ public void ValidateKeyMustMatchRegularExpressionInComponents() Assert.False(result); Assert.NotNull(errors); var error = Assert.Single(errors); - Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", OpenApiComponentsRules.KeyRegex.ToString()), + Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", OpenApiComponentsRules.KeyPattern), error.Message); } + + [Theory] + [InlineData("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_", true)] + [InlineData("", false)] + [InlineData("a b", false)] + [InlineData("a/b", false)] + [InlineData("é", false)] + [InlineData("12", false)] + [InlineData("a\n", true)] + [InlineData("\n", false)] + [InlineData("a\n\n", false)] + [InlineData("a\r\n", false)] + [InlineData("a\nb", false)] + [InlineData("a\0", false)] + public void ValidateComponentKeyPreservesRegexBehavior(string key, bool isValid) + { + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + { key, new OpenApiSchema() } + } + }; + + var rules = new ValidationRuleSet(); + rules.Add(typeof(OpenApiComponents), OpenApiComponentsRules.KeyMustBeRegularExpression); + var errors = components.Validate(rules); + + Assert.Equal(isValid, !errors.Any()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LoadAsyncValidatesLongComponentKeys(bool isValid) + { + var key = new string('a', 1_000_000) + (isValid ? string.Empty : "!"); + var json = """ + {"openapi":"3.1.0","info":{"title":"Test","version":"1.0"},"paths":{},"components":{"schemas":{ + """ + JsonSerializer.Serialize(key) + ":{\"type\":\"string\"}}}}"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var result = await OpenApiDocument.LoadAsync(stream, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result.Document); + Assert.NotNull(result.Diagnostic); + if (isValid) + { + Assert.Empty(result.Diagnostic.Errors); + } + else + { + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, + key, "schemas", OpenApiComponentsRules.KeyPattern), error.Message); + } + } } } diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs index 0cb229534..7f5cd47e2 100644 --- a/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs @@ -36,6 +36,7 @@ public void ValidateDefaultShouldNotHaveDataTypeMismatchForSimpleSchema() } [Fact] +#pragma warning disable CS0618 public void ValidateExampleAndDefaultShouldNotHaveDataTypeMismatchForSimpleSchema() { // Arrange @@ -58,6 +59,7 @@ public void ValidateExampleAndDefaultShouldNotHaveDataTypeMismatchForSimpleSchem // Assert Assert.True(result); } +#pragma warning restore CS0618 [Fact] public void ValidateEnumShouldNotHaveDataTypeMismatchForSimpleSchema() @@ -390,5 +392,204 @@ public void ValidateOneOfSchemaPropertyNameContainsPropertySpecifiedInTheDiscrim //Assert Assert.Empty(errors); } + + [Fact] + public void ValidateSchemaDiscriminatorReturnsExistingErrorForSelfCycleWithoutValidDiscriminator() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Discriminator = new() + { + PropertyName = "type" + } + }; + schema.OneOf = [schema]; + + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Person"] = schema + } + }; + + // Act + var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); + var walker = new OpenApiWalker(validator); + walker.Walk(components); + + // Assert + Assert.Equivalent(new List + { + new(nameof(OpenApiSchemaRules.ValidateSchemaDiscriminator), "#/schemas/Person/discriminator", + string.Format(SRResource.Validation_SchemaRequiredFieldListMustContainThePropertySpecifiedInTheDiscriminator, + string.Empty, "type")) + }, validator.Errors); + } + + [Fact] +#pragma warning disable CS0618 + public void TraverseSchemaElementsReturnsFalseForMultiNodeCycleWithoutDiscriminator() + { + // Arrange + var first = new OpenApiSchema(); + var second = new OpenApiSchema(); + var third = new OpenApiSchema(); + + first.OneOf = [second]; + second.AnyOf = [third]; + third.AllOf = [first]; + + // Act + var result = OpenApiSchemaRules.TraverseSchemaElements("type", [first]); + + // Assert + Assert.False(result); + } +#pragma warning restore CS0618 + + [Fact] +#pragma warning disable CS0618 + public void ValidateChildSchemaAgainstDiscriminatorReturnsTrueForDeepAcyclicTraversal() + { + // Arrange + const string discriminatorName = "type"; + var root = CreateSchemaChain(5000, discriminatorName); + + // Act + var result = OpenApiSchemaRules.ValidateChildSchemaAgainstDiscriminator(root, discriminatorName); + + // Assert + Assert.True(result); + } +#pragma warning restore CS0618 + + [Fact] + public void ValidateSchemaDiscriminatorCanFindValidLaterOneOfChild() + { + // Arrange + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Person"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Discriminator = new() + { + PropertyName = "type" + }, + OneOf = + [ + new OpenApiSchema(), + new OpenApiSchema + { + Properties = new Dictionary + { + ["type"] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + ] + } + } + }; + + // Act + var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); + var walker = new OpenApiWalker(validator); + walker.Walk(components); + + // Assert + Assert.Empty(validator.Errors); + } + + [Fact] + public void ValidateSchemaDiscriminatorChecksOneOfAnyOfAndAllOf() + { + // Arrange + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Person"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Discriminator = new() + { + PropertyName = "type" + }, + OneOf = + [ + new OpenApiSchema() + ], + AnyOf = + [ + new OpenApiSchema() + ], + AllOf = + [ + new OpenApiSchema + { + Properties = new Dictionary + { + ["type"] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + ] + } + } + }; + + // Act + var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); + var walker = new OpenApiWalker(validator); + walker.Walk(components); + + // Assert + Assert.Empty(validator.Errors); + } + + private static OpenApiSchema CreateSchemaChain(int depth, string discriminatorName) + { + var root = new OpenApiSchema(); + var current = root; + + for (var i = 0; i < depth; i++) + { + var next = new OpenApiSchema(); + switch (i % 3) + { + case 0: + current.OneOf = [next]; + break; + case 1: + current.AnyOf = [next]; + break; + default: + current.AllOf = [next]; + break; + } + + current = next; + } + + current.Properties = new Dictionary + { + [discriminatorName] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + }; + + return root; + } } } diff --git a/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs b/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs index f54e1e675..8bb018e9f 100644 --- a/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs @@ -120,6 +120,38 @@ public void OpenApiWorkspacesCanResolveReferencesToDocumentFragmentsWithJsonPoin Assert.Same(responseFragment.Headers["header1"], resolvedElement.Headers["header1"]); } + [Fact] + public void OpenApiWorkspacesCanResolveSchemaAnchorAgainstSchemaId() + { + // Arrange + var workspace = new OpenApiWorkspace(); + var schema = new OpenApiSchema + { + Id = "https://example.com/schema", + Anchor = "address", + Type = JsonSchemaType.Object + }; + var document = new OpenApiDocument + { + BaseUri = new Uri("https://example.com/openapi.yaml"), + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["person"] = schema + } + } + }; + + workspace.RegisterComponents(document); + + // Act + var resolvedSchema = workspace.ResolveReference("https://example.com/schema#address"); + + // Assert + Assert.Same(schema, resolvedSchema); + } + // Test artifacts private static OpenApiDocument CreateCommonDocument() { diff --git a/test/Microsoft.OpenApi.Tests/Writers/OpenApiJsonWriterTests.cs b/test/Microsoft.OpenApi.Tests/Writers/OpenApiJsonWriterTests.cs index fa3b0163d..8065c16b3 100644 --- a/test/Microsoft.OpenApi.Tests/Writers/OpenApiJsonWriterTests.cs +++ b/test/Microsoft.OpenApi.Tests/Writers/OpenApiJsonWriterTests.cs @@ -58,7 +58,7 @@ public async Task WriteStringListAsJsonShouldMatchExpected(string[] stringValues } writer.WriteEndArray(); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var parsedObject = JsonSerializer.Deserialize>(outputString.GetStringBuilder().ToString()); var expectedObject = diff --git a/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterAnyExtensionsTests.cs b/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterAnyExtensionsTests.cs index fb315feff..fb537ce39 100644 --- a/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterAnyExtensionsTests.cs +++ b/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterAnyExtensionsTests.cs @@ -173,13 +173,10 @@ public async Task WriteOpenApiDateTimeAsJsonWorksAsync(string inputString, bool Assert.Equal(expectedJson, json); } - public static IEnumerable BooleanInputs - { - get => - from input in new[] { true, false } - from shouldBeTerse in shouldProduceTerseOutputValues - select new object[] { input, shouldBeTerse }; - } + public static IEnumerable BooleanInputs { get; } = + from input in new[] { true, false } + from shouldBeTerse in shouldProduceTerseOutputValues + select new object[] { input, shouldBeTerse }; [Theory] [MemberData(nameof(BooleanInputs))] diff --git a/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterSpecialCharacterTests.cs b/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterSpecialCharacterTests.cs index 66819367c..16a416080 100644 --- a/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterSpecialCharacterTests.cs +++ b/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterSpecialCharacterTests.cs @@ -14,23 +14,20 @@ public class OpenApiWriterSpecialCharacterTests { static bool[] shouldProduceTerseOutputValues = new[] { true, false }; - public static IEnumerable StringWithSpecialCharacters - { - get => - from inputExpected in new[] { - new[]{ "Test\bTest", "\"Test\\bTest\"" }, - new[]{ "Test\fTest", "\"Test\\fTest\""}, - new[]{ "Test\nTest", "\"Test\\nTest\""}, - new[]{ "Test\rTest", "\"Test\\rTest\""}, - new[]{ "Test\tTest", "\"Test\\tTest\""}, - new[]{ "Test\\Test", "\"Test\\\\Test\""}, - new[]{ "Test\"Test", "\"Test\\\"Test\""}, - new[]{ "StringsWith\"Quotes\"", "\"StringsWith\\\"Quotes\\\"\""}, - new[]{ "0x1234", "\"0x1234\""}, - } - from shouldBeTerse in shouldProduceTerseOutputValues - select new object[] { inputExpected[0], inputExpected[1], shouldBeTerse }; - } + public static IEnumerable StringWithSpecialCharacters { get; } = + from inputExpected in new[] { + new[]{ "Test\bTest", "\"Test\\bTest\"" }, + new[]{ "Test\fTest", "\"Test\\fTest\""}, + new[]{ "Test\nTest", "\"Test\\nTest\""}, + new[]{ "Test\rTest", "\"Test\\rTest\""}, + new[]{ "Test\tTest", "\"Test\\tTest\""}, + new[]{ "Test\\Test", "\"Test\\\\Test\""}, + new[]{ "Test\"Test", "\"Test\\\"Test\""}, + new[]{ "StringsWith\"Quotes\"", "\"StringsWith\\\"Quotes\\\"\""}, + new[]{ "0x1234", "\"0x1234\""}, + } + from shouldBeTerse in shouldProduceTerseOutputValues + select new object[] { inputExpected[0], inputExpected[1], shouldBeTerse }; [Theory] [MemberData(nameof(StringWithSpecialCharacters))] diff --git a/test/Microsoft.OpenApi.Tests/Writers/OpenApiYamlWriterTests.cs b/test/Microsoft.OpenApi.Tests/Writers/OpenApiYamlWriterTests.cs index e80b5f7f9..97f18df28 100644 --- a/test/Microsoft.OpenApi.Tests/Writers/OpenApiYamlWriterTests.cs +++ b/test/Microsoft.OpenApi.Tests/Writers/OpenApiYamlWriterTests.cs @@ -70,7 +70,7 @@ public async Task WriteStringListAsYamlShouldMatchExpected(string[] stringValues } writer.WriteEndArray(); - await writer.FlushAsync(); + await writer.FlushAsync(TestContext.Current.CancellationToken); var actualYaml = outputString.GetStringBuilder() .ToString()