├── .config └── dotnet-tools.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 10_bug_report.yml │ ├── 20_feature_request.yml │ ├── 30_question.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actionlint-matcher.json ├── dependabot.yml ├── update-dotnet-sdk.json └── workflows │ ├── benchmark.yml │ ├── build.yml │ ├── bump-version.yml │ ├── code-scan.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── ossf-scorecard.yml │ └── release.yml ├── .gitignore ├── .markdownlint.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vsconfig ├── CODE_OF_CONDUCT.md ├── Directory.Build.props ├── Directory.Packages.props ├── LICENSE ├── NuGet.config ├── OpenApi.Extensions.ruleset ├── OpenApi.Extensions.slnx ├── README.md ├── SECURITY.MD ├── benchmark.ps1 ├── build.ps1 ├── exclusion.dic ├── global.json ├── omnisharp.json ├── package-icon.png ├── package-readme.md ├── perf └── OpenApi.Extensions.Benchmarks │ ├── MartinCostello.OpenApi.Extensions.Benchmarks.csproj │ ├── OpenApiBenchmarks.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ └── TodoAppServer.cs ├── samples └── TodoApp │ ├── ApiEndpoints.cs │ ├── Data │ ├── ITodoRepository.cs │ ├── TodoContext.cs │ ├── TodoItem.cs │ └── TodoRepository.cs │ ├── DateTimeExampleProvider.cs │ ├── Extensions │ └── DbSetExtensions.cs │ ├── GuidExampleProvider.cs │ ├── Models │ ├── CreateTodoItemModel.cs │ ├── CreatedTodoItemModel.cs │ ├── TodoItemFilterModel.cs │ ├── TodoItemModel.cs │ └── TodoListViewModel.cs │ ├── ProblemDetailsExampleProvider.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Services │ ├── ITodoService.cs │ └── TodoService.cs │ ├── TodoApp.csproj │ ├── TodoAppBuilder.cs │ ├── TodoJsonSerializerContext.cs │ ├── appsettings.Development.json │ ├── appsettings.json │ └── wwwroot │ ├── favicon.ico │ └── swagger-ui │ └── index.html ├── src └── OpenApi.Extensions │ ├── ExampleFormatter.cs │ ├── ExampleFormatter.net9.0.cs │ ├── ExampleTargets.cs │ ├── IExampleProvider`1.cs │ ├── IOpenApiExampleMetadata.cs │ ├── IOpenApiExampleMetadata.net9.0.cs │ ├── MartinCostello.OpenApi.Extensions.csproj │ ├── OpenApiConstants.cs │ ├── OpenApiEndpointConventionBuilderExtensions.cs │ ├── OpenApiEndpointRouteBuilderExtensions.cs │ ├── OpenApiExampleAttribute.cs │ ├── OpenApiExampleAttribute`1.cs │ ├── OpenApiExampleAttribute`2.cs │ ├── OpenApiExampleAttribute`2.net9.0.cs │ ├── OpenApiExtensions.cs │ ├── OpenApiExtensionsOptions.cs │ ├── OpenApiResponseAttribute.cs │ ├── PublicAPI │ ├── PublicAPI.Shipped.txt │ ├── PublicAPI.Unshipped.txt │ └── net9.0 │ │ ├── PublicAPI.Shipped.txt │ │ └── PublicAPI.Unshipped.txt │ ├── ReflectionExtensions.cs │ ├── Transformers │ ├── AddExamplesTransformer.cs │ ├── AddOperationXmlDocumentationTransformer.cs │ ├── AddParameterDescriptionsTransformer.cs │ ├── AddResponseDescriptionsTransformer.cs │ ├── AddSchemaXmlDocumentationTransformer.cs │ ├── AddServersTransformer.cs │ └── DescriptionsTransformer.cs │ ├── XmlCommentsHelper.cs │ └── XmlDescriptionService.cs ├── startvs.cmd ├── startvscode.cmd ├── stylecop.json └── tests ├── Models.A ├── Animal.cs ├── AnimalExampleProvider.cs ├── AnimalsJsonSerializationContext.cs ├── Cat.cs ├── CatExampleProvider.cs ├── Dog.cs ├── DogExampleProvider.cs ├── IAnimal.cs ├── Models.A.csproj ├── Spot.cs └── Tom.cs ├── Models.B ├── Car.cs ├── CarType.cs ├── Models.B.csproj ├── Motorcycle.cs ├── Vehicle.cs └── VehiclesJsonSerializationContext.cs ├── OpenApi.Extensions.Tests ├── AssemblyTests.cs ├── ControllerTests.Schema_Is_Correct_For_Web_Api.DotNet9_0.verified.txt ├── ControllerTests.cs ├── DocumentTests.cs ├── ExampleFormatterTests.Can_Serialize_Boolean_value=False.DotNet9_0.verified.txt ├── ExampleFormatterTests.Can_Serialize_Boolean_value=True.DotNet9_0.verified.txt ├── ExampleFormatterTests.Can_Serialize_Complex_Object.DotNet9_0.verified.txt ├── ExampleFormatterTests.Can_Serialize_Integer.DotNet9_0.verified.txt ├── ExampleFormatterTests.Can_Serialize_Long.DotNet9_0.verified.txt ├── ExampleFormatterTests.Can_Serialize_Null_String.DotNet9_0.verified.txt ├── ExampleFormatterTests.Can_Serialize_String.DotNet9_0.verified.txt ├── ExampleFormatterTests.cs ├── IWebHostBuilderExtensions.cs ├── IntegrationTests.Http_404_Is_Returned_If_Yaml_Document_Not_Found.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_App.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_App_As_Yaml.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_Classes.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_Example_Attribute_Hierarchy.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_Interfaces.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_Not_Json.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_Records.DotNet9_0.verified.txt ├── IntegrationTests.Schema_Is_Correct_For_Sample.DotNet9_0.verified.txt ├── IntegrationTests.cs ├── MartinCostello.OpenApi.Extensions.Tests.csproj ├── MinimalFixture.cs ├── MvcFixture.cs ├── OpenApiEndpointConventionBuilderExtensionsTests.cs ├── OpenApiExampleAttributeTests.cs ├── OpenApiExtensionsTests.cs ├── OpenApiResponseAttributeTests.cs ├── WebApplicationFactoryExtensions.cs └── XmlCommentsHelperTests.cs ├── TestApp ├── AppJsonSerializationContext.cs ├── Greeting.cs ├── ProblemDetailsExampleProvider.cs ├── Program.cs ├── Properties │ └── launchSettings.json └── TestApp.csproj └── WebApi ├── Controllers └── TimeController.cs ├── Models └── TimeModel.cs ├── MvcSerializerContext.cs ├── Program.cs ├── Properties └── launchSettings.json ├── WebApi.csproj ├── appsettings.Development.json └── appsettings.json /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-validate": { 6 | "version": "0.0.1-preview.537", 7 | "commands": [ 8 | "dotnet-validate" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh eol=lf 2 | *.verified.json text eol=lf working-tree-encoding=UTF-8 3 | *.verified.txt text eol=lf working-tree-encoding=UTF-8 4 | *.verified.xml text eol=lf working-tree-encoding=UTF-8 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @martincostello 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To contribute changes (source code, scripts, configuration) to this repository please follow the steps below. 4 | These steps are a guideline for contributing and do not necessarily need to be followed for all changes. 5 | 6 | 1. If you intend to fix a bug please create an issue before forking the repository. 7 | 1. Fork the `main` branch of this repository from the latest commit. 8 | 1. Create a branch from your fork's `main` branch to help isolate your changes from any further work on `main`. If fixing an issue try to reference its name in your branch name (e.g. `issue-123`) to make changes easier to track the changes. 9 | 1. Work on your proposed changes on your fork. If you are fixing an issue include at least one unit test that reproduces it if the code changes to fix it have not been applied; if you are adding new functionality please include unit tests appropriate to the changes you are making. 10 | 1. When you think your changes are complete, test that the code builds cleanly using `build.ps1`. There should be no compiler warnings and all tests should pass. 11 | 1. Once your changes build cleanly locally submit a Pull Request back to the `main` branch from your fork's branch. Ideally commits to your branch should be squashed before creating the Pull Request. If the Pull Request fixes an issue please reference it in the title and/or description. Please keep changes focused around a specific topic rather than include multiple types of changes in a single Pull Request. 12 | 1. After your Pull Request is created it will build against the repository's continuous integrations. 13 | 1. Once the Pull Request has been reviewed by the project's [contributors](https://github.com/martincostello/openapi-extensions/graphs/contributors) and the status checks pass your Pull Request will be merged back to the `main` branch, assuming that the changes are deemed appropriate. 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [martincostello] 2 | buy_me_a_coffee: martincostello 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: '' 3 | --- 4 | 5 | ### Expected behaviour 6 | 7 | 8 | 9 | ### Actual behaviour 10 | 11 | 12 | 13 | ### Steps to reproduce 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/10_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Something not behaving as expected? 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/openapi-extensions/blob/main/README.md) before submitting a bug report. 10 | 11 | If you're not using the latest release, please try upgrading to the latest version first to see if the issue resolves itself. 12 | - type: input 13 | attributes: 14 | label: Version 15 | description: Which version of the library are you experiencing the issue with? 16 | placeholder: 1.1.0 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Describe the bug 22 | description: A clear and concise description of what the bug is. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Expected behaviour 28 | description: A clear and concise description of what you expected to happen. 29 | validations: 30 | required: false 31 | - type: textarea 32 | attributes: 33 | label: Actual behaviour 34 | description: What actually happens. 35 | validations: 36 | required: false 37 | - type: textarea 38 | attributes: 39 | label: Steps to reproduce 40 | description: | 41 | Provide a link to a [minimalistic project which reproduces this issue (repro)](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 42 | Code snippets, such as a failing unit test or small console app, which demonstrate the issue wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 43 | 44 | This issue will be closed if: 45 | - The behaviour you're reporting cannot be easily reproduced. 46 | - The issue is a duplicate of an existing issue. 47 | - The behaviour you're reporting is by design. 48 | validations: 49 | required: false 50 | - type: textarea 51 | attributes: 52 | label: Exception(s) (if any) 53 | description: Include any exception(s) and/or stack trace(s) you get when facing this issue. 54 | render: text 55 | validations: 56 | required: false 57 | - type: input 58 | attributes: 59 | label: .NET Version 60 | description: | 61 | Run `dotnet --version` to get the .NET SDK version you're using. 62 | Alternatively, which target framework(s) (e.g. `net8.0`) does the project you're using the package with target? 63 | placeholder: 9.0.100 64 | validations: 65 | required: false 66 | - type: textarea 67 | attributes: 68 | label: Anything else? 69 | description: | 70 | Links? References? Anything that will give us more context about the issue you are encountering is useful. 71 | 72 | 💡Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 73 | validations: 74 | required: false 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/20_feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature request 2 | description: Suggest a feature request or improvement 3 | title: '[Feature request]: ' 4 | labels: ['feature-request'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/openapi-extensions/blob/main/README.md) before submitting a feature request. 10 | - type: textarea 11 | attributes: 12 | label: Is your feature request related to a specific problem? Or an existing feature? 13 | description: A clear and concise description of what the problem is. Motivating examples help prioritise things. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the solution you'd like 20 | description: | 21 | A clear and concise description of what you want to happen. Include any alternative solutions you've considered. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: | 28 | Add any other context or screenshots about the feature request here. 29 | validations: 30 | required: false 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/30_question.yml: -------------------------------------------------------------------------------- 1 | name: 🤔 Question? 2 | description: You have something specific to achieve and the existing documentation hasn't covered how. 3 | title: '[Question]: ' 4 | labels: ['question'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/openapi-extensions/blob/main/README.md) before asking a question. 10 | - type: textarea 11 | attributes: 12 | label: What do you want to achieve? 13 | description: A clear and concise description of what you're trying to do. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: What code or approach do you have so far? 20 | description: | 21 | Provide a [minimalistic project which shows what you have so far](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 22 | Code snippets wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: | 29 | Add any other context or screenshots related to your question here. 30 | validations: 31 | required: false 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Contact me 4 | url: https://martincostello.com/bluesky 5 | about: You can also contact me on Bluesky. 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/actionlint-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "actionlint", 5 | "pattern": [ 6 | { 7 | "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4, 12 | "code": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "05:30" 8 | timezone: Europe/London 9 | - package-ecosystem: nuget 10 | directory: "/" 11 | groups: 12 | xunit: 13 | patterns: 14 | - Verify.Xunit* 15 | - xunit* 16 | schedule: 17 | interval: daily 18 | time: "05:30" 19 | timezone: Europe/London 20 | open-pull-requests-limit: 99 21 | ignore: 22 | - dependency-name: "Microsoft.AspNetCore.OpenApi" 23 | - dependency-name: "Microsoft.OpenApi*" 24 | -------------------------------------------------------------------------------- /.github/update-dotnet-sdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martincostello/github-automation/main/.github/update-dotnet-sdk-schema.json", 3 | "update-nuget-packages": false 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: benchmark 2 | 3 | env: 4 | DOTNET_CLI_TELEMETRY_OPTOUT: true 5 | DOTNET_NOLOGO: true 6 | DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 7 | NUGET_XMLDOC_MODE: skip 8 | TERM: xterm 9 | 10 | on: 11 | push: 12 | branches: 13 | - main 14 | - dotnet-vnext 15 | - dotnet-nightly 16 | paths-ignore: 17 | - '**/*.gitattributes' 18 | - '**/*.gitignore' 19 | - '**/*.md' 20 | workflow_dispatch: 21 | 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | benchmark: 27 | name: benchmark 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | 32 | - name: Checkout code 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | filter: 'tree:0' 36 | show-progress: false 37 | 38 | - name: Setup .NET SDK 39 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 40 | with: 41 | dotnet-version: '9.0.x' 42 | 43 | - name: Setup .NET SDK 44 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 45 | 46 | - name: Run benchmarks 47 | shell: pwsh 48 | run: ./benchmark.ps1 49 | 50 | - name: Publish BenchmarkDotNet artifacts 51 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 52 | if: ${{ !cancelled() }} 53 | with: 54 | name: artifacts 55 | path: ./BenchmarkDotNet.Artifacts/results/* 56 | if-no-files-found: error 57 | 58 | - name: Get repository name 59 | id: get-repo-name 60 | shell: pwsh 61 | run: | 62 | $repoName = ${env:GITHUB_REPOSITORY}.Split("/")[-1] 63 | "repo-name=${repoName}" >> ${env:GITHUB_OUTPUT} 64 | 65 | - name: Publish results 66 | uses: martincostello/benchmarkdotnet-results-publisher@abcb3ce3975e1e86f06f2c04e3a4059ccdb91cc1 # v1.0.2 67 | with: 68 | branch: ${{ github.ref_name }} 69 | comment-on-threshold: true 70 | name: 'OpenAPI Extensions' 71 | output-file-path: '${{ steps.get-repo-name.outputs.repo-name }}/data.json' 72 | repo: '${{ github.repository_owner }}/benchmarks' 73 | repo-token: ${{ secrets.BENCHMARKS_TOKEN }} 74 | 75 | - name: Output summary 76 | shell: pwsh 77 | env: 78 | REPO_NAME: ${{ steps.get-repo-name.outputs.repo-name }} 79 | run: | 80 | $summary += "`n`n" 81 | $summary += "View benchmark results history [here](https://benchmarks.martincostello.com/?repo=${env:REPO_NAME}&branch=${env:GITHUB_REF_NAME})." 82 | $summary >> ${env:GITHUB_STEP_SUMMARY} 83 | -------------------------------------------------------------------------------- /.github/workflows/code-scan.yml: -------------------------------------------------------------------------------- 1 | name: code-scan 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: 8 | - main 9 | - dotnet-vnext 10 | - dotnet-nightly 11 | schedule: 12 | - cron: '0 6 * * MON' 13 | workflow_dispatch: 14 | 15 | permissions: 16 | actions: read 17 | contents: read 18 | 19 | jobs: 20 | code-ql: 21 | 22 | runs-on: ubuntu-latest 23 | 24 | permissions: 25 | security-events: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 30 | with: 31 | filter: 'tree:0' 32 | show-progress: false 33 | 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 36 | with: 37 | build-mode: none 38 | languages: 'csharp' 39 | queries: security-and-quality 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 43 | with: 44 | category: '/language:csharp' 45 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: dependency-review 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dotnet-vnext 8 | - dotnet-nightly 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | filter: 'tree:0' 23 | show-progress: false 24 | 25 | - name: Review dependencies 26 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 27 | with: 28 | allow-licenses: 'Apache-2.0,BSD-3-Clause,MIT' 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - '**/*.gitattributes' 8 | - '**/*.gitignore' 9 | - '**/*.md' 10 | pull_request: 11 | branches: 12 | - main 13 | - dotnet-vnext 14 | - dotnet-nightly 15 | workflow_dispatch: 16 | 17 | permissions: 18 | contents: read 19 | 20 | env: 21 | FORCE_COLOR: 3 22 | POWERSHELL_YAML_VERSION: '0.4.12' 23 | PSSCRIPTANALYZER_VERSION: '1.24.0' 24 | TERM: xterm 25 | 26 | jobs: 27 | lint: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | 32 | - name: Checkout code 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | filter: 'tree:0' 36 | show-progress: false 37 | 38 | - name: Add actionlint problem matcher 39 | run: echo "::add-matcher::.github/actionlint-matcher.json" 40 | 41 | - name: Lint workflows 42 | uses: docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 # v1.7.7 43 | with: 44 | args: -color 45 | 46 | - name: Lint markdown 47 | uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20.0.0 48 | with: 49 | config: '.markdownlint.json' 50 | globs: | 51 | **/*.md 52 | 53 | - name: Lint PowerShell in workflows 54 | uses: martincostello/lint-actions-powershell@5942e3350ee5bd8f8933cec4e1185d13f0ea688f # v1.0.0 55 | with: 56 | powershell-yaml-version: ${{ env.POWERSHELL_YAML_VERSION }} 57 | psscriptanalyzer-version: ${{ env.PSSCRIPTANALYZER_VERSION }} 58 | treat-warnings-as-errors: true 59 | 60 | - name: Lint PowerShell scripts 61 | shell: pwsh 62 | run: | 63 | $settings = @{ 64 | IncludeDefaultRules = $true 65 | Severity = @("Error", "Warning") 66 | } 67 | $issues = Invoke-ScriptAnalyzer -Path ${env:GITHUB_WORKSPACE} -Recurse -ReportSummary -Settings $settings 68 | foreach ($issue in $issues) { 69 | $severity = $issue.Severity.ToString() 70 | $level = $severity.Contains("Error") ? "error" : $severity.Contains("Warning") ? "warning" : "notice" 71 | Write-Output "::${level} file=$($issue.ScriptName),line=$($issue.Line),title=PSScriptAnalyzer::$($issue.Message)" 72 | } 73 | if ($issues.Count -gt 0) { 74 | exit 1 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/ossf-scorecard.yml: -------------------------------------------------------------------------------- 1 | name: ossf-scorecard 2 | 3 | on: 4 | branch_protection_rule: 5 | push: 6 | branches: [ main ] 7 | schedule: 8 | - cron: '0 5 * * MON' 9 | workflow_dispatch: 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | name: analysis 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | id-token: write 20 | security-events: write 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | filter: 'tree:0' 27 | persist-credentials: false 28 | show-progress: false 29 | 30 | - name: Run analysis 31 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 32 | with: 33 | publish_results: true 34 | results_file: results.sarif 35 | results_format: sarif 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 39 | with: 40 | name: SARIF 41 | path: results.sarif 42 | retention-days: 5 43 | 44 | - name: Upload to code-scanning 45 | uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 46 | with: 47 | sarif_file: results.sarif 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | publish: 7 | description: 'If true, does not create the release as a draft.' 8 | required: false 9 | type: boolean 10 | default: false 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | release: 16 | runs-on: [ ubuntu-latest ] 17 | 18 | concurrency: 19 | group: ${{ github.workflow }} 20 | cancel-in-progress: false 21 | 22 | steps: 23 | 24 | - name: Checkout code 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | with: 27 | filter: 'tree:0' 28 | show-progress: false 29 | token: ${{ secrets.COSTELLOBOT_TOKEN }} 30 | 31 | - name: Get version 32 | id: get-version 33 | shell: pwsh 34 | run: | 35 | $properties = Join-Path "." "Directory.Build.props" 36 | $xml = [xml](Get-Content $properties) 37 | $version = $xml.SelectSingleNode('Project/PropertyGroup/VersionPrefix').InnerText 38 | "version=${version}" >> $env:GITHUB_OUTPUT 39 | 40 | - name: Create release 41 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 42 | env: 43 | DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} 44 | DRAFT: ${{ inputs.publish != true }} 45 | VERSION: ${{ steps.get-version.outputs.version }} 46 | with: 47 | github-token: ${{ secrets.COSTELLOBOT_TOKEN }} 48 | script: | 49 | const { repo, owner } = context.repo; 50 | const draft = process.env.DRAFT === 'true'; 51 | const version = process.env.VERSION; 52 | const tag_name = `v${version}`; 53 | const name = tag_name; 54 | 55 | const { data: notes } = await github.rest.repos.generateReleaseNotes({ 56 | owner, 57 | repo, 58 | tag_name, 59 | target_commitish: process.env.DEFAULT_BRANCH, 60 | }); 61 | 62 | const body = notes.body 63 | .split('\n') 64 | .filter((line) => !line.includes(' @costellobot ')) 65 | .filter((line) => !line.includes(' @dependabot ')) 66 | .filter((line) => !line.includes(' @github-actions ')) 67 | .join('\n'); 68 | 69 | const { data: release } = await github.rest.repos.createRelease({ 70 | owner, 71 | repo, 72 | tag_name, 73 | name, 74 | body, 75 | draft, 76 | }); 77 | 78 | core.notice(`Created release ${release.name}: ${release.html_url}`); 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dotnet 3 | .idea 4 | .metadata 5 | .settings 6 | .vs 7 | _ReSharper* 8 | _reports 9 | _UpgradeReport_Files/ 10 | artifacts/ 11 | Backup*/ 12 | BenchmarkDotNet.Artifacts/ 13 | bin 14 | Bin 15 | coverage 16 | coverage.* 17 | junit.xml 18 | MSBuild_Logs/ 19 | node_modules 20 | obj 21 | packages 22 | project.lock.json 23 | TestResults 24 | typings 25 | UpgradeLog*.htm 26 | UpgradeLog*.XML 27 | PublishProfiles 28 | *.binlog 29 | *.db 30 | *.db-shm 31 | *.db-wal 32 | *.DotSettings 33 | *.GhostDoc.xml 34 | *.log 35 | *.nupkg 36 | !.packages/*.nupkg 37 | *.opensdf 38 | *.[Pp]ublish.xml 39 | *.publishproj 40 | *.pubxml 41 | *.received.* 42 | *.sdf 43 | *.sln.cache 44 | *.sln.docstates 45 | *.sln.ide 46 | *.suo 47 | *.user 48 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD040": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "ms-dotnettools.csharp", 5 | "ms-vscode.PowerShell" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run tests", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "dotnet", 10 | "args": [ 11 | "test" 12 | ], 13 | "cwd": "${workspaceFolder}/tests/OpenApi.Extensions.Tests", 14 | "console": "internalConsole", 15 | "stopAtEntry": false, 16 | "internalConsoleOptions": "openOnSessionStart" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "terminal.integrated.defaultProfile.linux": "pwsh", 3 | "terminal.integrated.defaultProfile.osx": "pwsh", 4 | "terminal.integrated.defaultProfile.windows": "PowerShell" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet build", 7 | "type": "shell", 8 | "group": "build", 9 | "presentation": { 10 | "reveal": "silent" 11 | }, 12 | "problemMatcher": "$msCompile" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vsconfig: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "components": [ 4 | "Microsoft.NetCore.Component.Runtime.9.0", 5 | "Microsoft.NetCore.Component.SDK", 6 | "Microsoft.VisualStudio.Component.CoreEditor", 7 | "Microsoft.VisualStudio.Component.Roslyn.Compiler", 8 | "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", 9 | "Microsoft.VisualStudio.Workload.CoreEditor" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team [through a GitHub issue][issue]. 59 | All complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [issue]: https://github.com/martincostello/openapi-extensions/issues 74 | [homepage]: https://contributor-covenant.org 75 | [version]: https://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | $(MSBuildThisFileDirectory)OpenApi.Extensions.ruleset 5 | true 6 | openapi 7 | true 8 | true 9 | 1.1.0 10 | 1.1.1 11 | 12 | 13 | true 14 | $(NoWarn);419;1570;1573;1574;1584;1591;SA0001;SA1602 15 | 16 | 17 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /OpenApi.Extensions.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /OpenApi.Extensions.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /SECURITY.MD: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To privately report a security vulnerability, please create a security advisory in the [repository's Security tab][advisories]. 6 | 7 | Further details can be found in the [GitHub documentation][reporting]. 8 | 9 | [advisories]: https://github.com/martincostello/openapi-extensions/security/advisories 10 | [reporting]: https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability 11 | -------------------------------------------------------------------------------- /benchmark.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | 3 | #Requires -PSEdition Core 4 | #Requires -Version 7 5 | 6 | param( 7 | [Parameter(Mandatory = $false)][string] $Filter = "", 8 | [Parameter(Mandatory = $false)][string] $Job = "" 9 | ) 10 | 11 | $ErrorActionPreference = "Stop" 12 | $ProgressPreference = "SilentlyContinue" 13 | 14 | $solutionPath = $PSScriptRoot 15 | $sdkFile = Join-Path $solutionPath "global.json" 16 | 17 | $dotnetVersion = (Get-Content $sdkFile | Out-String | ConvertFrom-Json).sdk.version 18 | 19 | $installDotNetSdk = $false 20 | 21 | if (($null -eq (Get-Command "dotnet" -ErrorAction SilentlyContinue)) -and ($null -eq (Get-Command "dotnet.exe" -ErrorAction SilentlyContinue))) { 22 | Write-Output "The .NET SDK is not installed." 23 | $installDotNetSdk = $true 24 | } 25 | else { 26 | Try { 27 | $installedDotNetVersion = (dotnet --version 2>&1 | Out-String).Trim() 28 | } 29 | Catch { 30 | $installedDotNetVersion = "?" 31 | } 32 | 33 | if ($installedDotNetVersion -ne $dotnetVersion) { 34 | Write-Output "The required version of the .NET SDK is not installed. Expected $dotnetVersion." 35 | $installDotNetSdk = $true 36 | } 37 | } 38 | 39 | if ($installDotNetSdk) { 40 | ${env:DOTNET_INSTALL_DIR} = Join-Path $PSScriptRoot ".dotnet" 41 | $sdkPath = Join-Path ${env:DOTNET_INSTALL_DIR} "sdk" $dotnetVersion 42 | 43 | if (-Not (Test-Path $sdkPath)) { 44 | if (-Not (Test-Path ${env:DOTNET_INSTALL_DIR})) { 45 | mkdir ${env:DOTNET_INSTALL_DIR} | Out-Null 46 | } 47 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor "Tls12" 48 | 49 | if (($PSVersionTable.PSVersion.Major -ge 6) -And (-Not $IsWindows)) { 50 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.sh" 51 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile $installScript -UseBasicParsing 52 | chmod +x $installScript 53 | & $installScript --jsonfile $sdkFile --install-dir ${env:DOTNET_INSTALL_DIR} --no-path 54 | } 55 | else { 56 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.ps1" 57 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile $installScript -UseBasicParsing 58 | & $installScript -JsonFile $sdkFile -InstallDir ${env:DOTNET_INSTALL_DIR} -NoPath 59 | } 60 | } 61 | } 62 | else { 63 | ${env:DOTNET_INSTALL_DIR} = Split-Path -Path (Get-Command dotnet).Path 64 | } 65 | 66 | $dotnet = Join-Path ${env:DOTNET_INSTALL_DIR} "dotnet" 67 | 68 | if ($installDotNetSdk) { 69 | ${env:PATH} = "${env:DOTNET_INSTALL_DIR};${env:PATH}" 70 | } 71 | 72 | $benchmarks = (Join-Path $solutionPath "perf" "OpenApi.Extensions.Benchmarks" "MartinCostello.OpenApi.Extensions.Benchmarks.csproj") 73 | 74 | Write-Output "Running benchmarks..." 75 | 76 | $additionalArgs = @() 77 | 78 | if (-Not [string]::IsNullOrEmpty($Filter)) { 79 | $additionalArgs += "--filter" 80 | $additionalArgs += $Filter 81 | } 82 | 83 | if (-Not [string]::IsNullOrEmpty($Job)) { 84 | $additionalArgs += "--job" 85 | $additionalArgs += $Job 86 | } 87 | 88 | if (-Not [string]::IsNullOrEmpty(${env:GITHUB_SHA})) { 89 | $additionalArgs += "--exporters" 90 | $additionalArgs += "json" 91 | } 92 | 93 | & $dotnet run --project $benchmarks --configuration "Release" -- $additionalArgs 94 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | 3 | #Requires -PSEdition Core 4 | #Requires -Version 7 5 | 6 | param( 7 | [Parameter(Mandatory = $false)][switch] $SkipTests 8 | ) 9 | 10 | $ErrorActionPreference = "Stop" 11 | $ProgressPreference = "SilentlyContinue" 12 | 13 | $solutionPath = $PSScriptRoot 14 | $sdkFile = Join-Path $solutionPath "global.json" 15 | 16 | $dotnetVersion = (Get-Content $sdkFile | Out-String | ConvertFrom-Json).sdk.version 17 | 18 | $installDotNetSdk = $false 19 | 20 | if (($null -eq (Get-Command "dotnet" -ErrorAction SilentlyContinue)) -and ($null -eq (Get-Command "dotnet.exe" -ErrorAction SilentlyContinue))) { 21 | Write-Output "The .NET SDK is not installed." 22 | $installDotNetSdk = $true 23 | } 24 | else { 25 | Try { 26 | $installedDotNetVersion = (dotnet --version 2>&1 | Out-String).Trim() 27 | } 28 | Catch { 29 | $installedDotNetVersion = "?" 30 | } 31 | 32 | if ($installedDotNetVersion -ne $dotnetVersion) { 33 | Write-Output "The required version of the .NET SDK is not installed. Expected $dotnetVersion." 34 | $installDotNetSdk = $true 35 | } 36 | } 37 | 38 | if ($installDotNetSdk) { 39 | 40 | ${env:DOTNET_INSTALL_DIR} = Join-Path $PSScriptRoot ".dotnet" 41 | $sdkPath = Join-Path ${env:DOTNET_INSTALL_DIR} "sdk" $dotnetVersion 42 | 43 | if (-Not (Test-Path $sdkPath)) { 44 | if (-Not (Test-Path ${env:DOTNET_INSTALL_DIR})) { 45 | mkdir ${env:DOTNET_INSTALL_DIR} | Out-Null 46 | } 47 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor "Tls12" 48 | if (($PSVersionTable.PSVersion.Major -ge 6) -And (-Not $IsWindows)) { 49 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.sh" 50 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile $installScript -UseBasicParsing 51 | chmod +x $installScript 52 | & $installScript --jsonfile $sdkFile --install-dir ${env:DOTNET_INSTALL_DIR} --no-path --skip-non-versioned-files 53 | } 54 | else { 55 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.ps1" 56 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile $installScript -UseBasicParsing 57 | & $installScript -JsonFile $sdkFile -InstallDir ${env:DOTNET_INSTALL_DIR} -NoPath -SkipNonVersionedFiles 58 | } 59 | } 60 | } 61 | else { 62 | ${env:DOTNET_INSTALL_DIR} = Split-Path -Path (Get-Command dotnet).Path 63 | } 64 | 65 | $dotnet = Join-Path ${env:DOTNET_INSTALL_DIR} "dotnet" 66 | 67 | if ($installDotNetSdk) { 68 | ${env:PATH} = "${env:DOTNET_INSTALL_DIR};${env:PATH}" 69 | } 70 | 71 | function DotNetPack { 72 | param([string]$Project) 73 | 74 | & $dotnet pack $Project --include-symbols --include-source 75 | 76 | if ($LASTEXITCODE -ne 0) { 77 | throw "dotnet pack failed with exit code $LASTEXITCODE" 78 | } 79 | } 80 | 81 | function DotNetTest { 82 | param() 83 | 84 | $additionalArgs = @() 85 | 86 | if (-Not [string]::IsNullOrEmpty(${env:GITHUB_SHA})) { 87 | $additionalArgs += "--logger:GitHubActions;report-warnings=false" 88 | $additionalArgs += "--logger:junit;LogFilePath=junit.xml" 89 | } 90 | 91 | & $dotnet test --configuration "Release" $additionalArgs 92 | 93 | if ($LASTEXITCODE -ne 0) { 94 | throw "dotnet test failed with exit code $LASTEXITCODE" 95 | } 96 | } 97 | 98 | $packageProjects = @( 99 | (Join-Path $solutionPath "src" "OpenApi.Extensions" "MartinCostello.OpenApi.Extensions.csproj") 100 | ) 101 | 102 | Write-Output "Packaging libraries..." 103 | ForEach ($project in $packageProjects) { 104 | DotNetPack $project $Configuration 105 | } 106 | 107 | if (-Not $SkipTests) { 108 | Write-Output "Testing solution..." 109 | DotNetTest 110 | } 111 | -------------------------------------------------------------------------------- /exclusion.dic: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.301", 4 | "allowPrerelease": false, 5 | "rollForward": "latestMajor", 6 | "paths": [ ".dotnet", "$host$" ], 7 | "errorMessage": "The required version of the .NET SDK could not be found. Please run ./build.ps1 to bootstrap the .NET SDK." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /omnisharp.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileOptions": { 3 | "systemExcludeSearchPatterns": [ 4 | "**/bin/**/*", 5 | "**/obj/**/*", 6 | "**/node_modules/**/*" 7 | ], 8 | "excludeSearchPatterns": [] 9 | }, 10 | "FormattingOptions": { 11 | "enableEditorConfigSupport": true 12 | }, 13 | "msbuild": { 14 | "MSBuildSDKsPath": ".\\.dotnet", 15 | "EnablePackageAutoRestore": true, 16 | "loadProjectsOnDemand": true 17 | }, 18 | "RoslynExtensionsOptions": { 19 | "enableAnalyzersSupport": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/openapi-extensions/ce1cc678ff86186a2ad58f910f3e183796449a21/package-icon.png -------------------------------------------------------------------------------- /package-readme.md: -------------------------------------------------------------------------------- 1 | # OpenAPI Extensions 2 | 3 | [![NuGet][package-badge-version]][package-download] 4 | [![NuGet Downloads][package-badge-downloads]][package-download] 5 | [![Build status][build-badge]][build-status] 6 | 7 | ## Introduction 8 | 9 | Extensions for the [Microsoft.AspNetCore.OpenApi][aspnetcore-openapi] library. 10 | 11 | ## Feedback 12 | 13 | Any feedback or issues for this package can be added to the issues in [GitHub][issues]. 14 | 15 | ## License 16 | 17 | This package is licensed under the [Apache 2.0][license] license. 18 | 19 | [aspnetcore-openapi]: https://www.nuget.org/packages/Microsoft.AspNetCore.OpenApi 20 | [build-badge]: https://github.com/martincostello/openapi-extensions/actions/workflows/build.yml/badge.svg?branch=main&event=push 21 | [build-status]: https://github.com/martincostello/openapi-extensions/actions?query=workflow%3Abuild+branch%3Amain+event%3Apush "Continuous Integration for this project" 22 | [issues]: https://github.com/martincostello/openapi-extensions/issues "Issues for this project on GitHub.com" 23 | [license]: https://www.apache.org/licenses/LICENSE-2.0.txt "The Apache 2.0 license" 24 | [package-badge-downloads]: https://img.shields.io/nuget/dt/MartinCostello.OpenApi.Extensions?logo=nuget&label=Downloads&color=blue 25 | [package-badge-version]: https://img.shields.io/nuget/v/MartinCostello.OpenApi.Extensions?logo=nuget&label=Latest&color=blue 26 | [package-download]: https://www.nuget.org/packages/MartinCostello.OpenApi.Extensions "Download MartinCostello.OpenApi.Extensions from NuGet" 27 | -------------------------------------------------------------------------------- /perf/OpenApi.Extensions.Benchmarks/MartinCostello.OpenApi.Extensions.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | $(NoWarn);SA1600 4 | Exe 5 | MartinCostello.OpenApi 6 | net9.0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /perf/OpenApi.Extensions.Benchmarks/OpenApiBenchmarks.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using BenchmarkDotNet.Attributes; 5 | using BenchmarkDotNet.Diagnosers; 6 | 7 | namespace MartinCostello.OpenApi; 8 | 9 | [EventPipeProfiler(EventPipeProfile.CpuSampling)] 10 | [MemoryDiagnoser] 11 | public class OpenApiBenchmarks : IAsyncDisposable 12 | { 13 | private TodoAppServer? _app = new(); 14 | private HttpClient? _client; 15 | private bool _disposed; 16 | 17 | [GlobalSetup] 18 | public async Task StartServer() 19 | { 20 | if (_app is { } app) 21 | { 22 | await app.StartAsync(); 23 | _client = app.CreateHttpClient(); 24 | } 25 | } 26 | 27 | [GlobalCleanup] 28 | public async Task StopServer() 29 | { 30 | if (_app is { } app) 31 | { 32 | await app.StopAsync(); 33 | _app = null; 34 | } 35 | } 36 | 37 | [Benchmark(Baseline = true)] 38 | public async Task GetOpenApiDocumentJson() 39 | => await _client!.GetStringAsync("/openapi/v1.json"); 40 | 41 | [Benchmark] 42 | public async Task GetOpenApiDocumentYaml() 43 | => await _client!.GetStringAsync("/openapi/v1.yaml"); 44 | 45 | public async ValueTask DisposeAsync() 46 | { 47 | GC.SuppressFinalize(this); 48 | 49 | if (!_disposed) 50 | { 51 | _client?.Dispose(); 52 | _client = null; 53 | 54 | if (_app is not null) 55 | { 56 | await _app.DisposeAsync(); 57 | _app = null; 58 | } 59 | } 60 | 61 | _disposed = true; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /perf/OpenApi.Extensions.Benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using BenchmarkDotNet.Running; 5 | using MartinCostello.OpenApi; 6 | 7 | if (args.SequenceEqual(["--test"])) 8 | { 9 | await using var benchmarks = new OpenApiBenchmarks(); 10 | await benchmarks.StartServer(); 11 | 12 | try 13 | { 14 | _ = await benchmarks.GetOpenApiDocumentJson(); 15 | _ = await benchmarks.GetOpenApiDocumentYaml(); 16 | } 17 | finally 18 | { 19 | await benchmarks.StopServer(); 20 | } 21 | } 22 | else 23 | { 24 | BenchmarkRunner.Run(args: args); 25 | } 26 | -------------------------------------------------------------------------------- /perf/OpenApi.Extensions.Benchmarks/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "MartinCostello.OpenApi.Extensions.Benchmarks": { 4 | "commandName": "Project", 5 | "commandLineArgs": "" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /perf/OpenApi.Extensions.Benchmarks/TodoAppServer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Hosting.Server; 7 | using Microsoft.AspNetCore.Hosting.Server.Features; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | using TodoApp; 11 | 12 | namespace MartinCostello.OpenApi; 13 | 14 | internal sealed class TodoAppServer : IAsyncDisposable 15 | { 16 | private WebApplication? _app; 17 | private Uri? _baseAddress; 18 | private bool _disposed; 19 | 20 | public TodoAppServer() 21 | { 22 | var builder = WebApplication.CreateBuilder([$"--contentRoot={GetContentRoot()}"]); 23 | 24 | builder.Logging.ClearProviders(); 25 | builder.WebHost.UseUrls("https://127.0.0.1:0"); 26 | 27 | builder.AddTodoApp(); 28 | 29 | _app = builder.Build(); 30 | _app.UseTodoApp(); 31 | } 32 | 33 | public HttpClient CreateHttpClient() 34 | { 35 | var handler = new HttpClientHandler 36 | { 37 | ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, 38 | }; 39 | 40 | #pragma warning disable CA5400 41 | return new(handler, disposeHandler: true) { BaseAddress = _baseAddress }; 42 | #pragma warning restore CA5400 43 | } 44 | 45 | public async Task StartAsync() 46 | { 47 | if (_app is { } app) 48 | { 49 | await app.StartAsync(); 50 | 51 | var server = app.Services.GetRequiredService(); 52 | var addresses = server.Features.Get(); 53 | 54 | _baseAddress = addresses!.Addresses 55 | .Select((p) => new Uri(p)) 56 | .Last(); 57 | } 58 | } 59 | 60 | public async Task StopAsync() 61 | { 62 | if (_app is { } app) 63 | { 64 | await app.StopAsync(); 65 | _app = null; 66 | } 67 | } 68 | 69 | public async ValueTask DisposeAsync() 70 | { 71 | GC.SuppressFinalize(this); 72 | 73 | if (!_disposed && _app is not null) 74 | { 75 | await _app.DisposeAsync(); 76 | } 77 | 78 | _disposed = true; 79 | } 80 | 81 | private static string GetContentRoot() 82 | { 83 | string contentRoot = string.Empty; 84 | var directoryInfo = new DirectoryInfo(Path.GetDirectoryName(typeof(OpenApiBenchmarks).Assembly.Location)!); 85 | 86 | do 87 | { 88 | string? solutionPath = Directory.EnumerateFiles(directoryInfo.FullName, "TodoApp.slnx").FirstOrDefault(); 89 | 90 | if (solutionPath is not null) 91 | { 92 | contentRoot = Path.GetFullPath(Path.Combine(directoryInfo.FullName, "samples", "TodoApp")); 93 | break; 94 | } 95 | 96 | directoryInfo = directoryInfo.Parent; 97 | } 98 | while (directoryInfo is not null); 99 | 100 | return contentRoot; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /samples/TodoApp/Data/ITodoRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Data; 5 | 6 | /// 7 | /// Represents a repository of Todo items. 8 | /// 9 | public interface ITodoRepository 10 | { 11 | /// 12 | /// Adds a new Todo item. 13 | /// 14 | /// The text of the Todo item. 15 | /// The to use. 16 | /// 17 | /// A representing the asynchronous operation that returns the ID of the new Todo item. 18 | /// 19 | Task AddItemAsync(string text, CancellationToken cancellationToken = default); 20 | 21 | /// 22 | /// Marks a Todo item as completed. 23 | /// 24 | /// The ID of the Todo item to complete. 25 | /// The to use. 26 | /// 27 | /// A representing the asynchronous operation that returns a value indicating whether the Todo item was completed. 28 | /// 29 | Task CompleteItemAsync(Guid itemId, CancellationToken cancellationToken = default); 30 | 31 | /// 32 | /// Deletes a Todo item. 33 | /// 34 | /// The ID of the Todo item to delete. 35 | /// The to use. 36 | /// 37 | /// A representing the asynchronous operation that returns a value indicating whether the Todo item was deleted. 38 | /// 39 | Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default); 40 | 41 | /// 42 | /// Gets a Todo item by its ID. 43 | /// 44 | /// The ID of the Todo item. 45 | /// The to use. 46 | /// 47 | /// A representing the asynchronous operation that returns the Todo item, if found; otherwise . 48 | /// 49 | Task GetItemAsync(Guid itemId, CancellationToken cancellationToken = default); 50 | 51 | /// 52 | /// Gets a list of Todo items. 53 | /// 54 | /// The to use. 55 | /// 56 | /// A representing the asynchronous operation that returns the list of Todo items. 57 | /// 58 | Task> GetItemsAsync(CancellationToken cancellationToken = default); 59 | 60 | /// 61 | /// Gets a list of Todo items that match the specified criteria. 62 | /// 63 | /// The prefix to search by. 64 | /// Whether to search completed items. 65 | /// The to use. 66 | /// 67 | /// A representing the asynchronous operation that returns the list of matching Todo items. 68 | /// 69 | Task> FindAsync(string prefix, bool isCompleted, CancellationToken cancellationToken = default); 70 | 71 | /// 72 | /// Gets a list of Todo items created after the specified date and time. 73 | /// 74 | /// The date and time to look for items created after. 75 | /// The to use. 76 | /// 77 | /// A representing the asynchronous operation that returns the list of matching Todo items. 78 | /// 79 | Task> GetAfterDateAsync(DateTime value, CancellationToken cancellationToken); 80 | } 81 | -------------------------------------------------------------------------------- /samples/TodoApp/Data/TodoContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace TodoApp.Data; 7 | 8 | /// 9 | /// Represents the data context for the Todo application. 10 | /// 11 | /// The data context options to use. 12 | public class TodoContext(DbContextOptions options) : DbContext(options) 13 | { 14 | /// 15 | /// Gets or sets the Todo items. 16 | /// 17 | public DbSet Items { get; set; } = default!; 18 | } 19 | -------------------------------------------------------------------------------- /samples/TodoApp/Data/TodoItem.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Data; 5 | 6 | /// 7 | /// A class representing a Todo item. 8 | /// 9 | public class TodoItem 10 | { 11 | /// 12 | /// Gets or sets the ID of the Todo item. 13 | /// 14 | public Guid Id { get; set; } 15 | 16 | /// 17 | /// Gets or sets the text of the Todo item. 18 | /// 19 | public string Text { get; set; } = default!; 20 | 21 | /// 22 | /// Gets or sets the date and time the Todo item was created. 23 | /// 24 | public DateTime CreatedAt { get; set; } 25 | 26 | /// 27 | /// Gets or sets the date and time the Todo item was completed, if any. 28 | /// 29 | public DateTime? CompletedAt { get; set; } 30 | } 31 | -------------------------------------------------------------------------------- /samples/TodoApp/Data/TodoRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace TodoApp.Data; 7 | 8 | /// 9 | /// Represents a repository of Todo items. 10 | /// 11 | /// The to use. 12 | /// The to use. 13 | public class TodoRepository(TimeProvider timeProvider, TodoContext context) : ITodoRepository 14 | { 15 | /// 16 | public async Task AddItemAsync(string text, CancellationToken cancellationToken = default) 17 | { 18 | await EnsureDatabaseAsync(cancellationToken); 19 | 20 | var item = new TodoItem 21 | { 22 | CreatedAt = UtcNow(), 23 | Text = text, 24 | }; 25 | 26 | context.Add(item); 27 | 28 | await context.SaveChangesAsync(cancellationToken); 29 | 30 | return item; 31 | } 32 | 33 | /// 34 | public async Task CompleteItemAsync(Guid itemId, CancellationToken cancellationToken = default) 35 | { 36 | var item = await GetItemAsync(itemId, cancellationToken); 37 | 38 | if (item is null) 39 | { 40 | return null; 41 | } 42 | 43 | if (item.CompletedAt.HasValue) 44 | { 45 | return false; 46 | } 47 | 48 | item.CompletedAt = UtcNow(); 49 | 50 | context.Items.Update(item); 51 | 52 | await context.SaveChangesAsync(cancellationToken); 53 | 54 | return true; 55 | } 56 | 57 | /// 58 | public async Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default) 59 | { 60 | var item = await GetItemAsync(itemId, cancellationToken); 61 | 62 | if (item is null) 63 | { 64 | return false; 65 | } 66 | 67 | context.Items.Remove(item); 68 | 69 | await context.SaveChangesAsync(cancellationToken); 70 | 71 | return true; 72 | } 73 | 74 | /// 75 | public async Task GetItemAsync(Guid itemId, CancellationToken cancellationToken = default) 76 | { 77 | await EnsureDatabaseAsync(cancellationToken); 78 | 79 | return await context.Items.FindItemAsync(itemId, cancellationToken); 80 | } 81 | 82 | /// 83 | public async Task> GetItemsAsync(CancellationToken cancellationToken = default) 84 | { 85 | await EnsureDatabaseAsync(cancellationToken); 86 | 87 | return await context.Items 88 | .OrderBy(x => x.CompletedAt.HasValue) 89 | .ThenBy(x => x.CreatedAt) 90 | .ToListAsync(cancellationToken); 91 | } 92 | 93 | /// 94 | public async Task> FindAsync( 95 | string prefix, 96 | bool isCompleted, 97 | CancellationToken cancellationToken = default) 98 | { 99 | await EnsureDatabaseAsync(cancellationToken); 100 | 101 | return await context.Items 102 | .Where(x => x.Text.StartsWith(prefix)) 103 | .Where(x => x.CompletedAt.HasValue == isCompleted) 104 | .ToListAsync(cancellationToken); 105 | } 106 | 107 | /// 108 | public async Task> GetAfterDateAsync(DateTime value, CancellationToken cancellationToken) 109 | { 110 | await EnsureDatabaseAsync(cancellationToken); 111 | 112 | return await context.Items 113 | .Where(x => x.CreatedAt > value) 114 | .ToListAsync(cancellationToken); 115 | } 116 | 117 | private async Task EnsureDatabaseAsync(CancellationToken cancellationToken) 118 | => await context.Database.EnsureCreatedAsync(cancellationToken); 119 | 120 | private DateTime UtcNow() => timeProvider.GetUtcNow().UtcDateTime; 121 | } 122 | -------------------------------------------------------------------------------- /samples/TodoApp/DateTimeExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp; 7 | 8 | /// 9 | /// A class representing an example provider for values. 10 | /// 11 | public sealed class DateTimeExampleProvider : IExampleProvider 12 | { 13 | /// 14 | public static DateTime GenerateExample() => new(2025, 03, 05, 16, 43, 44, DateTimeKind.Utc); 15 | } 16 | -------------------------------------------------------------------------------- /samples/TodoApp/Extensions/DbSetExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Microsoft.EntityFrameworkCore; 5 | 6 | /// 7 | /// A class containing extension ethods for . 8 | /// 9 | public static class DbSetExtensions 10 | { 11 | /// 12 | /// Finds the item with the specified ID. 13 | /// 14 | /// The type of the item. 15 | /// The type of the key. 16 | /// The set to search for the item in. 17 | /// The key's value. 18 | /// The to use. 19 | /// 20 | /// A representing the asynchronous operation to find the item. 21 | /// 22 | public static ValueTask FindItemAsync( 23 | this DbSet set, 24 | TKey keyValue, 25 | CancellationToken cancellationToken) 26 | where TEntity : class 27 | { 28 | ArgumentNullException.ThrowIfNull(keyValue); 29 | return set.FindAsync([keyValue], cancellationToken); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /samples/TodoApp/GuidExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp; 7 | 8 | /// 9 | /// A class representing an example provider for GUIDs. 10 | /// 11 | public sealed class GuidExampleProvider : IExampleProvider 12 | { 13 | private static readonly Guid Value = Guid.Parse("a03952ca-880e-4af7-9cfa-630be0feb4a5"); 14 | 15 | /// 16 | public static Guid GenerateExample() => Value; 17 | } 18 | -------------------------------------------------------------------------------- /samples/TodoApp/Models/CreateTodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp.Models; 7 | 8 | /// 9 | /// Represents the model for creating a new Todo item. 10 | /// 11 | [OpenApiExample] 12 | public class CreateTodoItemModel : IExampleProvider 13 | { 14 | /// 15 | /// Gets or sets the text of the Todo item. 16 | /// 17 | public string Text { get; set; } = string.Empty; 18 | 19 | /// 20 | public static CreateTodoItemModel GenerateExample() 21 | { 22 | return new() 23 | { 24 | Text = "Buy eggs 🥚", 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/TodoApp/Models/CreatedTodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp.Models; 7 | 8 | /// 9 | /// Represents the model for a created Todo item. This class cannot be inherited. 10 | /// 11 | [OpenApiExample] 12 | public class CreatedTodoItemModel : IExampleProvider 13 | { 14 | /// 15 | /// Gets or sets the ID of the created Todo item. 16 | /// 17 | public string Id { get; set; } = string.Empty; 18 | 19 | /// 20 | public static CreatedTodoItemModel GenerateExample() 21 | { 22 | return new() 23 | { 24 | Id = "a03952ca-880e-4af7-9cfa-630be0feb4a5", 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/TodoApp/Models/TodoItemFilterModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp.Models; 7 | 8 | /// 9 | /// Represents the model for searching for Todo items. 10 | /// 11 | [OpenApiExample] 12 | public sealed class TodoItemFilterModel : IExampleProvider 13 | { 14 | /// 15 | /// Gets or sets the text of the filter. 16 | /// 17 | public string Text { get; set; } = string.Empty; 18 | 19 | /// 20 | /// Gets or sets a value indicating whether to search completed Todo items. 21 | /// 22 | public bool IsCompleted { get; set; } 23 | 24 | /// 25 | public static TodoItemFilterModel GenerateExample() 26 | { 27 | return new() 28 | { 29 | IsCompleted = false, 30 | Text = "Buy eggs 🥚", 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /samples/TodoApp/Models/TodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp.Models; 7 | 8 | /// 9 | /// Represents a Todo item. 10 | /// 11 | [OpenApiExample] 12 | public class TodoItemModel : IExampleProvider 13 | { 14 | /// 15 | /// Gets or sets the ID of the Todo item. 16 | /// 17 | public string Id { get; set; } = default!; 18 | 19 | /// 20 | /// Gets or sets the text of the Todo item. 21 | /// 22 | public string Text { get; set; } = default!; 23 | 24 | /// 25 | /// Gets or sets a value indicating whether the Todo item has been completed. 26 | /// 27 | public bool IsCompleted { get; set; } 28 | 29 | /// 30 | /// Gets or sets the date and time the Todo item was last updated. 31 | /// 32 | public string LastUpdated { get; set; } = default!; 33 | 34 | /// 35 | public static TodoItemModel GenerateExample() 36 | { 37 | return new() 38 | { 39 | Id = "a03952ca-880e-4af7-9cfa-630be0feb4a5", 40 | IsCompleted = false, 41 | Text = "Buy eggs 🥚", 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /samples/TodoApp/Models/TodoListViewModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace TodoApp.Models; 7 | 8 | /// 9 | /// Represents a collection of Todo items. 10 | /// 11 | [OpenApiExample] 12 | public class TodoListViewModel : IExampleProvider 13 | { 14 | /// 15 | /// Gets or sets the Todo item(s). 16 | /// 17 | public ICollection Items { get; set; } = []; 18 | 19 | /// 20 | public static TodoListViewModel GenerateExample() 21 | { 22 | return new() 23 | { 24 | Items = [TodoItemModel.GenerateExample()], 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/TodoApp/ProblemDetailsExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace TodoApp; 8 | 9 | /// 10 | /// A class representing an example provider for . 11 | /// 12 | public class ProblemDetailsExampleProvider : IExampleProvider 13 | { 14 | /// 15 | public static ProblemDetails GenerateExample() 16 | { 17 | return new() 18 | { 19 | Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1", 20 | Title = "Bad Request", 21 | Status = StatusCodes.Status400BadRequest, 22 | Detail = "The specified value is invalid.", 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /samples/TodoApp/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using TodoApp; 5 | 6 | // Create the default web application builder 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | // Configure the Todo repository and associated services 10 | builder.AddTodoApp(); 11 | 12 | // Create the app 13 | var app = builder.Build(); 14 | 15 | // Use TodoApp middleware and endpoints with the web application 16 | app.UseTodoApp(); 17 | 18 | // Run the application 19 | app.Run(); 20 | 21 | namespace TodoApp 22 | { 23 | public partial class Program 24 | { 25 | // Expose the Program class for use with WebApplicationFactory 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/TodoApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "TodoApp": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "applicationUrl": "https://localhost:50001;http://localhost:50000", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /samples/TodoApp/Services/ITodoService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using TodoApp.Models; 5 | 6 | namespace TodoApp.Services; 7 | 8 | /// 9 | /// Represents a service for managing Todo items. 10 | /// 11 | public interface ITodoService 12 | { 13 | /// 14 | /// Adds a new Todo item. 15 | /// 16 | /// The text of the Todo item. 17 | /// The to use. 18 | /// 19 | /// A representing the asynchronous operation that returns the ID of the new Todo item. 20 | /// 21 | Task AddItemAsync(string text, CancellationToken cancellationToken); 22 | 23 | /// 24 | /// Marks a Todo item as completed. 25 | /// 26 | /// The ID of the Todo item to complete. 27 | /// The to use. 28 | /// 29 | /// A representing the asynchronous operation that returns a value indicating whether the Todo item was completed. 30 | /// 31 | Task CompleteItemAsync(Guid itemId, CancellationToken cancellationToken); 32 | 33 | /// 34 | /// Deletes a Todo item. 35 | /// 36 | /// The ID of the Todo item to delete. 37 | /// The to use. 38 | /// 39 | /// A representing the asynchronous operation that returns a value indicating whether the Todo item was deleted. 40 | /// 41 | Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken); 42 | 43 | /// 44 | /// Gets a Todo item by its ID. 45 | /// 46 | /// The ID of the Todo item. 47 | /// The to use. 48 | /// 49 | /// A representing the asynchronous operation that returns the Todo item, if found; otherwise . 50 | /// 51 | Task GetAsync(Guid itemId, CancellationToken cancellationToken); 52 | 53 | /// 54 | /// Gets a list of Todo items. 55 | /// 56 | /// The to use. 57 | /// 58 | /// A representing the asynchronous operation that returns the list of Todo items. 59 | /// 60 | Task GetListAsync(CancellationToken cancellationToken); 61 | 62 | /// 63 | /// Gets a list of Todo items that match the specified criteria. 64 | /// 65 | /// The to use to search. 66 | /// The to use. 67 | /// 68 | /// A representing the asynchronous operation that returns the list of matching Todo items. 69 | /// 70 | Task FindAsync(TodoItemFilterModel filter, CancellationToken cancellationToken); 71 | 72 | /// 73 | /// Gets a list of Todo items created after the specified date and time. 74 | /// 75 | /// The date and time to look for items created after. 76 | /// The to use. 77 | /// 78 | /// A representing the asynchronous operation that returns the list of matching Todo items. 79 | /// 80 | Task GetAfterDateAsync(DateTime value, CancellationToken cancellationToken); 81 | } 82 | -------------------------------------------------------------------------------- /samples/TodoApp/Services/TodoService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using TodoApp.Data; 5 | using TodoApp.Models; 6 | 7 | namespace TodoApp.Services; 8 | 9 | /// 10 | /// A class representing a service for managing Todo items. 11 | /// 12 | /// The to use. 13 | public class TodoService(ITodoRepository repository) : ITodoService 14 | { 15 | /// 16 | public async Task AddItemAsync(string text, CancellationToken cancellationToken) 17 | { 18 | var item = await repository.AddItemAsync(text, cancellationToken); 19 | return item.Id.ToString(); 20 | } 21 | 22 | /// 23 | public async Task CompleteItemAsync(Guid itemId, CancellationToken cancellationToken) 24 | { 25 | return await repository.CompleteItemAsync(itemId, cancellationToken); 26 | } 27 | 28 | /// 29 | public async Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken) 30 | { 31 | return await repository.DeleteItemAsync(itemId, cancellationToken); 32 | } 33 | 34 | /// 35 | public async Task GetAsync(Guid itemId, CancellationToken cancellationToken) 36 | { 37 | var item = await repository.GetItemAsync(itemId, cancellationToken); 38 | return item is null ? null : MapItem(item); 39 | } 40 | 41 | /// 42 | public async Task GetListAsync(CancellationToken cancellationToken) 43 | { 44 | var items = await repository.GetItemsAsync(cancellationToken); 45 | return MapItems(items); 46 | } 47 | 48 | /// 49 | public async Task FindAsync(TodoItemFilterModel filter, CancellationToken cancellationToken) 50 | { 51 | var items = await repository.FindAsync(filter.Text, filter.IsCompleted, cancellationToken); 52 | return MapItems(items); 53 | } 54 | 55 | /// 56 | public async Task GetAfterDateAsync(DateTime value, CancellationToken cancellationToken) 57 | { 58 | var items = await repository.GetAfterDateAsync(value, cancellationToken); 59 | return MapItems(items); 60 | } 61 | 62 | private static TodoListViewModel MapItems(IList items) 63 | { 64 | var result = new List(items.Count); 65 | 66 | foreach (var todo in items) 67 | { 68 | result.Add(MapItem(todo)); 69 | } 70 | 71 | return new() { Items = result }; 72 | } 73 | 74 | private static TodoItemModel MapItem(TodoItem item) 75 | { 76 | return new() 77 | { 78 | Id = item.Id.ToString(), 79 | IsCompleted = item.CompletedAt.HasValue, 80 | LastUpdated = (item.CompletedAt ?? item.CreatedAt).ToString("u", CultureInfo.InvariantCulture), 81 | Text = item.Text, 82 | }; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /samples/TodoApp/TodoApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | true 5 | false 6 | false 7 | $(NoWarn);CA1019;CA1050;CA1813;CS1591;IDE0130 8 | TodoApp 9 | net9.0 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /samples/TodoApp/TodoAppBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | using Microsoft.AspNetCore.HttpOverrides; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.AspNetCore.WebUtilities; 8 | 9 | namespace TodoApp; 10 | 11 | /// 12 | /// Extension methods for configuring the TodoApp application. 13 | /// 14 | public static class TodoAppBuilder 15 | { 16 | /// 17 | /// Adds TodoApp services to the specified . 18 | /// 19 | /// The to configure. 20 | /// 21 | /// The value passed by for chaining. 22 | /// 23 | public static WebApplicationBuilder AddTodoApp(this WebApplicationBuilder builder) 24 | { 25 | // Configure the Todo repository and associated services 26 | builder.Services.AddTodoApi(); 27 | 28 | // Configure OpenAPI documentation for the Todo API 29 | builder.Services.AddOpenApi(options => 30 | { 31 | options.AddDocumentTransformer((document, _, _) => 32 | { 33 | document.Info.Title = "Todo API"; 34 | document.Info.Description = "An API for managing Todo items."; 35 | document.Info.Version = "v1"; 36 | 37 | document.Info.Contact = new() 38 | { 39 | Name = "martincostello", 40 | Url = new("https://github.com/martincostello/openapi-extensions"), 41 | }; 42 | 43 | document.Info.License = new() 44 | { 45 | Name = "Apache 2.0", 46 | Url = new("https://www.apache.org/licenses/LICENSE-2.0"), 47 | }; 48 | 49 | return Task.CompletedTask; 50 | }); 51 | }); 52 | 53 | // Configure extensions for OpenAPI 54 | builder.Services.AddHttpContextAccessor(); 55 | builder.Services.AddOpenApiExtensions(options => 56 | { 57 | // Always return the server URLs in the OpenAPI document 58 | options.AddServerUrls = true; 59 | 60 | // Set a default URL to use for generation of the OpenAPI document using 61 | // https://www.nuget.org/packages/Microsoft.Extensions.ApiDescription.Server. 62 | options.DefaultServerUrl = "https://localhost:50001"; 63 | 64 | // Add examples for OpenAPI operations and components 65 | options.AddExamples = true; 66 | 67 | // Add JSON serialization context to use to serialize examples 68 | options.SerializationContexts.Add(TodoJsonSerializerContext.Default); 69 | 70 | // Add custom example providers for DateTimes, GUIDs and ProblemDetails 71 | options.AddExample(); 72 | options.AddExample(); 73 | options.AddExample(); 74 | 75 | // Configure XML comments for the schemas in the OpenAPI document 76 | options.AddXmlComments(); 77 | 78 | // Add a custom transformation for the descriptions in the OpenAPI document 79 | options.DescriptionTransformations.Add((p) => p.Replace(" This class cannot be inherited.", string.Empty, StringComparison.Ordinal)); 80 | }); 81 | 82 | if (!builder.Environment.IsDevelopment()) 83 | { 84 | builder.Services.AddProblemDetails(); 85 | builder.Services.Configure((options) => 86 | { 87 | options.CustomizeProblemDetails = (context) => 88 | { 89 | if (context.Exception is not null) 90 | { 91 | context.ProblemDetails.Detail = "An internal error occurred."; 92 | } 93 | 94 | context.ProblemDetails.Instance = context.HttpContext.Request.Path; 95 | context.ProblemDetails.Title = ReasonPhrases.GetReasonPhrase(context.ProblemDetails.Status ?? StatusCodes.Status500InternalServerError); 96 | }; 97 | }); 98 | } 99 | 100 | if (string.Equals(builder.Configuration["CODESPACES"], "true", StringComparison.OrdinalIgnoreCase)) 101 | { 102 | // When running in GitHub Codespaces, X-Forwarded-Host also needs to be set 103 | builder.Services.Configure( 104 | options => options.ForwardedHeaders |= ForwardedHeaders.XForwardedHost); 105 | } 106 | 107 | return builder; 108 | } 109 | 110 | /// 111 | /// Configures the specified to use TodoApp. 112 | /// 113 | /// The to configure. 114 | /// 115 | /// The value passed by for chaining. 116 | /// 117 | public static WebApplication UseTodoApp(this WebApplication app) 118 | { 119 | // Configure error handling 120 | app.UseStatusCodePagesWithReExecute("/error", "?id={0}"); 121 | 122 | // Require use of HTTPS in production 123 | if (!app.Environment.IsDevelopment()) 124 | { 125 | app.UseHsts(); 126 | app.UseHttpsRedirection(); 127 | } 128 | 129 | // Add static files for SwaggerUI 130 | app.UseStaticFiles(); 131 | 132 | // Add endpoints for OpenAPI 133 | app.MapOpenApi(); 134 | app.MapOpenApiYaml(); 135 | 136 | // Add the HTTP endpoints 137 | app.MapTodoApiRoutes(); 138 | 139 | return app; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /samples/TodoApp/TodoJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Nodes; 5 | using System.Text.Json.Serialization; 6 | using Microsoft.AspNetCore.Mvc; 7 | using TodoApp.Models; 8 | 9 | namespace TodoApp; 10 | 11 | /// 12 | /// A class that provides metadata for (de)serializing JSON for both the API endpoints and with OpenAPI. 13 | /// 14 | [JsonSerializable(typeof(CreateTodoItemModel))] 15 | [JsonSerializable(typeof(CreatedTodoItemModel))] 16 | [JsonSerializable(typeof(DateTime))] 17 | [JsonSerializable(typeof(Guid))] 18 | [JsonSerializable(typeof(JsonObject))] 19 | [JsonSerializable(typeof(ProblemDetails))] 20 | [JsonSerializable(typeof(TodoItemFilterModel))] 21 | [JsonSerializable(typeof(TodoItemModel))] 22 | [JsonSerializable(typeof(TodoListViewModel))] 23 | [JsonSourceGenerationOptions( 24 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, 25 | NumberHandling = JsonNumberHandling.Strict, 26 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, 27 | WriteIndented = true)] 28 | public sealed partial class TodoJsonSerializerContext : JsonSerializerContext; 29 | -------------------------------------------------------------------------------- /samples/TodoApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Warning", 6 | "Microsoft": "Warning", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/TodoApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*", 8 | "DataDirectory": "App_Data" 9 | } 10 | -------------------------------------------------------------------------------- /samples/TodoApp/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/openapi-extensions/ce1cc678ff86186a2ad58f910f3e183796449a21/samples/TodoApp/wwwroot/favicon.ico -------------------------------------------------------------------------------- /samples/TodoApp/wwwroot/swagger-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API Documentation - TodoApp 6 | 7 | 8 | 30 | 31 | 32 |
33 | 34 | 35 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/ExampleFormatter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// A class containing methods to help format JSON examples for OpenAPI. This class cannot be inherited. 8 | /// 9 | internal static partial class ExampleFormatter 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/ExampleFormatter.net9.0.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | #if NET9_0 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | using System.Text.Json; 8 | using System.Text.Json.Serialization; 9 | using Microsoft.OpenApi.Any; 10 | 11 | namespace MartinCostello.OpenApi; 12 | 13 | internal static partial class ExampleFormatter 14 | { 15 | /// 16 | /// Formats the specified value as JSON. 17 | /// 18 | /// The type of the value. 19 | /// The example value to format as JSON. 20 | /// The JSON serializer context to use. 21 | /// 22 | /// The to use as the example. 23 | /// 24 | /// 25 | /// is . 26 | /// 27 | public static IOpenApiAny AsJson(T example, JsonSerializerContext context) 28 | { 29 | ArgumentNullException.ThrowIfNull(context); 30 | 31 | string? json = JsonSerializer.Serialize(example, typeof(T), context); 32 | using var document = JsonDocument.Parse(json); 33 | 34 | return TryParse(document.RootElement, out var any) ? any : new OpenApiNull(); 35 | } 36 | 37 | private static bool TryParse(JsonElement token, [NotNullWhen(true)] out IOpenApiAny? any) 38 | { 39 | any = null; 40 | 41 | switch (token.ValueKind) 42 | { 43 | case JsonValueKind.Array: 44 | var array = new OpenApiArray(); 45 | 46 | foreach (var value in token.EnumerateArray()) 47 | { 48 | if (TryParse(value, out var child)) 49 | { 50 | array.Add(child); 51 | } 52 | } 53 | 54 | any = array; 55 | return true; 56 | 57 | case JsonValueKind.False: 58 | any = new OpenApiBoolean(false); 59 | return true; 60 | 61 | case JsonValueKind.True: 62 | any = new OpenApiBoolean(true); 63 | return true; 64 | 65 | case JsonValueKind.Number: 66 | any = new OpenApiDouble(token.GetDouble()); 67 | return true; 68 | 69 | case JsonValueKind.String: 70 | any = new OpenApiString(token.GetString()); 71 | return true; 72 | 73 | case JsonValueKind.Object: 74 | var obj = new OpenApiObject(); 75 | 76 | foreach (var child in token.EnumerateObject()) 77 | { 78 | if (TryParse(child.Value, out var value)) 79 | { 80 | obj[child.Name] = value; 81 | } 82 | } 83 | 84 | any = obj; 85 | return true; 86 | 87 | case JsonValueKind.Null: 88 | any = new OpenApiNull(); 89 | return true; 90 | 91 | case JsonValueKind.Undefined: 92 | default: 93 | return false; 94 | } 95 | } 96 | } 97 | #endif 98 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/ExampleTargets.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// A class that defines the valid attribute targets for example attributes. 8 | /// 9 | internal static class ExampleTargets 10 | { 11 | /// 12 | /// The valid attribute targets for example attributes. 13 | /// 14 | public const AttributeTargets ValidTargets = 15 | AttributeTargets.Class | 16 | AttributeTargets.Enum | 17 | AttributeTargets.Interface | 18 | AttributeTargets.Method | 19 | AttributeTargets.Parameter | 20 | AttributeTargets.ReturnValue | 21 | AttributeTargets.Struct; 22 | } 23 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/IExampleProvider`1.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// Defines a method for obtaining examples for use in OpenAPI documentation. 8 | /// 9 | /// The type of the example. 10 | public interface IExampleProvider 11 | { 12 | /// 13 | /// Generates the example to use. 14 | /// 15 | /// 16 | /// A that should be used as the example. 17 | /// 18 | static abstract T GenerateExample(); 19 | } 20 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/IOpenApiExampleMetadata.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// Defines metadata for an OpenAPI example. 8 | /// 9 | public partial interface IOpenApiExampleMetadata 10 | { 11 | /// 12 | /// Gets the type associated with the example. 13 | /// 14 | Type ExampleType { get; } 15 | } 16 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/IOpenApiExampleMetadata.net9.0.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | #if NET9_0 5 | 6 | using System.Text.Json.Serialization; 7 | using Microsoft.OpenApi.Any; 8 | 9 | namespace MartinCostello.OpenApi; 10 | 11 | public partial interface IOpenApiExampleMetadata 12 | { 13 | /// 14 | /// Generates an example object for the type associated with . 15 | /// 16 | /// The JSON serializer context to use. 17 | /// 18 | /// The example to use. 19 | /// 20 | IOpenApiAny GenerateExample(JsonSerializerContext context); 21 | } 22 | 23 | #endif 24 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/MartinCostello.OpenApi.Extensions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenAPI Extensions 4 | Extensions for ASP.NET Core's OpenAPI support. 5 | true 6 | true 7 | true 8 | $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated 9 | true 10 | true 11 | Library 12 | MartinCostello.OpenApi.Extensions 13 | MartinCostello.OpenApi 14 | $(Description) 15 | net9.0 16 | $(AssemblyTitle) 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiConstants.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | internal static class OpenApiConstants 7 | { 8 | // See https://github.com/dotnet/aspnetcore/blob/0fee04e2e1e507f0c993fa902d53abdd7c5dff65/src/OpenApi/src/Services/OpenApiConstants.cs#L10 9 | internal const string DefaultDocumentName = "v1"; 10 | 11 | // See https://github.com/dotnet/aspnetcore/blob/0fee04e2e1e507f0c993fa902d53abdd7c5dff65/src/OpenApi/src/Services/OpenApiConstants.cs#L12 12 | internal const string DefaultOpenApiRouteAsYaml = "/openapi/{documentName}.yaml"; 13 | 14 | // See https://github.com/dotnet/aspnetcore/blob/0fee04e2e1e507f0c993fa902d53abdd7c5dff65/src/OpenApi/src/Services/OpenApiConstants.cs#L13 15 | internal const string DescriptionId = "x-aspnetcore-id"; 16 | 17 | // See https://github.com/dotnet/aspnetcore/blob/0fee04e2e1e507f0c993fa902d53abdd7c5dff65/src/OpenApi/src/Services/OpenApiConstants.cs#L14 18 | internal const string SchemaId = "x-schema-id"; 19 | } 20 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiEndpointConventionBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | 6 | namespace MartinCostello.OpenApi; 7 | 8 | /// 9 | /// A class containing methods for adding routing metadata to endpoint 10 | /// instances for OpenAPI using . 11 | /// This class cannot be inherited. 12 | /// 13 | public static class OpenApiEndpointConventionBuilderExtensions 14 | { 15 | /// 16 | /// Adds OpenAPI response metadata to for all builders produced by builder. 17 | /// 18 | /// The type of the endpoint convention builder. 19 | /// The . 20 | /// The HTTP status code for the response. 21 | /// The description of the response. 22 | /// 23 | /// The current instance for chaining. 24 | /// 25 | /// 26 | /// is . 27 | /// 28 | public static TBuilder ProducesOpenApiResponse(this TBuilder builder, int statusCode, string description) 29 | where TBuilder : IEndpointConventionBuilder 30 | { 31 | ArgumentNullException.ThrowIfNull(builder); 32 | return builder.WithMetadata(new OpenApiResponseAttribute(statusCode, description)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiExampleAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// An attribute representing a string example for an OpenAPI document. This class cannot be inherited. 8 | /// 9 | /// The example's value. 10 | [AttributeUsage(ExampleTargets.ValidTargets, AllowMultiple = false)] 11 | public sealed class OpenApiExampleAttribute(string value) : OpenApiExampleAttribute, IExampleProvider 12 | { 13 | /// 14 | /// Gets the example value. 15 | /// 16 | public string Value { get; } = value; 17 | 18 | /// 19 | static string IExampleProvider.GenerateExample() => "value"; 20 | 21 | /// 22 | public override string GenerateExample() => Value; 23 | } 24 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiExampleAttribute`1.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | #pragma warning disable CA1813 7 | 8 | /// 9 | /// An attribute representing an example for an OpenAPI document. 10 | /// 11 | /// The type of the example. 12 | public class OpenApiExampleAttribute() : OpenApiExampleAttribute() 13 | where T : IExampleProvider; 14 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiExampleAttribute`2.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | #pragma warning disable CA1813 7 | 8 | /// 9 | /// An attribute representing an example for an OpenAPI document. 10 | /// 11 | /// The type of the schema. 12 | /// The type of the example provider. 13 | [AttributeUsage(ExampleTargets.ValidTargets, AllowMultiple = true, Inherited = true)] 14 | public partial class OpenApiExampleAttribute : Attribute, IOpenApiExampleMetadata 15 | where TProvider : IExampleProvider 16 | { 17 | /// 18 | public Type ExampleType { get; } = typeof(TSchema); 19 | 20 | /// 21 | /// Generates the example to use. 22 | /// 23 | /// 24 | /// A that should be used as the example. 25 | /// 26 | public virtual TSchema GenerateExample() => TProvider.GenerateExample(); 27 | } 28 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiExampleAttribute`2.net9.0.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | #if NET9_0 5 | 6 | using System.Text.Json.Serialization; 7 | using Microsoft.OpenApi.Any; 8 | 9 | namespace MartinCostello.OpenApi; 10 | 11 | public partial class OpenApiExampleAttribute : Attribute, IOpenApiExampleMetadata 12 | where TProvider : IExampleProvider 13 | { 14 | /// 15 | IOpenApiAny IOpenApiExampleMetadata.GenerateExample(JsonSerializerContext context) 16 | => ExampleFormatter.AsJson(GenerateExample(), context); 17 | } 18 | 19 | #endif 20 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/OpenApiResponseAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// Represents an OpenAPI operation response. This class cannot be inherited. 8 | /// 9 | /// The HTTP status code for the response. 10 | /// The description of the response. 11 | [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] 12 | public sealed class OpenApiResponseAttribute(int httpStatusCode, string description) : Attribute 13 | { 14 | /// 15 | /// Gets the HTTP status code for the response. 16 | /// 17 | public int HttpStatusCode { get; } = httpStatusCode; 18 | 19 | /// 20 | /// Gets the description of the response. 21 | /// 22 | public string Description { get; } = description; 23 | } 24 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/PublicAPI/PublicAPI.Shipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | MartinCostello.OpenApi.IExampleProvider 3 | MartinCostello.OpenApi.IExampleProvider.GenerateExample() -> T 4 | MartinCostello.OpenApi.IOpenApiExampleMetadata 5 | MartinCostello.OpenApi.IOpenApiExampleMetadata.ExampleType.get -> System.Type! 6 | MartinCostello.OpenApi.OpenApiEndpointConventionBuilderExtensions 7 | MartinCostello.OpenApi.OpenApiExampleAttribute 8 | MartinCostello.OpenApi.OpenApiExampleAttribute.OpenApiExampleAttribute(string! value) -> void 9 | MartinCostello.OpenApi.OpenApiExampleAttribute.Value.get -> string! 10 | MartinCostello.OpenApi.OpenApiExampleAttribute 11 | MartinCostello.OpenApi.OpenApiExampleAttribute.OpenApiExampleAttribute() -> void 12 | MartinCostello.OpenApi.OpenApiExampleAttribute 13 | MartinCostello.OpenApi.OpenApiExampleAttribute.ExampleType.get -> System.Type! 14 | MartinCostello.OpenApi.OpenApiExampleAttribute.OpenApiExampleAttribute() -> void 15 | MartinCostello.OpenApi.OpenApiExtensions 16 | MartinCostello.OpenApi.OpenApiExtensionsOptions 17 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddExample() -> MartinCostello.OpenApi.OpenApiExtensionsOptions! 18 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddExample() -> MartinCostello.OpenApi.OpenApiExtensionsOptions! 19 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddExamples.get -> bool 20 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddExamples.set -> void 21 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddServerUrls.get -> bool 22 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddServerUrls.set -> void 23 | MartinCostello.OpenApi.OpenApiExtensionsOptions.AddXmlComments() -> MartinCostello.OpenApi.OpenApiExtensionsOptions! 24 | MartinCostello.OpenApi.OpenApiExtensionsOptions.DefaultServerUrl.get -> string? 25 | MartinCostello.OpenApi.OpenApiExtensionsOptions.DefaultServerUrl.set -> void 26 | MartinCostello.OpenApi.OpenApiExtensionsOptions.DescriptionTransformations.get -> System.Collections.Generic.IList!>! 27 | MartinCostello.OpenApi.OpenApiExtensionsOptions.ExamplesMetadata.get -> System.Collections.Generic.ICollection! 28 | MartinCostello.OpenApi.OpenApiExtensionsOptions.OpenApiExtensionsOptions() -> void 29 | MartinCostello.OpenApi.OpenApiExtensionsOptions.SerializationContexts.get -> System.Collections.Generic.IList! 30 | MartinCostello.OpenApi.OpenApiExtensionsOptions.XmlDocumentationAssemblies.get -> System.Collections.Generic.IList! 31 | MartinCostello.OpenApi.OpenApiResponseAttribute 32 | MartinCostello.OpenApi.OpenApiResponseAttribute.Description.get -> string! 33 | MartinCostello.OpenApi.OpenApiResponseAttribute.HttpStatusCode.get -> int 34 | MartinCostello.OpenApi.OpenApiResponseAttribute.OpenApiResponseAttribute(int httpStatusCode, string! description) -> void 35 | override MartinCostello.OpenApi.OpenApiExampleAttribute.GenerateExample() -> string! 36 | static MartinCostello.OpenApi.OpenApiEndpointConventionBuilderExtensions.ProducesOpenApiResponse(this TBuilder builder, int statusCode, string! description) -> TBuilder 37 | static MartinCostello.OpenApi.OpenApiExtensions.AddOpenApiExtensions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 38 | static MartinCostello.OpenApi.OpenApiExtensions.AddOpenApiExtensions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 39 | static MartinCostello.OpenApi.OpenApiExtensions.AddOpenApiExtensions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 40 | static MartinCostello.OpenApi.OpenApiExtensions.AddOpenApiExtensions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 41 | static MartinCostello.OpenApi.OpenApiExtensions.AddOpenApiExtensions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 42 | static MartinCostello.OpenApi.OpenApiExtensions.AddOpenApiExtensions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 43 | virtual MartinCostello.OpenApi.OpenApiExampleAttribute.GenerateExample() -> TSchema 44 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/PublicAPI/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/PublicAPI/net9.0/PublicAPI.Shipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | MartinCostello.OpenApi.IOpenApiExampleMetadata.GenerateExample(System.Text.Json.Serialization.JsonSerializerContext! context) -> Microsoft.OpenApi.Any.IOpenApiAny! 3 | MartinCostello.OpenApi.OpenApiEndpointRouteBuilderExtensions 4 | static MartinCostello.OpenApi.OpenApiEndpointRouteBuilderExtensions.MapOpenApiYaml(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern = "/openapi/{documentName}.yaml") -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! 5 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/PublicAPI/net9.0/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/ReflectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Reflection; 5 | 6 | namespace MartinCostello.OpenApi; 7 | 8 | internal static class ReflectionExtensions 9 | { 10 | public static IEnumerable GetExampleMetadata(this MethodInfo method) 11 | => method.GetCustomAttributes() 12 | .OfType() 13 | .Concat(method.ReturnParameter.GetExampleMetadata()); 14 | 15 | public static IEnumerable GetExampleMetadata(this ParameterInfo parameter) 16 | => parameter.GetCustomAttributes().OfType(); 17 | 18 | public static IOpenApiExampleMetadata? GetExampleMetadata(this Type type) 19 | => type.GetCustomAttributes(inherit: true) 20 | .OfType() 21 | .FirstOrDefault((p) => p.ExampleType.IsAssignableFrom(type)); 22 | } 23 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/Transformers/AddOperationXmlDocumentationTransformer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Reflection; 5 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 6 | using Microsoft.AspNetCore.Mvc.Controllers; 7 | using Microsoft.AspNetCore.Mvc.Infrastructure; 8 | using Microsoft.AspNetCore.OpenApi; 9 | using Microsoft.OpenApi.Models; 10 | 11 | namespace MartinCostello.OpenApi.Transformers; 12 | 13 | /// 14 | /// A class that adds XML documentation for OpenAPI operations. This class cannot be inherited. 15 | /// 16 | /// The to use. 17 | internal sealed class AddOperationXmlDocumentationTransformer(XmlDescriptionService service) : IOpenApiOperationTransformer 18 | { 19 | private readonly XmlDescriptionService _service = service; 20 | 21 | /// 22 | public Task TransformAsync( 23 | OpenApiOperation operation, 24 | OpenApiOperationTransformerContext context, 25 | CancellationToken cancellationToken) 26 | { 27 | ApplyOperationDescription(operation, context); 28 | ApplyParametersDescription(operation, context); 29 | 30 | return Task.CompletedTask; 31 | } 32 | 33 | private static string? GetXmlMethodName(OpenApiOperationTransformerContext context) => 34 | GetMethodInfo(context.Description) is not { } methodInfo || 35 | XmlCommentsHelper.GetMemberName(methodInfo) is not { Length: > 0 } name ? null : name; 36 | 37 | private static MethodInfo? GetMethodInfo(ApiDescription description) 38 | { 39 | if (description.ActionDescriptor is ControllerActionDescriptor descriptor) 40 | { 41 | return descriptor.MethodInfo; 42 | } 43 | 44 | return description.ActionDescriptor.EndpointMetadata.OfType().FirstOrDefault(); 45 | } 46 | 47 | private static bool TryApplyParameterDescription( 48 | OpenApiOperation operation, 49 | ApiParameterDescription parameterDescription, 50 | string description) 51 | { 52 | var parameter = operation.Parameters?.FirstOrDefault((p) => p.Name == parameterDescription.Name); 53 | 54 | if (parameter is null) 55 | { 56 | return false; 57 | } 58 | 59 | parameter.Description ??= description; 60 | 61 | return true; 62 | } 63 | 64 | private void ApplyOperationDescription( 65 | OpenApiOperation operation, 66 | OpenApiOperationTransformerContext context) 67 | { 68 | if (GetXmlMethodName(context) is not { Length: > 0 } methodName) 69 | { 70 | return; 71 | } 72 | 73 | if (operation.Summary is null && 74 | _service.GetDescription(methodName) is { Length: > 0 } summary) 75 | { 76 | operation.Summary = summary; 77 | } 78 | 79 | if (operation.Description is null && 80 | _service.GetDescription(methodName, section: "remarks") is { Length: > 0 } remarks) 81 | { 82 | operation.Description = remarks; 83 | } 84 | } 85 | 86 | private void ApplyParametersDescription( 87 | OpenApiOperation operation, 88 | OpenApiOperationTransformerContext context) 89 | { 90 | if (operation.Parameters is null) 91 | { 92 | return; 93 | } 94 | 95 | foreach (var description in context.Description.ParameterDescriptions) 96 | { 97 | if (TryApplyModelParameterDescription(operation, description)) 98 | { 99 | continue; 100 | } 101 | 102 | TryApplyEndpointParameterDescription(operation, context, description); 103 | } 104 | } 105 | 106 | private bool TryApplyModelParameterDescription( 107 | OpenApiOperation operation, 108 | ApiParameterDescription description) 109 | { 110 | if (description.ParameterDescriptor is not IParameterInfoParameterDescriptor descriptor || 111 | XmlCommentsHelper.GetMemberName(descriptor.ParameterInfo.Member) is not { Length: > 0 } name || 112 | _service.GetDescription(name) is not { Length: > 0 } summary) 113 | { 114 | return false; 115 | } 116 | 117 | return TryApplyParameterDescription(operation, description, summary); 118 | } 119 | 120 | private bool TryApplyEndpointParameterDescription( 121 | OpenApiOperation operation, 122 | OpenApiOperationTransformerContext context, 123 | ApiParameterDescription description) 124 | { 125 | if (GetXmlMethodName(context) is { Length: > 0 } methodName && 126 | _service.GetDescription(methodName, description.Name) is { Length: > 0 } summary) 127 | { 128 | return TryApplyParameterDescription(operation, description, summary); 129 | } 130 | 131 | return false; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/Transformers/AddParameterDescriptionsTransformer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using System.ComponentModel; 6 | using System.Reflection; 7 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 8 | using Microsoft.AspNetCore.OpenApi; 9 | using Microsoft.OpenApi.Models; 10 | 11 | namespace MartinCostello.OpenApi.Transformers; 12 | 13 | /// 14 | /// A class that adds descriptions to OpenAPI operations. This class cannot be inherited. 15 | /// 16 | internal sealed class AddParameterDescriptionsTransformer : IOpenApiOperationTransformer 17 | { 18 | private readonly ConcurrentDictionary _methodParametersDescriptions = []; 19 | 20 | /// 21 | public Task TransformAsync( 22 | OpenApiOperation operation, 23 | OpenApiOperationTransformerContext context, 24 | CancellationToken cancellationToken) 25 | { 26 | if (operation.Parameters is { Count: > 0 }) 27 | { 28 | TryAddParameterDescriptions(operation.Parameters, context.Description); 29 | } 30 | 31 | return Task.CompletedTask; 32 | } 33 | 34 | private void TryAddParameterDescriptions( 35 | IList parameters, 36 | ApiDescription apiDescription) 37 | { 38 | var descriptions = GetMethodParameterDescriptions(apiDescription); 39 | 40 | if (descriptions is { Length: > 0 }) 41 | { 42 | foreach ((var argument, var description) in descriptions) 43 | { 44 | if (description is not null) 45 | { 46 | var parameter = parameters.FirstOrDefault((p) => p.Name == argument.Name); 47 | if (parameter is not null) 48 | { 49 | parameter.Description ??= description; 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | private ParameterDescription[] GetMethodParameterDescriptions(ApiDescription description) 57 | { 58 | var method = description.ActionDescriptor.EndpointMetadata.OfType().FirstOrDefault(); 59 | 60 | if (method is null) 61 | { 62 | return []; 63 | } 64 | 65 | return _methodParametersDescriptions.GetOrAdd(method, static (p) => 66 | { 67 | var parameters = p.GetParameters(); 68 | var descriptions = new ParameterDescription[parameters.Length]; 69 | 70 | for (int i = 0; i < parameters.Length; i++) 71 | { 72 | var parameter = parameters[i]; 73 | var description = p.GetCustomAttributes().FirstOrDefault()?.Description; 74 | 75 | descriptions[i] = new(parameter, description); 76 | } 77 | 78 | return descriptions; 79 | }); 80 | } 81 | 82 | private sealed record ParameterDescription(ParameterInfo Parameter, string? Description); 83 | } 84 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/Transformers/AddResponseDescriptionsTransformer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.OpenApi; 5 | using Microsoft.OpenApi.Models; 6 | 7 | namespace MartinCostello.OpenApi.Transformers; 8 | 9 | /// 10 | /// A class that adds response descriptions to OpenAPI operations. This class cannot be inherited. 11 | /// 12 | internal sealed class AddResponseDescriptionsTransformer : IOpenApiOperationTransformer 13 | { 14 | /// 15 | public Task TransformAsync( 16 | OpenApiOperation operation, 17 | OpenApiOperationTransformerContext context, 18 | CancellationToken cancellationToken) 19 | { 20 | foreach (var attribute in context.Description.ActionDescriptor.EndpointMetadata.OfType()) 21 | { 22 | if (operation.Responses is not null && 23 | operation.Responses.TryGetValue(attribute.HttpStatusCode.ToString(CultureInfo.InvariantCulture), out var response)) 24 | { 25 | response.Description = attribute.Description; 26 | } 27 | } 28 | 29 | return Task.CompletedTask; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/Transformers/AddSchemaXmlDocumentationTransformer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Reflection; 5 | using System.Text.Json.Serialization.Metadata; 6 | using Microsoft.AspNetCore.OpenApi; 7 | using Microsoft.OpenApi.Models; 8 | 9 | namespace MartinCostello.OpenApi.Transformers; 10 | 11 | /// 12 | /// A class that adds XML documentation to OpenAPI schemas. This class cannot be inherited. 13 | /// 14 | /// The assembly to add XML documentation to the types of. 15 | /// The to use. 16 | internal sealed class AddSchemaXmlDocumentationTransformer(Assembly assembly, XmlDescriptionService service) 17 | : IOpenApiSchemaTransformer 18 | { 19 | private readonly Assembly _assembly = assembly; 20 | private readonly XmlDescriptionService _service = service; 21 | 22 | /// 23 | public Task TransformAsync( 24 | OpenApiSchema schema, 25 | OpenApiSchemaTransformerContext context, 26 | CancellationToken cancellationToken) 27 | { 28 | if (schema.Description is null && 29 | GetMemberName(context.JsonTypeInfo, context.JsonPropertyInfo) is { Length: > 0 } memberName && 30 | _service.GetDescription(memberName) is { Length: > 0 } description) 31 | { 32 | schema.Description = description; 33 | } 34 | 35 | return Task.CompletedTask; 36 | } 37 | 38 | private string? GetMemberName(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo) 39 | { 40 | if (typeInfo.Type.Assembly != _assembly && 41 | propertyInfo?.DeclaringType.Assembly != _assembly) 42 | { 43 | return null; 44 | } 45 | else if (propertyInfo is not null) 46 | { 47 | var typeName = propertyInfo.DeclaringType.FullName; 48 | var memberName = 49 | propertyInfo.AttributeProvider is MemberInfo member ? 50 | member.Name : 51 | $"{char.ToUpperInvariant(propertyInfo.Name[0])}{propertyInfo.Name[1..]}"; 52 | 53 | var memberType = propertyInfo.AttributeProvider is PropertyInfo ? "P" : "F"; 54 | 55 | return $"{memberType}:{typeName}{Type.Delimiter}{memberName}"; 56 | } 57 | else 58 | { 59 | return $"T:{typeInfo.Type.FullName}"; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/Transformers/AddServersTransformer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.OpenApi; 7 | using Microsoft.Extensions.Options; 8 | using Microsoft.OpenApi.Models; 9 | 10 | namespace MartinCostello.OpenApi.Transformers; 11 | 12 | /// 13 | /// A class that server information to an OpenAPI document. This class cannot be inherited. 14 | /// 15 | /// The configured . 16 | /// The to use, if available. 17 | /// The configured , if any. 18 | internal sealed class AddServersTransformer( 19 | IOptions extensionsOptions, 20 | IHttpContextAccessor? accessor, 21 | IOptions? forwardedHeadersOptions) : IOpenApiDocumentTransformer 22 | { 23 | /// 24 | public Task TransformAsync( 25 | OpenApiDocument document, 26 | OpenApiDocumentTransformerContext context, 27 | CancellationToken cancellationToken) 28 | { 29 | if (GetServerUrl() is { } url) 30 | { 31 | document.Servers = [new() { Url = url }]; 32 | } 33 | 34 | return Task.CompletedTask; 35 | } 36 | 37 | private string? GetServerUrl() 38 | { 39 | if (!extensionsOptions.Value.AddServerUrls) 40 | { 41 | return null; 42 | } 43 | 44 | if (accessor?.HttpContext?.Request is not { } request) 45 | { 46 | return extensionsOptions.Value.DefaultServerUrl; 47 | } 48 | 49 | if (forwardedHeadersOptions?.Value is not { } options) 50 | { 51 | return null; 52 | } 53 | 54 | var scheme = TryGetFirstHeader(options.ForwardedProtoHeaderName) ?? request.Scheme; 55 | var host = TryGetFirstHeader(options.ForwardedHostHeaderName) ?? request.Host.ToString(); 56 | 57 | return new Uri($"{scheme}://{host}").ToString().TrimEnd('/'); 58 | 59 | string? TryGetFirstHeader(string name) 60 | => request.Headers.TryGetValue(name, out var values) ? values.FirstOrDefault() : null; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/Transformers/DescriptionsTransformer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.OpenApi; 5 | using Microsoft.OpenApi.Models; 6 | 7 | namespace MartinCostello.OpenApi.Transformers; 8 | 9 | /// 10 | /// A class representing an operation and schema description transformer. This class cannot be inherited. 11 | /// 12 | /// A delegate to a method to use to transform description strings. 13 | internal sealed class DescriptionsTransformer(Func transformer) : 14 | IOpenApiOperationTransformer, 15 | IOpenApiSchemaTransformer 16 | { 17 | /// 18 | public Task TransformAsync( 19 | OpenApiOperation operation, 20 | OpenApiOperationTransformerContext context, 21 | CancellationToken cancellationToken) 22 | { 23 | ApplyResponseDescriptions(operation); 24 | ApplyParametersDescriptions(operation); 25 | 26 | return Task.CompletedTask; 27 | } 28 | 29 | /// 30 | public Task TransformAsync( 31 | OpenApiSchema schema, 32 | OpenApiSchemaTransformerContext context, 33 | CancellationToken cancellationToken) 34 | { 35 | if (schema.Description is { } schemaDescription) 36 | { 37 | schema.Description = transformer(schemaDescription); 38 | } 39 | 40 | if (schema.Properties is { } properties) 41 | { 42 | foreach (var property in properties.Values) 43 | { 44 | if (property.Description is { } propertyDescription) 45 | { 46 | property.Description = transformer(propertyDescription); 47 | } 48 | } 49 | } 50 | 51 | return Task.CompletedTask; 52 | } 53 | 54 | internal static string RemoveBackticks(string description) 55 | => description.Replace("`", string.Empty, StringComparison.Ordinal); 56 | 57 | internal static string RemoveStyleCopPrefixes(string description) 58 | { 59 | string[] prefixes = 60 | [ 61 | "Gets or sets a value indicating ", 62 | "Gets a value indicating ", 63 | "Gets or sets ", 64 | "Gets ", 65 | ]; 66 | 67 | foreach (var prefix in prefixes) 68 | { 69 | description = description.Replace(prefix, string.Empty, StringComparison.Ordinal); 70 | } 71 | 72 | description = char.ToUpperInvariant(description[0]) + description[1..]; 73 | 74 | return description; 75 | } 76 | 77 | private void ApplyResponseDescriptions(OpenApiOperation operation) 78 | { 79 | if (operation.Responses is { } responses) 80 | { 81 | foreach (var response in responses.Values) 82 | { 83 | if (response.Content is not { } content) 84 | { 85 | continue; 86 | } 87 | 88 | foreach (var model in content.Values) 89 | { 90 | if (model.Schema?.Properties is { } properties) 91 | { 92 | foreach (var property in properties.Values) 93 | { 94 | if (property.Description is { } description) 95 | { 96 | property.Description = transformer(description); 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | private void ApplyParametersDescriptions(OpenApiOperation operation) 106 | { 107 | if (operation.Parameters is { } parameters) 108 | { 109 | foreach (var parameter in parameters) 110 | { 111 | if (parameter.Description is { } description) 112 | { 113 | parameter.Description = transformer(description); 114 | } 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/XmlCommentsHelper.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics; 5 | using System.Reflection; 6 | 7 | namespace MartinCostello.OpenApi; 8 | 9 | internal static class XmlCommentsHelper 10 | { 11 | public static string? GetMemberName(MethodInfo method) 12 | { 13 | if (method.DeclaringType is null) 14 | { 15 | return null; 16 | } 17 | 18 | var builder = new StringBuilder() 19 | .Append("M:") 20 | .Append(QualifiedNameFor(method.DeclaringType)) 21 | .Append('.') 22 | .Append(method.Name); 23 | 24 | if (method.GetGenericArguments() is { Length: > 0 } typeParameters) 25 | { 26 | builder.Append("``") 27 | .Append(typeParameters.Length); 28 | } 29 | 30 | if (method.GetParameters() is { Length: > 0 } parameters) 31 | { 32 | string?[] names = [.. parameters.Select(GetName)]; 33 | 34 | if (names.Any((p) => p is null)) 35 | { 36 | return null; 37 | } 38 | 39 | builder.Append('(') 40 | .Append(string.Join(',', names)) 41 | .Append(')'); 42 | } 43 | 44 | return builder.ToString(); 45 | 46 | static string? GetName(ParameterInfo parameter) 47 | => GetNameForType(parameter.ParameterType); 48 | } 49 | 50 | public static string? GetMemberName(MemberInfo member) 51 | { 52 | if (member.DeclaringType is null) 53 | { 54 | return null; 55 | } 56 | 57 | var builder = new StringBuilder() 58 | .Append((member.MemberType & MemberTypes.Field) != 0 ? 'F' : 'P') 59 | .Append(':') 60 | .Append(QualifiedNameFor(member.DeclaringType)) 61 | .Append('.') 62 | .Append(member.Name); 63 | 64 | return builder.ToString(); 65 | } 66 | 67 | private static string? QualifiedNameFor(Type type, bool expandGenericArguments = false) 68 | { 69 | if (type.IsArray) 70 | { 71 | var elementType = type.GetElementType(); 72 | 73 | if (elementType is null) 74 | { 75 | return null; 76 | } 77 | 78 | return GetNameForType(elementType) + "[]"; 79 | } 80 | 81 | var builder = new StringBuilder(); 82 | 83 | if (!string.IsNullOrEmpty(type.Namespace)) 84 | { 85 | builder.Append(type.Namespace) 86 | .Append('.'); 87 | } 88 | 89 | if (type.IsNested) 90 | { 91 | builder.Append(string.Join('.', GetNestedTypeNames(type))) 92 | .Append('.'); 93 | } 94 | 95 | if (type.IsConstructedGenericType && expandGenericArguments) 96 | { 97 | int index = type.Name.IndexOf('`', StringComparison.Ordinal); 98 | 99 | Debug.Assert(index is not -1, "Backtick was not found in generic type name."); 100 | 101 | builder.Append(type.Name.AsSpan(..index)); 102 | 103 | var argumentNames = type 104 | .GetGenericArguments() 105 | .Select(GetNameForType); 106 | 107 | builder.Append('{') 108 | .Append(string.Join(',', argumentNames)) 109 | .Append('}'); 110 | } 111 | else 112 | { 113 | builder.Append(type.Name); 114 | } 115 | 116 | return builder.ToString(); 117 | } 118 | 119 | private static IEnumerable GetNestedTypeNames(Type type) 120 | { 121 | if (!type.IsNested || type.DeclaringType is null) 122 | { 123 | yield break; 124 | } 125 | 126 | foreach (var name in GetNestedTypeNames(type.DeclaringType)) 127 | { 128 | yield return name; 129 | } 130 | 131 | yield return type.DeclaringType.Name; 132 | } 133 | 134 | private static string? GetNameForType(Type type) 135 | { 136 | return type.IsGenericParameter ? 137 | FormattableString.Invariant($"``{type.GenericParameterPosition}") : 138 | QualifiedNameFor(type, expandGenericArguments: true); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/OpenApi.Extensions/XmlDescriptionService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using System.Reflection; 6 | using System.Xml; 7 | using System.Xml.XPath; 8 | 9 | namespace MartinCostello.OpenApi; 10 | 11 | internal sealed class XmlDescriptionService(Assembly assembly) 12 | { 13 | private readonly Assembly _assembly = assembly; 14 | private readonly ConcurrentDictionary _descriptions = []; 15 | private XPathNavigator? _navigator; 16 | 17 | public string? GetDescription(string memberName, string? parameterName = null, string? section = "summary") 18 | { 19 | var cacheKey = 20 | memberName + 21 | (!string.IsNullOrEmpty(parameterName) ? $"/{parameterName}" : $"/{section}"); 22 | 23 | if (_descriptions.TryGetValue(cacheKey, out var description)) 24 | { 25 | return description; 26 | } 27 | 28 | var navigator = CreateNavigator(); 29 | 30 | var xmlPath = 31 | !string.IsNullOrEmpty(parameterName) ? 32 | $"/doc/members/member[@name='{memberName}']/param[@name='{parameterName}']" : 33 | $"/doc/members/member[@name='{memberName}']/{section}"; 34 | 35 | if (navigator.SelectSingleNode(xmlPath) is { Value.Length: > 0 } node) 36 | { 37 | description = node.Value.Trim(); 38 | } 39 | 40 | _descriptions[cacheKey] = description; 41 | 42 | return description; 43 | } 44 | 45 | private XPathNavigator CreateNavigator() 46 | { 47 | if (_navigator is null) 48 | { 49 | var path = Path.Combine(AppContext.BaseDirectory, $"{_assembly.GetName().Name}.xml"); 50 | using var reader = XmlReader.Create(path); 51 | _navigator = new XPathDocument(reader).CreateNavigator(); 52 | } 53 | 54 | return _navigator; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /startvs.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | :: This command launches a Visual Studio solution with environment variables required to use a local version of the .NET SDK. 5 | 6 | :: This tells .NET to use the same dotnet.exe that the build script uses. 7 | SET DOTNET_ROOT=%~dp0.dotnet 8 | SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 9 | 10 | :: Put our local dotnet.exe on PATH first so Visual Studio knows which one to use. 11 | SET PATH=%DOTNET_ROOT%;%PATH% 12 | 13 | SET sln=%~dp0OpenApi.Extensions.slnx 14 | 15 | IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( 16 | echo The .NET SDK has not yet been installed. Run `%~dp0build.ps1` to install it 17 | exit /b 1 18 | ) 19 | 20 | IF "%VSINSTALLDIR%" == "" ( 21 | start "" "%sln%" 22 | ) else ( 23 | "%VSINSTALLDIR%\Common7\IDE\devenv.com" "%sln%" 24 | ) 25 | -------------------------------------------------------------------------------- /startvscode.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | :: This command launches Visual Studio Code with environment variables required to use a local version of the .NET SDK. 5 | 6 | :: This tells .NET to use the same dotnet.exe that the build script uses. 7 | SET DOTNET_ROOT=%~dp0.dotnet 8 | SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 9 | 10 | :: Put our local dotnet.exe on PATH first so Visual Studio Code knows which one to use. 11 | SET PATH=%DOTNET_ROOT%;%PATH% 12 | 13 | :: Sets the Target Framework for Visual Studio Code. 14 | SET TARGET=net9.0 15 | 16 | SET FOLDER=%~1 17 | 18 | IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( 19 | echo The .NET SDK has not yet been installed. Run `%~dp0build.ps1` to install it 20 | exit /b 1 21 | ) 22 | 23 | IF "%FOLDER%"=="" ( 24 | code . 25 | ) else ( 26 | code "%FOLDER%" 27 | ) 28 | 29 | exit /b 1 30 | -------------------------------------------------------------------------------- /stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "documentationRules": { 5 | "companyName": "https://github.com/martincostello/openapi-extensions", 6 | "copyrightText": "Copyright (c) {ownerName}, {year}. All rights reserved.\nLicensed under the {licenseName} license. See the {licenseFile} file in the project root for full license information.", 7 | "documentExposedElements": true, 8 | "documentInterfaces": true, 9 | "documentInternalElements": false, 10 | "documentPrivateElements": false, 11 | "documentPrivateFields": false, 12 | "fileNamingConvention": "metadata", 13 | "xmlHeader": false, 14 | "variables": { 15 | "licenseFile": "LICENSE", 16 | "licenseName": "Apache 2.0", 17 | "ownerName": "Martin Costello", 18 | "year": "2024" 19 | } 20 | }, 21 | "layoutRules": { 22 | "newlineAtEndOfFile": "require" 23 | }, 24 | "maintainabilityRules": { 25 | }, 26 | "namingRules": { 27 | "allowCommonHungarianPrefixes": true, 28 | "allowedHungarianPrefixes": [ 29 | ] 30 | }, 31 | "orderingRules": { 32 | "elementOrder": [ 33 | "kind", 34 | "accessibility", 35 | "constant", 36 | "static", 37 | "readonly" 38 | ], 39 | "systemUsingDirectivesFirst": true, 40 | "usingDirectivesPlacement": "outsideNamespace" 41 | }, 42 | "readabilityRules": { 43 | }, 44 | "spacingRules": { 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Models.A/Animal.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.A; 5 | 6 | /// 7 | /// A class representing an animal. 8 | /// 9 | public class Animal : IAnimal 10 | { 11 | /// 12 | /// Gets or sets the name of the animal. 13 | /// 14 | public string? Name { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Models.A/AnimalExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace Models.A; 7 | 8 | /// 9 | /// A class representing an example provider for instances of and . This class cannot be inherited. 10 | /// 11 | public sealed class AnimalExampleProvider : IExampleProvider, IExampleProvider 12 | { 13 | /// 14 | static Animal IExampleProvider.GenerateExample() => new() 15 | { 16 | Name = "Donald", 17 | }; 18 | 19 | /// 20 | static IAnimal IExampleProvider.GenerateExample() => new Animal() 21 | { 22 | Name = "Daisy", 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /tests/Models.A/AnimalsJsonSerializationContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace Models.A; 7 | 8 | /// 9 | /// The to use for animals. 10 | /// 11 | [JsonSerializable(typeof(Animal))] 12 | [JsonSerializable(typeof(Animal[]))] 13 | [JsonSerializable(typeof(Cat))] 14 | [JsonSerializable(typeof(Dog))] 15 | [JsonSerializable(typeof(IAnimal))] 16 | [JsonSerializable(typeof(Spot))] 17 | [JsonSerializable(typeof(Tom))] 18 | [JsonSourceGenerationOptions(NumberHandling = JsonNumberHandling.Strict)] 19 | public sealed partial class AnimalsJsonSerializationContext : JsonSerializerContext; 20 | -------------------------------------------------------------------------------- /tests/Models.A/Cat.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.A; 5 | 6 | /// 7 | /// A class representing a cat. 8 | /// 9 | public class Cat : Animal 10 | { 11 | /// 12 | /// Gets or sets the color of the cat. 13 | /// 14 | public string? Color { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Models.A/CatExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace Models.A; 7 | 8 | /// 9 | /// A class representing an example provider for instances of . This class cannot be inherited. 10 | /// 11 | public sealed class CatExampleProvider : IExampleProvider 12 | { 13 | /// 14 | public static Cat GenerateExample() => new() 15 | { 16 | Color = "Black", 17 | Name = "Whiskers", 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /tests/Models.A/Dog.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | using MartinCostello.OpenApi; 6 | 7 | namespace Models.A; 8 | 9 | /// 10 | /// A class representing a dog. Secret. 11 | /// 12 | public class Dog : Animal, IExampleProvider 13 | { 14 | /// 15 | /// Gets or sets the breed of the dog. 16 | /// 17 | public string? Breed { get; set; } 18 | 19 | /// 20 | /// Gets the age of the dog, if known. 21 | /// 22 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] 23 | public int? Age { get; init; } 24 | 25 | /// 26 | public static Dog GenerateExample() => new() 27 | { 28 | Breed = "Greyhound", 29 | Name = "Santa's Little Helper", 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /tests/Models.A/DogExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace Models.A; 7 | 8 | /// 9 | /// A class representing an example provider for instances of . This class cannot be inherited. 10 | /// 11 | public sealed class DogExampleProvider : IExampleProvider 12 | { 13 | /// 14 | public static Dog GenerateExample() => new() 15 | { 16 | Breed = "Golden Retriever", 17 | Name = "Fido", 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /tests/Models.A/IAnimal.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.OpenApi; 5 | 6 | namespace Models.A; 7 | 8 | /// 9 | /// Represents an animal. 10 | /// 11 | [OpenApiExample] 12 | public interface IAnimal 13 | { 14 | /// 15 | /// Gets or sets the name of the animal. 16 | /// 17 | string? Name { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Models.A/Models.A.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test models for MartinCostello.OpenApi.Extensions. 4 | true 5 | true 6 | false 7 | Library 8 | Models.A 9 | net9.0 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/Models.A/Spot.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.A; 5 | 6 | /// 7 | /// A class representing Spot the dog. 8 | /// 9 | public sealed class Spot : Dog; 10 | -------------------------------------------------------------------------------- /tests/Models.A/Tom.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.A; 5 | 6 | /// 7 | /// A class representing Tom the cat. 8 | /// 9 | public sealed class Tom : Cat; 10 | -------------------------------------------------------------------------------- /tests/Models.B/Car.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.B; 5 | 6 | /// 7 | /// Represents a car. 8 | /// 9 | /// The type of the car. 10 | /// The number of wheels the vehicle has. 11 | /// The name of the manufacturer. 12 | public record Car(CarType Type, int Wheels, string Manufacturer) : Vehicle(Wheels, Manufacturer); 13 | -------------------------------------------------------------------------------- /tests/Models.B/CarType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.B; 5 | 6 | /// 7 | /// Represents a car type. 8 | /// 9 | public enum CarType 10 | { 11 | /// 12 | /// A hatchback. 13 | /// 14 | Hatchback, 15 | 16 | /// 17 | /// A saloon. 18 | /// 19 | Saloon, 20 | 21 | /// 22 | /// An estate. 23 | /// 24 | Estate, 25 | 26 | /// 27 | /// A convertible. 28 | /// 29 | Convertible, 30 | 31 | /// 32 | /// A coupe. 33 | /// 34 | Coupe, 35 | } 36 | -------------------------------------------------------------------------------- /tests/Models.B/Models.B.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test models for MartinCostello.OpenApi.Extensions. 4 | true 5 | true 6 | false 7 | Library 8 | Models.B 9 | net9.0 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/Models.B/Motorcycle.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.B; 5 | 6 | /// 7 | /// Represents a motorcycle. 8 | /// 9 | /// The name of the manufacturer. 10 | /// Whether the motorcycle has a sidecar. 11 | public record Motorcycle(string Manufacturer, bool HasSidecar = false) : Vehicle(2, Manufacturer); 12 | -------------------------------------------------------------------------------- /tests/Models.B/Vehicle.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Models.B; 5 | 6 | /// 7 | /// Represents a vehicle. 8 | /// 9 | /// The number of wheels the vehicle has. 10 | /// The name of the manufacturer. 11 | public record Vehicle(int Wheels, string Manufacturer); 12 | -------------------------------------------------------------------------------- /tests/Models.B/VehiclesJsonSerializationContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace Models.B; 7 | 8 | /// 9 | /// The to use for vehicles. 10 | /// 11 | [JsonSerializable(typeof(Car))] 12 | [JsonSerializable(typeof(Motorcycle))] 13 | [JsonSerializable(typeof(Vehicle))] 14 | [JsonSerializable(typeof(Vehicle[]))] 15 | [JsonSourceGenerationOptions(NumberHandling = JsonNumberHandling.Strict)] 16 | public sealed partial class VehiclesJsonSerializationContext : JsonSerializerContext; 17 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/AssemblyTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | public static class AssemblyTests 7 | { 8 | [Fact] 9 | public static void Library_Is_Strong_Named() 10 | { 11 | // Arrange 12 | var assembly = typeof(IExampleProvider<>).Assembly; 13 | 14 | // Act 15 | var name = assembly.GetName(); 16 | var actual = name.GetPublicKey(); 17 | 18 | // Assert 19 | actual.ShouldNotBeNull(); 20 | actual.ShouldNotBeEmpty(); 21 | Convert.ToHexStringLower(actual).ShouldBe("00240000048000009400000006020000002400005253413100040000010001004b0b2efbada897147aa03d2076278890aefe2f8023562336d206ec8a719b06e89461c31b43abec615918d509158629f93385930c030494509e418bf396d69ce7dbe0b5b2db1a81543ab42777cb98210677fed69dbeb3237492a7ad69e87a1911ed20eb2d7c300238dc6f6403e3d04a1351c5cb369de4e022b18fbec70f7d21ed"); 22 | 23 | actual = name.GetPublicKeyToken(); 24 | actual.ShouldNotBeNull(); 25 | actual.ShouldNotBeEmpty(); 26 | Convert.ToHexStringLower(actual).ShouldBe("9a192a7522c9e1a0"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ControllerTests.Schema_Is_Correct_For_Web_Api.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | openapi: 3.0.4, 3 | info: { 4 | title: WebApi | v1, 5 | version: 1.0.0 6 | }, 7 | paths: { 8 | /api/Time: { 9 | get: { 10 | tags: [ 11 | Time 12 | ], 13 | summary: Gets the current date and time., 14 | description: The current date and time is returned in Coordinated Universal Time (UTC)., 15 | responses: { 16 | 200: { 17 | description: OK, 18 | content: { 19 | text/plain: { 20 | schema: { 21 | $ref: #/components/schemas/TimeModel 22 | } 23 | }, 24 | application/json: { 25 | schema: { 26 | $ref: #/components/schemas/TimeModel 27 | } 28 | }, 29 | text/json: { 30 | schema: { 31 | $ref: #/components/schemas/TimeModel 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | }, 40 | components: { 41 | schemas: { 42 | TimeModel: { 43 | type: object, 44 | properties: { 45 | utcNow: { 46 | type: string, 47 | description: The current date and time in UTC., 48 | format: date-time 49 | } 50 | }, 51 | description: Represents the current date and time. 52 | } 53 | } 54 | }, 55 | tags: [ 56 | { 57 | name: Time 58 | } 59 | ] 60 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ControllerTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | public class ControllerTests(ITestOutputHelper outputHelper) : DocumentTests(outputHelper) 7 | { 8 | [Fact] 9 | public async Task Schema_Is_Correct_For_Web_Api() 10 | { 11 | // Arrange 12 | using var fixture = new MvcFixture(OutputHelper); 13 | 14 | // Act 15 | var actual = await fixture.GetOpenApiDocumentAsync(); 16 | 17 | // Assert 18 | await VerifyJson(actual, Settings).UniqueForTargetFrameworkAndVersion(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/DocumentTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Routing; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace MartinCostello.OpenApi; 8 | 9 | public abstract class DocumentTests(ITestOutputHelper outputHelper) 10 | { 11 | internal static VerifySettings Settings { get; } = CreateSettings(); 12 | 13 | protected ITestOutputHelper OutputHelper { get; } = outputHelper; 14 | 15 | protected async Task VerifyOpenApiDocumentAsync( 16 | Action configureServices, 17 | Action configureEndpoints, 18 | VerifySettings? settings = null) 19 | { 20 | // Arrange 21 | using var fixture = new MinimalFixture(configureServices, configureEndpoints, OutputHelper); 22 | 23 | // Act 24 | var actual = await fixture.GetOpenApiDocumentAsync(); 25 | 26 | // Assert 27 | await VerifyJson(actual, settings ?? Settings).UniqueForTargetFrameworkAndVersion(); 28 | } 29 | 30 | private static VerifySettings CreateSettings() 31 | { 32 | var settings = new VerifySettings(); 33 | 34 | settings.DontScrubDateTimes(); 35 | settings.DontScrubGuids(); 36 | 37 | return settings; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_Boolean_value=False.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | false -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_Boolean_value=True.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | true -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_Complex_Object.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Boolean: true, 3 | Integer: 42, 4 | Long: 42, 5 | String: hello world, 6 | DateTime: 2024-07-01T12:34:56Z, 7 | DateTimeOffset: 2024-07-01T12:34:56+00:00, 8 | Child: { 9 | Boolean: false, 10 | Integer: 2147483647, 11 | Long: 9.223372036854776E+18, 12 | String: nested, 13 | DateTime: 2024-06-01T12:34:56Z, 14 | DateTimeOffset: 2024-06-01T12:34:56+00:00, 15 | Child: null 16 | }, 17 | Children: [ 18 | { 19 | Boolean: true, 20 | Integer: -2147483648, 21 | Long: -9.223372036854776E+18, 22 | String: first, 23 | DateTime: 2024-05-01T12:34:56Z, 24 | DateTimeOffset: 2024-05-01T12:34:56+00:00, 25 | Child: null 26 | }, 27 | { 28 | Boolean: false, 29 | Integer: 0, 30 | Long: 0, 31 | String: second, 32 | DateTime: 2024-04-01T12:34:56Z, 33 | DateTimeOffset: 2024-04-01T12:34:56+00:00, 34 | Child: null 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_Integer.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | 42 -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_Long.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | 42 -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_Null_String.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.Can_Serialize_String.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | hello world -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/ExampleFormatterTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | using Microsoft.OpenApi; 6 | using Microsoft.OpenApi.Writers; 7 | 8 | namespace MartinCostello.OpenApi; 9 | 10 | public static partial class ExampleFormatterTests 11 | { 12 | [Theory] 13 | [InlineData(false)] 14 | [InlineData(true)] 15 | public static async Task Can_Serialize_Boolean(bool value) 16 | { 17 | // Act 18 | var actual = Serialize(value); 19 | 20 | // Assert 21 | await VerifyJson(actual).UseParameters(value).UniqueForTargetFrameworkAndVersion(); 22 | } 23 | 24 | [Fact] 25 | public static async Task Can_Serialize_Integer() 26 | { 27 | // Arrange 28 | int value = 42; 29 | 30 | // Act 31 | var actual = Serialize(value); 32 | 33 | // Assert 34 | await VerifyJson(actual).UniqueForTargetFrameworkAndVersion(); 35 | } 36 | 37 | [Fact] 38 | public static async Task Can_Serialize_Long() 39 | { 40 | // Arrange 41 | long value = 42; 42 | 43 | // Act 44 | var actual = Serialize(value); 45 | 46 | // Assert 47 | await VerifyJson(actual).UniqueForTargetFrameworkAndVersion(); 48 | } 49 | 50 | [Fact] 51 | public static async Task Can_Serialize_String() 52 | { 53 | // Arrange 54 | string value = "hello world"; 55 | 56 | // Act 57 | var actual = Serialize(value); 58 | 59 | // Assert 60 | await VerifyJson(actual).UniqueForTargetFrameworkAndVersion(); 61 | } 62 | 63 | [Fact] 64 | public static async Task Can_Serialize_Null_String() 65 | { 66 | // Arrange 67 | string? value = null; 68 | 69 | // Act 70 | var actual = Serialize(value); 71 | 72 | // Assert 73 | await VerifyJson(actual).UniqueForTargetFrameworkAndVersion(); 74 | } 75 | 76 | [Fact] 77 | public static async Task Can_Serialize_Complex_Object() 78 | { 79 | // Arrange 80 | var value = new Custom() 81 | { 82 | Boolean = true, 83 | DateTime = new DateTime(2024, 7, 1, 12, 34, 56, DateTimeKind.Utc), 84 | DateTimeOffset = new DateTimeOffset(2024, 7, 1, 12, 34, 56, TimeSpan.Zero), 85 | Integer = 42, 86 | Long = 42, 87 | String = "hello world", 88 | Child = new Custom() 89 | { 90 | Boolean = false, 91 | DateTime = new DateTime(2024, 6, 1, 12, 34, 56, DateTimeKind.Utc), 92 | DateTimeOffset = new DateTimeOffset(2024, 6, 1, 12, 34, 56, TimeSpan.Zero), 93 | Integer = int.MaxValue, 94 | Long = long.MaxValue, 95 | String = "nested", 96 | }, 97 | Children = 98 | [ 99 | new() 100 | { 101 | Boolean = true, 102 | DateTime = new DateTime(2024, 5, 1, 12, 34, 56, DateTimeKind.Utc), 103 | DateTimeOffset = new DateTimeOffset(2024, 5, 1, 12, 34, 56, TimeSpan.Zero), 104 | Integer = int.MinValue, 105 | Long = long.MinValue, 106 | String = "first", 107 | }, 108 | new() 109 | { 110 | Boolean = false, 111 | DateTime = new DateTime(2024, 4, 1, 12, 34, 56, DateTimeKind.Utc), 112 | DateTimeOffset = new DateTimeOffset(2024, 4, 1, 12, 34, 56, TimeSpan.Zero), 113 | Integer = 0, 114 | Long = 0, 115 | String = "second", 116 | }, 117 | ], 118 | }; 119 | 120 | // Act 121 | var actual = Serialize(value); 122 | 123 | // Assert 124 | await VerifyJson(actual, DocumentTests.Settings).UniqueForTargetFrameworkAndVersion(); 125 | } 126 | 127 | private static string? Serialize(T value) 128 | { 129 | var actual = ExampleFormatter.AsJson(value, ReflectionJsonSerializerContext.Default); 130 | 131 | using var stringWriter = new StringWriter(); 132 | var jsonWriter = new OpenApiJsonWriter(stringWriter); 133 | 134 | actual.Write(jsonWriter, OpenApiSpecVersion.OpenApi3_0); 135 | 136 | return stringWriter.ToString(); 137 | } 138 | 139 | private sealed class Custom 140 | { 141 | public bool Boolean { get; set; } 142 | 143 | public int Integer { get; set; } 144 | 145 | public long Long { get; set; } 146 | 147 | public string String { get; set; } = default!; 148 | 149 | public DateTime DateTime { get; set; } 150 | 151 | public DateTimeOffset DateTimeOffset { get; set; } 152 | 153 | public Custom Child { get; set; } = default!; 154 | 155 | public IList Children { get; set; } = []; 156 | } 157 | 158 | [JsonSerializable(typeof(bool))] 159 | [JsonSerializable(typeof(int))] 160 | [JsonSerializable(typeof(long))] 161 | [JsonSerializable(typeof(string))] 162 | [JsonSerializable(typeof(DateTime))] 163 | [JsonSerializable(typeof(DateTimeOffset))] 164 | [JsonSerializable(typeof(Custom))] 165 | [JsonSourceGenerationOptions(NumberHandling = JsonNumberHandling.Strict)] 166 | private sealed partial class ReflectionJsonSerializerContext : JsonSerializerContext; 167 | } 168 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IWebHostBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace MartinCostello.OpenApi; 8 | 9 | internal static class IWebHostBuilderExtensions 10 | { 11 | public static IWebHostBuilder ConfigureXUnitLogging(this IWebHostBuilder builder, ITestOutputHelper outputHelper) 12 | { 13 | return builder.ConfigureLogging((builder) => 14 | { 15 | builder.ClearProviders() 16 | .AddXUnit(outputHelper) 17 | .SetMinimumLevel(LogLevel.Information); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Http_404_Is_Returned_If_Yaml_Document_Not_Found.verified.txt: -------------------------------------------------------------------------------- 1 | No OpenAPI document with the name 'does-not-exist' was found. -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Schema_Is_Correct_For_App.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | openapi: 3.0.4, 3 | info: { 4 | title: TestApp | v1, 5 | version: 1.0.0 6 | }, 7 | servers: [ 8 | { 9 | url: http://localhost 10 | } 11 | ], 12 | paths: { 13 | /hello: { 14 | get: { 15 | tags: [ 16 | TestApp 17 | ], 18 | parameters: [ 19 | { 20 | name: name, 21 | in: query, 22 | description: The name of the person to greet., 23 | schema: { 24 | type: string 25 | }, 26 | example: Martin 27 | } 28 | ], 29 | responses: { 30 | 200: { 31 | description: A greeting., 32 | content: { 33 | application/json: { 34 | schema: { 35 | $ref: #/components/schemas/Greeting 36 | }, 37 | example: { 38 | text: Hello, World! 39 | } 40 | } 41 | } 42 | }, 43 | 400: { 44 | description: No name was provided., 45 | content: { 46 | application/problem+json: { 47 | schema: { 48 | $ref: #/components/schemas/ProblemDetails 49 | }, 50 | example: { 51 | type: https://tools.ietf.org/html/rfc9110#section-15.6.1, 52 | title: Internal Server Error, 53 | status: 500, 54 | detail: An internal error occurred., 55 | instance: /hello 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | }, 64 | components: { 65 | schemas: { 66 | Greeting: { 67 | type: object, 68 | properties: { 69 | text: { 70 | type: string, 71 | description: The text of the greeting., 72 | nullable: true 73 | } 74 | }, 75 | description: Represents a greeting., 76 | example: { 77 | text: Hello, World! 78 | } 79 | }, 80 | ProblemDetails: { 81 | type: object, 82 | properties: { 83 | type: { 84 | type: string, 85 | nullable: true 86 | }, 87 | title: { 88 | type: string, 89 | nullable: true 90 | }, 91 | status: { 92 | type: integer, 93 | format: int32, 94 | nullable: true 95 | }, 96 | detail: { 97 | type: string, 98 | nullable: true 99 | }, 100 | instance: { 101 | type: string, 102 | nullable: true 103 | } 104 | }, 105 | example: { 106 | type: https://tools.ietf.org/html/rfc9110#section-15.6.1, 107 | title: Internal Server Error, 108 | status: 500, 109 | detail: An internal error occurred., 110 | instance: /hello 111 | } 112 | } 113 | } 114 | }, 115 | tags: [ 116 | { 117 | name: TestApp 118 | } 119 | ] 120 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Schema_Is_Correct_For_App_As_Yaml.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | openapi: 3.0.4 2 | info: 3 | title: TestApp | v1 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost 7 | paths: 8 | /hello: 9 | get: 10 | tags: 11 | - TestApp 12 | parameters: 13 | - name: name 14 | in: query 15 | description: The name of the person to greet. 16 | schema: 17 | type: string 18 | example: Martin 19 | responses: 20 | '200': 21 | description: A greeting. 22 | content: 23 | application/json: 24 | schema: 25 | $ref: '#/components/schemas/Greeting' 26 | example: 27 | text: 'Hello, World!' 28 | '400': 29 | description: No name was provided. 30 | content: 31 | application/problem+json: 32 | schema: 33 | $ref: '#/components/schemas/ProblemDetails' 34 | example: 35 | type: https://tools.ietf.org/html/rfc9110#section-15.6.1 36 | title: Internal Server Error 37 | status: 500 38 | detail: An internal error occurred. 39 | instance: /hello 40 | components: 41 | schemas: 42 | Greeting: 43 | type: object 44 | properties: 45 | text: 46 | type: string 47 | description: The text of the greeting. 48 | nullable: true 49 | description: Represents a greeting. 50 | example: 51 | text: 'Hello, World!' 52 | ProblemDetails: 53 | type: object 54 | properties: 55 | type: 56 | type: string 57 | nullable: true 58 | title: 59 | type: string 60 | nullable: true 61 | status: 62 | type: integer 63 | format: int32 64 | nullable: true 65 | detail: 66 | type: string 67 | nullable: true 68 | instance: 69 | type: string 70 | nullable: true 71 | example: 72 | type: https://tools.ietf.org/html/rfc9110#section-15.6.1 73 | title: Internal Server Error 74 | status: 500 75 | detail: An internal error occurred. 76 | instance: /hello 77 | tags: 78 | - name: TestApp -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Schema_Is_Correct_For_Classes.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | openapi: 3.0.4, 3 | info: { 4 | title: TestApp | v1, 5 | version: 1.0.0 6 | }, 7 | paths: { 8 | /register: { 9 | post: { 10 | tags: [ 11 | TestApp 12 | ], 13 | requestBody: { 14 | content: { 15 | application/json: { 16 | schema: { 17 | $ref: #/components/schemas/Animal 18 | }, 19 | example: { 20 | Name: Donald 21 | } 22 | } 23 | }, 24 | required: true 25 | }, 26 | responses: { 27 | 201: { 28 | description: Created 29 | } 30 | } 31 | } 32 | }, 33 | /adopt/cat: { 34 | post: { 35 | tags: [ 36 | TestApp 37 | ], 38 | requestBody: { 39 | content: { 40 | application/json: { 41 | schema: { 42 | $ref: #/components/schemas/Cat 43 | }, 44 | example: { 45 | Name: Donald 46 | } 47 | } 48 | }, 49 | required: true 50 | }, 51 | responses: { 52 | 204: { 53 | description: No Content 54 | } 55 | } 56 | } 57 | }, 58 | /adopt/dog: { 59 | post: { 60 | tags: [ 61 | TestApp 62 | ], 63 | requestBody: { 64 | content: { 65 | application/json: { 66 | schema: { 67 | $ref: #/components/schemas/Dog 68 | }, 69 | example: { 70 | Breed: Greyhound, 71 | Name: Santa's Little Helper 72 | } 73 | } 74 | }, 75 | required: true 76 | }, 77 | responses: { 78 | 204: { 79 | description: No Content 80 | } 81 | } 82 | } 83 | }, 84 | /animals: { 85 | get: { 86 | tags: [ 87 | TestApp 88 | ], 89 | responses: { 90 | 200: { 91 | description: OK, 92 | content: { 93 | application/json: { 94 | schema: { 95 | type: array, 96 | items: { 97 | $ref: #/components/schemas/Animal 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | }, 107 | components: { 108 | schemas: { 109 | Animal: { 110 | type: object, 111 | properties: { 112 | name: { 113 | type: string, 114 | description: The name of the animal., 115 | nullable: true 116 | } 117 | }, 118 | description: A class representing an animal., 119 | example: { 120 | Name: Donald 121 | } 122 | }, 123 | Cat: { 124 | type: object, 125 | properties: { 126 | color: { 127 | type: string, 128 | description: The color of the cat., 129 | nullable: true 130 | }, 131 | name: { 132 | type: string, 133 | description: The name of the animal., 134 | nullable: true 135 | } 136 | }, 137 | description: A class representing a cat., 138 | example: { 139 | Name: Donald 140 | } 141 | }, 142 | Dog: { 143 | type: object, 144 | properties: { 145 | breed: { 146 | type: string, 147 | description: The breed of the dog., 148 | nullable: true 149 | }, 150 | age: { 151 | type: integer, 152 | description: The age of the dog, if known., 153 | format: int32, 154 | nullable: true 155 | }, 156 | name: { 157 | type: string, 158 | description: The name of the animal., 159 | nullable: true 160 | } 161 | }, 162 | description: A class representing a dog., 163 | example: { 164 | Breed: Greyhound, 165 | Name: Santa's Little Helper 166 | } 167 | } 168 | } 169 | }, 170 | tags: [ 171 | { 172 | name: TestApp 173 | } 174 | ] 175 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Schema_Is_Correct_For_Interfaces.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | openapi: 3.0.4, 3 | info: { 4 | title: TestApp | v1, 5 | version: 1.0.0 6 | }, 7 | paths: { 8 | /register: { 9 | post: { 10 | tags: [ 11 | TestApp 12 | ], 13 | requestBody: { 14 | content: { 15 | application/json: { 16 | schema: { 17 | $ref: #/components/schemas/IAnimal 18 | }, 19 | example: { 20 | Name: Daisy 21 | } 22 | } 23 | }, 24 | required: true 25 | }, 26 | responses: { 27 | 201: { 28 | description: Created 29 | } 30 | } 31 | } 32 | } 33 | }, 34 | components: { 35 | schemas: { 36 | IAnimal: { 37 | type: object, 38 | properties: { 39 | name: { 40 | type: string, 41 | description: The name of the animal., 42 | nullable: true 43 | } 44 | }, 45 | description: Represents an animal., 46 | example: { 47 | Name: Daisy 48 | } 49 | } 50 | } 51 | }, 52 | tags: [ 53 | { 54 | name: TestApp 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Schema_Is_Correct_For_Not_Json.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | openapi: 3.0.4, 3 | info: { 4 | title: TestApp | v1, 5 | version: 1.0.0 6 | }, 7 | servers: [ 8 | { 9 | url: http://localhost 10 | } 11 | ], 12 | paths: { 13 | /greet: { 14 | get: { 15 | tags: [ 16 | IntegrationTests 17 | ], 18 | responses: { 19 | 200: { 20 | description: OK, 21 | content: { 22 | text/plain: { 23 | schema: { 24 | type: string 25 | }, 26 | example: Bonjour! 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | }, 34 | tags: [ 35 | { 36 | name: IntegrationTests 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/IntegrationTests.Schema_Is_Correct_For_Records.DotNet9_0.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | openapi: 3.0.4, 3 | info: { 4 | title: TestApp | v1, 5 | version: 1.0.0 6 | }, 7 | paths: { 8 | /car: { 9 | post: { 10 | tags: [ 11 | TestApp 12 | ], 13 | requestBody: { 14 | content: { 15 | application/json: { 16 | schema: { 17 | $ref: #/components/schemas/Car 18 | } 19 | } 20 | }, 21 | required: true 22 | }, 23 | responses: { 24 | 204: { 25 | description: No Content 26 | } 27 | } 28 | } 29 | }, 30 | /vehicles: { 31 | get: { 32 | tags: [ 33 | TestApp 34 | ], 35 | responses: { 36 | 200: { 37 | description: OK, 38 | content: { 39 | application/json: { 40 | schema: { 41 | type: array, 42 | items: { 43 | $ref: #/components/schemas/Vehicle 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | }, 53 | components: { 54 | schemas: { 55 | Car: { 56 | required: [ 57 | type, 58 | wheels, 59 | manufacturer 60 | ], 61 | type: object, 62 | properties: { 63 | type: { 64 | $ref: #/components/schemas/CarType 65 | }, 66 | wheels: { 67 | type: integer, 68 | description: The number of wheels the vehicle has., 69 | format: int32 70 | }, 71 | manufacturer: { 72 | type: string, 73 | description: The name of the manufacturer. 74 | } 75 | }, 76 | description: Represents a car. 77 | }, 78 | CarType: { 79 | type: integer, 80 | description: The type of the car. 81 | }, 82 | Vehicle: { 83 | required: [ 84 | wheels, 85 | manufacturer 86 | ], 87 | type: object, 88 | properties: { 89 | wheels: { 90 | type: integer, 91 | description: The number of wheels the vehicle has., 92 | format: int32 93 | }, 94 | manufacturer: { 95 | type: string, 96 | description: The name of the manufacturer. 97 | } 98 | }, 99 | description: Represents a vehicle. 100 | } 101 | } 102 | }, 103 | tags: [ 104 | { 105 | name: TestApp 106 | } 107 | ] 108 | } -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/MartinCostello.OpenApi.Extensions.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tests for MartinCostello.OpenApi.Extensions. 4 | true 5 | false 6 | false 7 | Exe 8 | MartinCostello.OpenApi 9 | net9.0 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | true 34 | 85 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/MinimalFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Mvc.Testing; 7 | using Microsoft.AspNetCore.Routing; 8 | using Microsoft.Extensions.DependencyInjection; 9 | 10 | namespace MartinCostello.OpenApi; 11 | 12 | public sealed class MinimalFixture( 13 | Action configureServices, 14 | Action configureEndpoints, 15 | ITestOutputHelper outputHelper) : WebApplicationFactory() 16 | { 17 | protected override void ConfigureWebHost(IWebHostBuilder builder) 18 | { 19 | builder.ConfigureXUnitLogging(outputHelper); 20 | builder.ConfigureServices((services) => 21 | { 22 | services.AddSingleton(new ConfigureEndpointsFilter(configureEndpoints)); 23 | configureServices(services); 24 | }); 25 | } 26 | 27 | private sealed class ConfigureEndpointsFilter(Action configure) : IStartupFilter 28 | { 29 | public Action Configure(Action next) 30 | { 31 | return (builder) => 32 | { 33 | next(builder); 34 | builder.UseEndpoints(configure); 35 | }; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/MvcFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.WebApi.Models; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Mvc.Testing; 7 | 8 | namespace MartinCostello.OpenApi; 9 | 10 | public sealed class MvcFixture(ITestOutputHelper outputHelper) : WebApplicationFactory() 11 | { 12 | protected override void ConfigureWebHost(IWebHostBuilder builder) 13 | => builder.ConfigureXUnitLogging(outputHelper); 14 | } 15 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/OpenApiEndpointConventionBuilderExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | 6 | namespace MartinCostello.OpenApi; 7 | 8 | public static class OpenApiEndpointConventionBuilderExtensionsTests 9 | { 10 | [Fact] 11 | public static void ProducesOpenApiResponse_Throws_If_Builder_Is_Null() 12 | { 13 | // Arrange 14 | IEndpointConventionBuilder builder = null!; 15 | 16 | // Act and Assert 17 | Should.Throw(() => builder.ProducesOpenApiResponse(200, "OK")); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/OpenApiExampleAttributeTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using System.Text.Json.Serialization; 6 | using Microsoft.OpenApi.Writers; 7 | 8 | namespace MartinCostello.OpenApi; 9 | 10 | public static partial class OpenApiExampleAttributeTests 11 | { 12 | [Fact] 13 | public static void OpenApiExampleAttribute_Constructor_Initializes_Instance() 14 | { 15 | // Arrange 16 | var value = "example"; 17 | 18 | // Act 19 | var actual = new OpenApiExampleAttribute(value); 20 | 21 | // Assert 22 | actual.Value.ShouldBe(value); 23 | actual.GenerateExample().ShouldBe(value); 24 | } 25 | 26 | [Fact] 27 | public static void OpenApiExampleAttribute_IExampleProvider_Implementation_Is_Correct() 28 | { 29 | // Act 30 | var actual = GetExample(); 31 | 32 | // Assert 33 | actual.ShouldBe("value"); 34 | } 35 | 36 | [Fact] 37 | public static void OpenApiExampleAttributeT1_Constructor_Initializes_Instance() 38 | { 39 | // Act 40 | var actual = new OpenApiExampleAttribute(); 41 | 42 | // Assert 43 | actual.ExampleType.ShouldBe(typeof(Human)); 44 | } 45 | 46 | [Fact] 47 | public static void OpenApiExampleAttributeT1_GenerateExample_Returns_Correct_Value() 48 | { 49 | // Arrange 50 | var target = new OpenApiExampleAttribute(); 51 | 52 | // Act 53 | var actual = target.GenerateExample(); 54 | 55 | // Assert 56 | actual.ShouldNotBeNull(); 57 | actual.Name.ShouldBe("Martin"); 58 | } 59 | 60 | [Fact] 61 | public static void OpenApiExampleAttributeT2_Constructor_Initializes_Instance() 62 | { 63 | // Act 64 | var actual = new OpenApiExampleAttribute(); 65 | 66 | // Assert 67 | actual.ExampleType.ShouldBe(typeof(Person)); 68 | } 69 | 70 | [Fact] 71 | public static void OpenApiExampleAttributeT2_GenerateExample_Returns_Correct_Value() 72 | { 73 | // Arrange 74 | var target = new OpenApiExampleAttribute(); 75 | 76 | // Act 77 | var actual = target.GenerateExample(); 78 | 79 | // Assert 80 | actual.ShouldNotBeNull(); 81 | actual.Name.ShouldBe("Martin"); 82 | } 83 | 84 | [Theory] 85 | [InlineData(false, "Name")] 86 | [InlineData(true, "name")] 87 | public static void OpenApiExampleAttributeT2_GenerateExample_As_IOpenApiExampleMetadata_Returns_Correct_Value( 88 | bool camelCase, 89 | string expectedPropertyName) 90 | { 91 | // Arrange 92 | IOpenApiExampleMetadata target = new OpenApiExampleAttribute(); 93 | JsonSerializerContext context = camelCase ? PersonJsonSerializationContextCamel.Default : PersonJsonSerializationContextPascal.Default; 94 | 95 | // Act 96 | var actual = target.GenerateExample(context); 97 | 98 | // Assert 99 | actual.ShouldNotBeNull(); 100 | 101 | // Arrange 102 | using var stringWriter = new StringWriter(); 103 | var jsonWriter = new OpenApiJsonWriter(stringWriter); 104 | 105 | // Act 106 | actual.Write(jsonWriter, Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0); 107 | 108 | // Assert 109 | using var document = JsonDocument.Parse(stringWriter.ToString()); 110 | 111 | document.ShouldNotBeNull(); 112 | document.RootElement.ValueKind.ShouldBe(JsonValueKind.Object); 113 | document.RootElement.EnumerateObject().Count().ShouldBe(1); 114 | document.RootElement.TryGetProperty(expectedPropertyName, out var value).ShouldBeTrue(); 115 | 116 | value.ValueKind.ShouldBe(JsonValueKind.String); 117 | value.GetString().ShouldBe("Martin"); 118 | } 119 | 120 | private static TSchema GetExample() 121 | where TProvider : IExampleProvider 122 | => TProvider.GenerateExample(); 123 | 124 | private sealed record Human(string Name) : IExampleProvider 125 | { 126 | public static Human GenerateExample() => new("Martin"); 127 | } 128 | 129 | private sealed record Person(string Name); 130 | 131 | private sealed class PersonExampleProvider : IExampleProvider 132 | { 133 | public static Person GenerateExample() => new("Martin"); 134 | } 135 | 136 | [JsonSerializable(typeof(Person))] 137 | [JsonSourceGenerationOptions( 138 | NumberHandling = JsonNumberHandling.Strict, 139 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] 140 | private sealed partial class PersonJsonSerializationContextCamel : JsonSerializerContext; 141 | 142 | [JsonSerializable(typeof(Person))] 143 | [JsonSourceGenerationOptions( 144 | NumberHandling = JsonNumberHandling.Strict, 145 | PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified)] 146 | private sealed partial class PersonJsonSerializationContextPascal : JsonSerializerContext; 147 | } 148 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/OpenApiExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.OpenApi; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace MartinCostello.OpenApi; 9 | 10 | public static class OpenApiExtensionsTests 11 | { 12 | [Fact] 13 | public static void AddOpenApiExtensions_Validates_Arguments() 14 | { 15 | // Arrange 16 | string documentName = "api"; 17 | 18 | IServiceCollection services = null!; 19 | 20 | // Act and Assert 21 | Should.Throw(services.AddOpenApiExtensions).ParamName.ShouldBe("services"); 22 | Should.Throw(() => services.AddOpenApiExtensions(documentName)).ParamName.ShouldBe("services"); 23 | Should.Throw(() => services.AddOpenApiExtensions(documentName, ConfigureOneService)).ParamName.ShouldBe("services"); 24 | Should.Throw(() => services.AddOpenApiExtensions(documentName, ConfigureTwoServices)).ParamName.ShouldBe("services"); 25 | Should.Throw(() => services.AddOpenApiExtensions(ConfigureOneService)).ParamName.ShouldBe("services"); 26 | Should.Throw(() => services.AddOpenApiExtensions(ConfigureTwoServices)).ParamName.ShouldBe("services"); 27 | 28 | // Arrange 29 | services = new ServiceCollection(); 30 | Action configureOne = null!; 31 | Action configureTwo = null!; 32 | 33 | // Act and Assert 34 | Should.Throw(() => services.AddOpenApiExtensions(configureOne)).ParamName.ShouldBe("configureOptions"); 35 | Should.Throw(() => services.AddOpenApiExtensions(configureTwo)).ParamName.ShouldBe("configureOptions"); 36 | Should.Throw(() => services.AddOpenApiExtensions(documentName, configureOne)).ParamName.ShouldBe("configureOptions"); 37 | Should.Throw(() => services.AddOpenApiExtensions(documentName, configureTwo)).ParamName.ShouldBe("configureOptions"); 38 | 39 | static void ConfigureOneService(OpenApiExtensionsOptions options) 40 | => options.ShouldNotBeNull(); 41 | 42 | static void ConfigureTwoServices(OpenApiOptions first, OpenApiExtensionsOptions second) 43 | { 44 | first.ShouldNotBeNull(); 45 | second.ShouldNotBeNull(); 46 | } 47 | } 48 | 49 | [Fact] 50 | public static void AddOpenApiExtensions_Throws_If_Examples_Enabled_But_Not_JsonSerializerContext_Specified() 51 | { 52 | // Arrange 53 | var services = new ServiceCollection(); 54 | 55 | services.AddOpenApi(); 56 | services.AddOpenApiExtensions((options) => 57 | { 58 | options.AddExamples = true; 59 | options.SerializationContexts.Clear(); 60 | }); 61 | 62 | using var serviceProvider = services.BuildServiceProvider(); 63 | var monitor = serviceProvider.GetRequiredService>(); 64 | 65 | // Act and Assert 66 | var exception = Should.Throw(() => monitor.Get("v1")); 67 | exception.Message.ShouldBe(@"No JsonSerializerContext has been configured on the OpenApiExtensionsOptions instance for the OpenAPI document ""v1""."); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/OpenApiResponseAttributeTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | public static class OpenApiResponseAttributeTests 7 | { 8 | [Fact] 9 | public static void OpenApiResponseAttribute_Constructor_Initializes_Instance() 10 | { 11 | // Arrange 12 | var statusCode = 200; 13 | var description = "OK"; 14 | 15 | // Act 16 | var actual = new OpenApiResponseAttribute(statusCode, description); 17 | 18 | // Assert 19 | actual.HttpStatusCode.ShouldBe(statusCode); 20 | actual.Description.ShouldBe(description); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/OpenApi.Extensions.Tests/WebApplicationFactoryExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Mvc.Testing; 5 | 6 | namespace MartinCostello.OpenApi; 7 | 8 | internal static class WebApplicationFactoryExtensions 9 | { 10 | public static async Task GetOpenApiDocumentAsync(this WebApplicationFactory fixture) 11 | where T : class 12 | { 13 | using var client = fixture.CreateDefaultClient(); 14 | return await client.GetStringAsync("/openapi/v1.json"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/TestApp/AppJsonSerializationContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace MartinCostello.OpenApi; 8 | 9 | /// 10 | /// The to use for the application. This class cannot be inherited. 11 | /// 12 | [JsonSerializable(typeof(Greeting))] 13 | [JsonSerializable(typeof(ProblemDetails))] 14 | [JsonSerializable(typeof(string))] 15 | [JsonSourceGenerationOptions( 16 | NumberHandling = JsonNumberHandling.Strict, 17 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, 18 | WriteIndented = true)] 19 | public sealed partial class AppJsonSerializationContext : JsonSerializerContext; 20 | -------------------------------------------------------------------------------- /tests/TestApp/Greeting.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.OpenApi; 5 | 6 | /// 7 | /// Represents a greeting. 8 | /// 9 | [OpenApiExample] 10 | public sealed class Greeting : IExampleProvider 11 | { 12 | /// 13 | /// Gets or sets the text of the greeting. 14 | /// 15 | public string? Text { get; set; } 16 | 17 | /// 18 | public static Greeting GenerateExample() 19 | { 20 | return new() { Text = "Hello, World!" }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/TestApp/ProblemDetailsExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace MartinCostello.OpenApi; 7 | 8 | /// 9 | /// A class representing an example provider for . 10 | /// 11 | public sealed class ProblemDetailsExampleProvider : IExampleProvider 12 | { 13 | /// 14 | public static ProblemDetails GenerateExample() 15 | { 16 | return new() 17 | { 18 | Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", 19 | Title = "Internal Server Error", 20 | Status = StatusCodes.Status500InternalServerError, 21 | Detail = "An internal error occurred.", 22 | Instance = "/hello", 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/TestApp/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.WebUtilities; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | builder.Services.AddProblemDetails(); 9 | builder.Services.Configure((options) => 10 | { 11 | options.CustomizeProblemDetails = (context) => 12 | { 13 | if (context.Exception is not null) 14 | { 15 | context.ProblemDetails.Detail = "An internal error occurred."; 16 | } 17 | 18 | context.ProblemDetails.Instance = context.HttpContext.Request.Path; 19 | context.ProblemDetails.Title = ReasonPhrases.GetReasonPhrase(context.ProblemDetails.Status ?? StatusCodes.Status500InternalServerError); 20 | }; 21 | }); 22 | 23 | var app = builder.Build(); 24 | 25 | app.MapOpenApi(); 26 | 27 | app.Run(); 28 | 29 | namespace MartinCostello.OpenApi 30 | { 31 | public partial class Program 32 | { 33 | // Expose the Program class for use with WebApplicationFactory 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/TestApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "TestApp": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "https://localhost:55219;http://localhost:55220" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/TestApp/TestApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test application. 4 | true 5 | MartinCostello.OpenApi 6 | net9.0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/WebApi/Controllers/TimeController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.WebApi.Models; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace MartinCostello.WebApi.Controllers; 8 | 9 | /// 10 | /// Represents the controller for the time API. 11 | /// 12 | [ApiController] 13 | [Route("api/[controller]")] 14 | public class TimeController(TimeProvider timeProvider) : ControllerBase 15 | { 16 | /// 17 | /// Gets the current date and time. 18 | /// 19 | /// 20 | /// The current date and time. 21 | /// 22 | /// 23 | /// The current date and time is returned in Coordinated Universal Time (UTC). 24 | /// 25 | /// 26 | /// {"utcNow":"2025-03-13T10:18:37.8147935+00:00"} 27 | /// 28 | [HttpGet] 29 | public ActionResult Now() 30 | => Ok(new TimeModel { UtcNow = timeProvider.GetUtcNow() }); 31 | } 32 | -------------------------------------------------------------------------------- /tests/WebApi/Models/TimeModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.WebApi.Models; 5 | 6 | /// 7 | /// Represents the current date and time. 8 | /// 9 | public class TimeModel 10 | { 11 | /// 12 | /// Gets or sets the current date and time in UTC. 13 | /// 14 | public DateTimeOffset UtcNow { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /tests/WebApi/MvcSerializerContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | using MartinCostello.WebApi.Models; 6 | 7 | namespace MartinCostello.WebApi; 8 | 9 | [JsonSerializable(typeof(DateTimeOffset))] 10 | [JsonSerializable(typeof(TimeModel))] 11 | [JsonSourceGenerationOptions( 12 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, 13 | NumberHandling = JsonNumberHandling.Strict, 14 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, 15 | WriteIndented = true)] 16 | public sealed partial class MvcSerializerContext : JsonSerializerContext; 17 | -------------------------------------------------------------------------------- /tests/WebApi/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2024. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using MartinCostello.OpenApi; 6 | using MartinCostello.WebApi; 7 | using MartinCostello.WebApi.Models; 8 | 9 | var builder = WebApplication.CreateBuilder(args); 10 | 11 | builder.Services.AddSingleton(TimeProvider.System); 12 | 13 | builder.Services.AddControllers().AddJsonOptions((p) => ConfigureJsonSerialization(p.JsonSerializerOptions)); 14 | builder.Services.ConfigureHttpJsonOptions((p) => ConfigureJsonSerialization(p.SerializerOptions)); 15 | 16 | builder.Services.AddOpenApi(); 17 | builder.Services.AddOpenApiExtensions((p) => p.AddXmlComments()); 18 | 19 | var app = builder.Build(); 20 | 21 | app.UseRouting(); 22 | 23 | app.MapControllerRoute( 24 | name: "default", 25 | pattern: "{controller=Time}/{action=Now}/{id?}"); 26 | 27 | app.MapOpenApi(); 28 | 29 | app.Run(); 30 | 31 | static void ConfigureJsonSerialization(JsonSerializerOptions options) 32 | => options.TypeInfoResolverChain.Add(MvcSerializerContext.Default); 33 | -------------------------------------------------------------------------------- /tests/WebApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "WebApi": { 5 | "commandName": "Project", 6 | "launchBrowser": true, 7 | "launchUrl": "https://localhost:5100/api/time", 8 | "environmentVariables": { 9 | "ASPNETCORE_ENVIRONMENT": "Development" 10 | }, 11 | "applicationUrl": "https://localhost:5100;http://localhost:5101" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/WebApi/WebApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Test MVC Web API application. 4 | true 5 | $(NoWarn);SA1629 6 | MartinCostello.WebApi 7 | net9.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/WebApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/WebApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information", 6 | "Microsoft.AspNetCore": "Warning" 7 | } 8 | } 9 | } 10 | --------------------------------------------------------------------------------